SearchApiV2Service.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. <?php
  2. namespace App\Services;
  3. use App\Hashtag;
  4. use App\Profile;
  5. use App\Status;
  6. use App\Transformer\Api\AccountTransformer;
  7. use App\Util\ActivityPub\Helpers;
  8. use DB;
  9. use Illuminate\Support\Str;
  10. use League\Fractal;
  11. use League\Fractal\Serializer\ArraySerializer;
  12. class SearchApiV2Service
  13. {
  14. private $query;
  15. public static $mastodonMode = false;
  16. public static function query($query, $mastodonMode = false)
  17. {
  18. self::$mastodonMode = $mastodonMode;
  19. return (new self)->run($query);
  20. }
  21. protected function run($query)
  22. {
  23. $this->query = $query;
  24. $q = urldecode($query->input('q'));
  25. if ($query->has('resolve') &&
  26. (Str::startsWith($q, 'https://') ||
  27. Str::substrCount($q, '@') >= 1)
  28. ) {
  29. return $this->resolveQuery();
  30. }
  31. if ($query->has('type')) {
  32. switch ($query->input('type')) {
  33. case 'accounts':
  34. return [
  35. 'accounts' => $this->accounts(),
  36. 'hashtags' => [],
  37. 'statuses' => [],
  38. ];
  39. break;
  40. case 'hashtags':
  41. return [
  42. 'accounts' => [],
  43. 'hashtags' => $this->hashtags(),
  44. 'statuses' => [],
  45. ];
  46. break;
  47. case 'statuses':
  48. return [
  49. 'accounts' => [],
  50. 'hashtags' => [],
  51. 'statuses' => $this->statuses(),
  52. ];
  53. break;
  54. }
  55. }
  56. if ($query->has('account_id')) {
  57. return [
  58. 'accounts' => [],
  59. 'hashtags' => [],
  60. 'statuses' => $this->statusesById(),
  61. ];
  62. }
  63. return [
  64. 'accounts' => $this->accounts(),
  65. 'hashtags' => $this->hashtags(),
  66. 'statuses' => $this->statuses(),
  67. ];
  68. }
  69. protected function accounts($initalQuery = false)
  70. {
  71. $mastodonMode = self::$mastodonMode;
  72. $user = request()->user();
  73. $limit = $this->query->input('limit') ?? 20;
  74. $offset = $this->query->input('offset') ?? 0;
  75. $rawQuery = $initalQuery ? $initalQuery : $this->query->input('q');
  76. $query = $rawQuery.'%';
  77. $webfingerQuery = $query;
  78. if (Str::substrCount($rawQuery, '@') == 1 && substr($rawQuery, 0, 1) !== '@') {
  79. $query = '@'.$query;
  80. }
  81. if (substr($webfingerQuery, 0, 1) !== '@') {
  82. $webfingerQuery = '@'.$webfingerQuery;
  83. }
  84. $banned = InstanceService::getBannedDomains() ?? [];
  85. $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
  86. if ($domainBlocks && count($domainBlocks)) {
  87. $banned = array_unique(
  88. array_values(
  89. array_merge($banned, $domainBlocks)
  90. )
  91. );
  92. }
  93. $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
  94. $results = Profile::select('username', 'id', 'followers_count', 'domain')
  95. ->where('username', $operator, $query)
  96. ->orWhere('webfinger', $operator, $webfingerQuery)
  97. ->orderByDesc('profiles.followers_count')
  98. ->offset($offset)
  99. ->limit($limit)
  100. ->get()
  101. ->filter(function ($profile) use ($banned) {
  102. return in_array($profile->domain, $banned) == false;
  103. })
  104. ->map(function ($res) use ($mastodonMode) {
  105. return $mastodonMode ?
  106. AccountService::getMastodon($res['id']) :
  107. AccountService::get($res['id']);
  108. })
  109. ->filter(function ($account) {
  110. return $account && isset($account['id']) && ! isset($account['moved'], $account['moved']['id']);
  111. })
  112. ->values();
  113. return $results;
  114. }
  115. protected function hashtags()
  116. {
  117. $mastodonMode = self::$mastodonMode;
  118. $q = $this->query->input('q');
  119. $limit = $this->query->input('limit') ?? 20;
  120. $offset = $this->query->input('offset') ?? 0;
  121. $query = Str::startsWith($q, '#') ? substr($q, 1) : $q;
  122. $query = $query.'%';
  123. if (config('database.default') === 'pgsql') {
  124. $baseQuery = Hashtag::query()
  125. ->where('name', 'ilike', $query)
  126. ->where('is_banned', false)
  127. ->where(function ($q) {
  128. $q->where('can_search', true)
  129. ->orWhereNull('can_search');
  130. })
  131. ->orderByDesc(DB::raw('COALESCE(cached_count, 0)'))
  132. ->offset($offset)
  133. ->limit($limit)
  134. ->get();
  135. return $baseQuery
  136. ->map(function ($tag) use ($mastodonMode) {
  137. $res = [
  138. 'name' => $tag->name,
  139. 'url' => $tag->url(),
  140. ];
  141. if (! $mastodonMode) {
  142. $res['history'] = [];
  143. $res['count'] = $tag->cached_count ?? 0;
  144. }
  145. return $res;
  146. })
  147. ->values();
  148. }
  149. return Hashtag::where('name', 'like', $query)
  150. ->where('is_banned', false)
  151. ->where(function ($q) {
  152. $q->where('can_search', true)
  153. ->orWhereNull('can_search');
  154. })
  155. ->orderBy(DB::raw('COALESCE(cached_count, 0)'), 'desc')
  156. ->offset($offset)
  157. ->limit($limit)
  158. ->get()
  159. ->map(function ($tag) use ($mastodonMode) {
  160. $res = [
  161. 'name' => $tag->name,
  162. 'url' => $tag->url(),
  163. ];
  164. if (! $mastodonMode) {
  165. $res['history'] = [];
  166. $res['count'] = $tag->cached_count ?? 0;
  167. }
  168. return $res;
  169. })
  170. ->values();
  171. }
  172. protected function statuses()
  173. {
  174. // Removed until we provide more relevent sorting/results
  175. return [];
  176. }
  177. protected function statusesById()
  178. {
  179. // Removed until we provide more relevent sorting/results
  180. return [];
  181. }
  182. protected function resolveQuery()
  183. {
  184. $default = [
  185. 'accounts' => [],
  186. 'hashtags' => [],
  187. 'statuses' => [],
  188. ];
  189. $user = request()->user();
  190. $mastodonMode = self::$mastodonMode;
  191. $query = urldecode($this->query->input('q'));
  192. $limit = $this->query->input('limit') ?? 20;
  193. $offset = $this->query->input('offset') ?? 0;
  194. $banned = InstanceService::getBannedDomains();
  195. $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
  196. if ($domainBlocks && count($domainBlocks)) {
  197. $banned = array_unique(
  198. array_values(
  199. array_merge($banned, $domainBlocks)
  200. )
  201. );
  202. }
  203. if (substr($query, 0, 1) === '@' && ! Str::contains($query, '.')) {
  204. $default['accounts'] = $this->accounts(substr($query, 1));
  205. return $default;
  206. }
  207. if (Helpers::validateLocalUrl($query)) {
  208. if (Str::contains($query, '/p/') || Str::contains($query, 'i/web/post/')) {
  209. return $this->resolveLocalStatus();
  210. } elseif (Str::contains($query, 'i/web/profile/')) {
  211. return $this->resolveLocalProfileId();
  212. } else {
  213. return $this->resolveLocalProfile();
  214. }
  215. } else {
  216. if (! Helpers::validateUrl($query) && strpos($query, '@') == -1) {
  217. return $default;
  218. }
  219. if (! Str::startsWith($query, 'http') && Str::substrCount($query, '@') == 1 && strpos($query, '@') !== 0) {
  220. try {
  221. $res = WebfingerService::lookup('@'.$query, $mastodonMode);
  222. } catch (\Exception $e) {
  223. return $default;
  224. }
  225. if ($res && isset($res['id'], $res['url'])) {
  226. $domain = strtolower(parse_url($res['url'], PHP_URL_HOST));
  227. if (in_array($domain, $banned)) {
  228. return $default;
  229. }
  230. $paginated = collect($res)->take($limit)->skip($offset)->toArray();
  231. if (! empty($paginated)) {
  232. $default['accounts'][] = $paginated;
  233. } else {
  234. $default['accounts'] = [];
  235. }
  236. return $default;
  237. } else {
  238. return $default;
  239. }
  240. }
  241. if (Str::substrCount($query, '@') == 2) {
  242. try {
  243. $res = WebfingerService::lookup($query, $mastodonMode);
  244. } catch (\Exception $e) {
  245. return $default;
  246. }
  247. if ($res && isset($res['id'])) {
  248. $domain = strtolower(parse_url($res['url'], PHP_URL_HOST));
  249. if (in_array($domain, $banned)) {
  250. return $default;
  251. }
  252. $paginated = collect($res)->take($limit)->skip($offset)->toArray();
  253. if (! empty($paginated)) {
  254. $default['accounts'][] = $paginated;
  255. } else {
  256. $default['accounts'] = [];
  257. }
  258. return $default;
  259. } else {
  260. return $default;
  261. }
  262. }
  263. if ($sid = Status::whereUri($query)->first()) {
  264. $s = StatusService::get($sid->id, false);
  265. if (! $s || isset($s['account']['moved'], $s['account']['moved']['id'])) {
  266. return $default;
  267. }
  268. if (in_array($s['visibility'], ['public', 'unlisted'])) {
  269. $default['statuses'][] = $s;
  270. return $default;
  271. }
  272. }
  273. try {
  274. $res = ActivityPubFetchService::get($query);
  275. if ($res) {
  276. $json = json_decode($res, true);
  277. if (! $json || ! isset($json['@context']) || ! isset($json['type']) || ! in_array($json['type'], ['Note', 'Person'])) {
  278. return [
  279. 'accounts' => [],
  280. 'hashtags' => [],
  281. 'statuses' => [],
  282. ];
  283. }
  284. switch ($json['type']) {
  285. case 'Note':
  286. $obj = Helpers::statusFetch($query);
  287. if (! $obj || ! isset($obj['id'])) {
  288. return $default;
  289. }
  290. $note = $mastodonMode ?
  291. StatusService::getMastodon($obj['id'], false) :
  292. StatusService::get($obj['id'], false);
  293. if (! $note) {
  294. return $default;
  295. }
  296. if (! isset($note['visibility']) || ! in_array($note['visibility'], ['public', 'unlisted'])) {
  297. return $default;
  298. }
  299. $default['statuses'][] = $note;
  300. return $default;
  301. break;
  302. case 'Person':
  303. $obj = Helpers::profileFetch($query);
  304. if (! $obj) {
  305. return $default;
  306. }
  307. if (in_array($obj['domain'], $banned)) {
  308. return $default;
  309. }
  310. $default['accounts'][] = $mastodonMode ?
  311. AccountService::getMastodon($obj['id'], true) :
  312. AccountService::get($obj['id'], true);
  313. return $default;
  314. break;
  315. default:
  316. return [
  317. 'accounts' => [],
  318. 'hashtags' => [],
  319. 'statuses' => [],
  320. ];
  321. break;
  322. }
  323. }
  324. } catch (\Exception $e) {
  325. return [
  326. 'accounts' => [],
  327. 'hashtags' => [],
  328. 'statuses' => [],
  329. ];
  330. }
  331. return $default;
  332. }
  333. }
  334. protected function resolveLocalStatus()
  335. {
  336. $query = urldecode($this->query->input('q'));
  337. $query = last(explode('/', parse_url($query, PHP_URL_PATH)));
  338. $status = StatusService::getMastodon($query, false);
  339. if (! $status || ! in_array($status['visibility'], ['public', 'unlisted'])) {
  340. return [
  341. 'accounts' => [],
  342. 'hashtags' => [],
  343. 'statuses' => [],
  344. ];
  345. }
  346. $res = [
  347. 'accounts' => [],
  348. 'hashtags' => [],
  349. 'statuses' => [$status],
  350. ];
  351. return $res;
  352. }
  353. protected function resolveLocalProfile()
  354. {
  355. $query = urldecode($this->query->input('q'));
  356. $query = last(explode('/', parse_url($query, PHP_URL_PATH)));
  357. $profile = Profile::whereNull('status')
  358. ->whereNull('domain')
  359. ->whereUsername($query)
  360. ->first();
  361. if (! $profile || $profile->moved_to_profile_id) {
  362. return [
  363. 'accounts' => [],
  364. 'hashtags' => [],
  365. 'statuses' => [],
  366. ];
  367. }
  368. $fractal = new Fractal\Manager;
  369. $fractal->setSerializer(new ArraySerializer);
  370. $resource = new Fractal\Resource\Item($profile, new AccountTransformer);
  371. return [
  372. 'accounts' => [$fractal->createData($resource)->toArray()],
  373. 'hashtags' => [],
  374. 'statuses' => [],
  375. ];
  376. }
  377. protected function resolveLocalProfileId()
  378. {
  379. $query = urldecode($this->query->input('q'));
  380. $query = last(explode('/', parse_url($query, PHP_URL_PATH)));
  381. $profile = Profile::whereNull('status')
  382. ->find($query);
  383. if (! $profile) {
  384. return [
  385. 'accounts' => [],
  386. 'hashtags' => [],
  387. 'statuses' => [],
  388. ];
  389. }
  390. $fractal = new Fractal\Manager;
  391. $fractal->setSerializer(new ArraySerializer);
  392. $resource = new Fractal\Resource\Item($profile, new AccountTransformer);
  393. return [
  394. 'accounts' => [$fractal->createData($resource)->toArray()],
  395. 'hashtags' => [],
  396. 'statuses' => [],
  397. ];
  398. }
  399. }