SearchApiV2Service.php 13 KB

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