FollowerService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. <?php
  2. namespace App\Services;
  3. use App\Follower;
  4. use App\Jobs\FollowPipeline\FollowServiceWarmCache;
  5. use App\Profile;
  6. use Cache;
  7. use DB;
  8. use Illuminate\Support\Facades\Redis;
  9. class FollowerService
  10. {
  11. const CACHE_KEY = 'pf:services:followers:';
  12. const FOLLOWERS_SYNC_KEY = 'pf:services:followers:sync-followers:';
  13. const FOLLOWING_SYNC_KEY = 'pf:services:followers:sync-following:';
  14. const FOLLOWING_KEY = 'pf:services:follow:following:id:';
  15. const FOLLOWERS_KEY = 'pf:services:follow:followers:id:';
  16. const FOLLOWERS_LOCAL_KEY = 'pf:services:follow:local-follower-ids:v1:';
  17. const FOLLOWERS_INTER_KEY = 'pf:services:follow:followers:inter:id:';
  18. const FOLLOWERS_MUTUALS_KEY = 'pf:services:follow:mutuals:';
  19. public static function add($actor, $target, $refresh = true)
  20. {
  21. $ts = (int) microtime(true);
  22. if ($refresh) {
  23. RelationshipService::refresh($actor, $target);
  24. } else {
  25. RelationshipService::forget($actor, $target);
  26. }
  27. Redis::zadd(self::FOLLOWING_KEY.$actor, $ts, $target);
  28. Redis::zadd(self::FOLLOWERS_KEY.$target, $ts, $actor);
  29. Cache::forget('profile:following:'.$actor);
  30. Cache::forget(self::FOLLOWERS_LOCAL_KEY.$actor);
  31. Cache::forget(self::FOLLOWERS_LOCAL_KEY.$target);
  32. Redis::del(self::FOLLOWERS_MUTUALS_KEY.$actor);
  33. Redis::del(self::FOLLOWERS_MUTUALS_KEY.$target);
  34. }
  35. public static function remove($actor, $target, $silent = false)
  36. {
  37. Redis::zrem(self::FOLLOWING_KEY.$actor, $target);
  38. Redis::zrem(self::FOLLOWERS_KEY.$target, $actor);
  39. Cache::forget(self::FOLLOWERS_LOCAL_KEY.$actor);
  40. Cache::forget(self::FOLLOWERS_LOCAL_KEY.$target);
  41. Redis::del(self::FOLLOWERS_MUTUALS_KEY.$actor);
  42. Redis::del(self::FOLLOWERS_MUTUALS_KEY.$target);
  43. if ($silent !== true) {
  44. AccountService::del($actor);
  45. AccountService::del($target);
  46. RelationshipService::refresh($actor, $target);
  47. Cache::forget('profile:following:'.$actor);
  48. } else {
  49. RelationshipService::forget($actor, $target);
  50. }
  51. }
  52. public static function followers($id, $start = 0, $stop = 10)
  53. {
  54. self::cacheSyncCheck($id, 'followers');
  55. return Redis::zrevrange(self::FOLLOWERS_KEY.$id, $start, $stop);
  56. }
  57. public static function following($id, $start = 0, $stop = 10)
  58. {
  59. self::cacheSyncCheck($id, 'following');
  60. return Redis::zrevrange(self::FOLLOWING_KEY.$id, $start, $stop);
  61. }
  62. public static function followersPaginate($id, $page = 1, $limit = 10)
  63. {
  64. $start = $page == 1 ? 0 : $page * $limit - $limit;
  65. $end = $start + ($limit - 1);
  66. return self::followers($id, $start, $end);
  67. }
  68. public static function followingPaginate($id, $page = 1, $limit = 10)
  69. {
  70. $start = $page == 1 ? 0 : $page * $limit - $limit;
  71. $end = $start + ($limit - 1);
  72. return self::following($id, $start, $end);
  73. }
  74. public static function followerCount($id, $warmCache = true)
  75. {
  76. if ($warmCache) {
  77. self::cacheSyncCheck($id, 'followers');
  78. }
  79. return Redis::zCard(self::FOLLOWERS_KEY.$id);
  80. }
  81. public static function followingCount($id, $warmCache = true)
  82. {
  83. if ($warmCache) {
  84. self::cacheSyncCheck($id, 'following');
  85. }
  86. return Redis::zCard(self::FOLLOWING_KEY.$id);
  87. }
  88. public static function follows(string $actor, string $target, $quickCheck = false)
  89. {
  90. if ($actor == $target) {
  91. return false;
  92. }
  93. if ($quickCheck) {
  94. return (bool) Redis::zScore(self::FOLLOWERS_KEY.$target, $actor);
  95. }
  96. if (self::followerCount($target, false) && self::followingCount($actor, false)) {
  97. self::cacheSyncCheck($target, 'followers');
  98. return (bool) Redis::zScore(self::FOLLOWERS_KEY.$target, $actor);
  99. } else {
  100. self::cacheSyncCheck($target, 'followers');
  101. self::cacheSyncCheck($actor, 'following');
  102. return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
  103. }
  104. }
  105. public static function cacheSyncCheck($id, $scope = 'followers')
  106. {
  107. if ($scope === 'followers') {
  108. if (Cache::get(self::FOLLOWERS_SYNC_KEY.$id) != null) {
  109. return;
  110. }
  111. FollowServiceWarmCache::dispatch($id)->onQueue('low');
  112. }
  113. if ($scope === 'following') {
  114. if (Cache::get(self::FOLLOWING_SYNC_KEY.$id) != null) {
  115. return;
  116. }
  117. FollowServiceWarmCache::dispatch($id)->onQueue('low');
  118. }
  119. }
  120. public static function audience($profile, $scope = null)
  121. {
  122. return (new self)->getAudienceInboxes($profile, $scope);
  123. }
  124. public static function softwareAudience($profile, $software = 'pixelfed')
  125. {
  126. return collect(self::audience($profile))
  127. ->filter(function ($inbox) use ($software) {
  128. $domain = parse_url($inbox, PHP_URL_HOST);
  129. if (! $domain) {
  130. return false;
  131. }
  132. return InstanceService::software($domain) === strtolower($software);
  133. })
  134. ->unique()
  135. ->values()
  136. ->toArray();
  137. }
  138. protected function getAudienceInboxes($pid, $scope = null)
  139. {
  140. $key = 'pf:services:follower:audience:'.$pid;
  141. $bannedDomains = InstanceService::getBannedDomains();
  142. $domains = Cache::remember($key, 432000, function () use ($pid, $bannedDomains) {
  143. $profile = Profile::whereNull(['status', 'domain'])->find($pid);
  144. if (! $profile) {
  145. return [];
  146. }
  147. return DB::table('followers')
  148. ->join('profiles', 'followers.profile_id', '=', 'profiles.id')
  149. ->where('followers.following_id', $pid)
  150. ->whereNotNull('profiles.inbox_url')
  151. ->whereNull('profiles.deleted_at')
  152. ->select('followers.profile_id', 'followers.following_id', 'profiles.id', 'profiles.user_id', 'profiles.deleted_at', 'profiles.sharedInbox', 'profiles.inbox_url')
  153. ->get()
  154. ->map(function ($r) {
  155. return $r->sharedInbox ?? $r->inbox_url;
  156. })
  157. ->filter(function ($r) use ($bannedDomains) {
  158. $domain = parse_url($r, PHP_URL_HOST);
  159. return $r && ! in_array($domain, $bannedDomains);
  160. })
  161. ->unique()
  162. ->values();
  163. });
  164. if (! $domains || ! $domains->count()) {
  165. return [];
  166. }
  167. $banned = InstanceService::getBannedDomains();
  168. if (! $banned || count($banned) === 0) {
  169. return $domains->toArray();
  170. }
  171. $res = $domains->filter(function ($domain) use ($banned) {
  172. $parsed = parse_url($domain, PHP_URL_HOST);
  173. return ! in_array($parsed, $banned);
  174. })
  175. ->values()
  176. ->toArray();
  177. return $res;
  178. }
  179. public static function mutualCount($pid, $mid)
  180. {
  181. return Cache::remember(self::CACHE_KEY.':mutualcount:'.$pid.':'.$mid, 3600, function () use ($pid, $mid) {
  182. return DB::table('followers as u')
  183. ->join('followers as s', 'u.following_id', '=', 's.following_id')
  184. ->where('s.profile_id', $mid)
  185. ->where('u.profile_id', $pid)
  186. ->count();
  187. });
  188. }
  189. public static function mutualIds($pid, $mid, $limit = 3)
  190. {
  191. $key = self::CACHE_KEY.':mutualids:'.$pid.':'.$mid.':limit_'.$limit;
  192. return Cache::remember($key, 3600, function () use ($pid, $mid, $limit) {
  193. return DB::table('followers as u')
  194. ->join('followers as s', 'u.following_id', '=', 's.following_id')
  195. ->where('s.profile_id', $mid)
  196. ->where('u.profile_id', $pid)
  197. ->limit($limit)
  198. ->pluck('s.following_id')
  199. ->toArray();
  200. });
  201. }
  202. public static function mutualAccounts($actorId, $profileId)
  203. {
  204. if ($actorId == $profileId) {
  205. return [];
  206. }
  207. $actorKey = self::FOLLOWING_KEY.$actorId;
  208. $profileKey = self::FOLLOWERS_KEY.$profileId;
  209. $key = self::FOLLOWERS_INTER_KEY.$actorId.':'.$profileId;
  210. $res = Redis::zinterstore($key, [$actorKey, $profileKey]);
  211. if ($res) {
  212. return Redis::zrange($key, 0, -1);
  213. } else {
  214. return [];
  215. }
  216. }
  217. /**
  218. * Get mutual followers for DM suggestions using Redis set intersection
  219. * This is extremely fast as it operates entirely in Redis memory
  220. *
  221. * @param int $profileId
  222. * @param int $limit
  223. * @param int|null $cursor
  224. * @return array
  225. */
  226. public static function getMutualsForDM($profileId, $limit = 20, $cursor = null)
  227. {
  228. $acct = AccountService::get($profileId, true);
  229. if (! $acct || ! isset($acct['id'])) {
  230. return [
  231. 'data' => [],
  232. 'has_more' => false,
  233. 'next_cursor' => null,
  234. 'total_count' => 0,
  235. ];
  236. }
  237. self::cacheSyncCheck($profileId, 'followers');
  238. self::cacheSyncCheck($profileId, 'following');
  239. $followingKey = self::FOLLOWING_KEY.$profileId;
  240. $followersKey = self::FOLLOWERS_KEY.$profileId;
  241. $mutualsKey = self::FOLLOWERS_MUTUALS_KEY.$profileId;
  242. $ttl = Redis::ttl($mutualsKey);
  243. if ($ttl === -2 || $ttl < 300) {
  244. Redis::zinterstore($mutualsKey, [$followingKey, $followersKey]);
  245. Redis::expire($mutualsKey, 7200);
  246. }
  247. $start = 0;
  248. if ($cursor) {
  249. $cursorRank = Redis::zrank($mutualsKey, $cursor);
  250. if ($cursorRank !== false) {
  251. $start = $cursorRank + 1;
  252. }
  253. }
  254. $mutuals = Redis::zrange($mutualsKey, $start, $start + $limit);
  255. $hasMore = count($mutuals) > $limit;
  256. if ($hasMore) {
  257. $mutuals = array_slice($mutuals, 0, $limit);
  258. }
  259. $nextCursor = $hasMore && ! empty($mutuals) ? end($mutuals) : null;
  260. return [
  261. 'data' => array_map('intval', $mutuals),
  262. 'has_more' => $hasMore,
  263. 'next_cursor' => $nextCursor ? (int) $nextCursor : null,
  264. 'total_count' => Redis::zcard($mutualsKey),
  265. ];
  266. }
  267. /**
  268. * Get mutual followers with profile data for DM suggestions
  269. * Combines Redis efficiency with profile information
  270. *
  271. * @param int $profileId
  272. * @param int $limit
  273. * @param int|null $cursor
  274. * @return array
  275. */
  276. public static function getMutualsWithProfiles($profileId, $limit = 20, $cursor = null)
  277. {
  278. $mutuals = self::getMutualsForDM($profileId, $limit, $cursor);
  279. if (empty($mutuals['data'])) {
  280. return $mutuals;
  281. }
  282. $orderedProfiles = [];
  283. foreach ($mutuals['data'] as $mutualId) {
  284. $acct = AccountService::get($mutualId, true);
  285. if ($acct && isset($acct['id'])) {
  286. $orderedProfiles[] = $acct;
  287. }
  288. }
  289. return [
  290. 'data' => $orderedProfiles,
  291. 'has_more' => $mutuals['has_more'],
  292. 'next_cursor' => $mutuals['next_cursor'],
  293. 'total_count' => $mutuals['total_count'],
  294. ];
  295. }
  296. /**
  297. * Get mutual count efficiently using Redis intersection
  298. *
  299. * @param int $profileId
  300. * @return int
  301. */
  302. public static function getMutualCount($profileId)
  303. {
  304. self::cacheSyncCheck($profileId, 'followers');
  305. self::cacheSyncCheck($profileId, 'following');
  306. $followingKey = self::FOLLOWING_KEY.$profileId;
  307. $followersKey = self::FOLLOWERS_KEY.$profileId;
  308. $mutualsKey = self::FOLLOWERS_MUTUALS_KEY.$profileId;
  309. $ttl = Redis::ttl($mutualsKey);
  310. if ($ttl === -2 || $ttl < 300) {
  311. Redis::zinterstore($mutualsKey, [$followingKey, $followersKey]);
  312. Redis::expire($mutualsKey, 7200);
  313. }
  314. return Redis::zcard($mutualsKey);
  315. }
  316. public static function delCache($id)
  317. {
  318. Redis::del(self::CACHE_KEY.$id);
  319. Redis::del(self::FOLLOWING_KEY.$id);
  320. Redis::del(self::FOLLOWERS_KEY.$id);
  321. Redis::del(self::FOLLOWERS_MUTUALS_KEY.$id);
  322. Cache::forget(self::FOLLOWERS_SYNC_KEY.$id);
  323. Cache::forget(self::FOLLOWING_SYNC_KEY.$id);
  324. }
  325. public static function localFollowerIds($pid, $limit = 0)
  326. {
  327. $key = self::FOLLOWERS_LOCAL_KEY.$pid;
  328. $res = Cache::remember($key, 7200, function () use ($pid) {
  329. return DB::table('followers')
  330. ->join('profiles', 'followers.profile_id', '=', 'profiles.id')
  331. ->where('followers.following_id', $pid)
  332. ->whereNotNull('profiles.user_id')
  333. ->whereNull('profiles.deleted_at')
  334. ->select('followers.profile_id', 'followers.following_id', 'profiles.id', 'profiles.user_id', 'profiles.deleted_at')
  335. ->pluck('followers.profile_id');
  336. });
  337. return $limit ?
  338. $res->take($limit)->values()->toArray() :
  339. $res->values()->toArray();
  340. }
  341. }