CustomFilterController.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Models\CustomFilter;
  4. use App\Models\CustomFilterKeyword;
  5. use Illuminate\Http\Request;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Gate;
  9. use Illuminate\Validation\Rule;
  10. class CustomFilterController extends Controller
  11. {
  12. // const ACTIVE_TYPES = ['home', 'public', 'tags', 'notifications', 'thread', 'profile', 'groups'];
  13. const ACTIVE_TYPES = ['home', 'public', 'tags'];
  14. public function index(Request $request)
  15. {
  16. abort_if(! $request->user() || ! $request->user()->token(), 403);
  17. abort_unless($request->user()->tokenCan('read'), 403);
  18. $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
  19. ->unexpired()
  20. ->with(['keywords', 'statuses'])
  21. ->orderByDesc('updated_at')
  22. ->get()
  23. ->map(function ($filter) {
  24. return [
  25. 'id' => $filter->id,
  26. 'title' => $filter->title,
  27. 'context' => $filter->context,
  28. 'expires_at' => $filter->expires_at,
  29. 'filter_action' => $filter->filterAction,
  30. 'keywords' => $filter->keywords->map(function ($keyword) {
  31. return [
  32. 'id' => $keyword->id,
  33. 'keyword' => $keyword->keyword,
  34. 'whole_word' => (bool) $keyword->whole_word,
  35. ];
  36. }),
  37. 'statuses' => $filter->statuses->map(function ($status) {
  38. return [
  39. 'id' => $status->id,
  40. 'status_id' => $status->status_id,
  41. ];
  42. }),
  43. ];
  44. });
  45. return response()->json($filters);
  46. }
  47. public function show(Request $request, $id)
  48. {
  49. abort_if(! $request->user() || ! $request->user()->token(), 403);
  50. abort_unless($request->user()->tokenCan('read'), 403);
  51. $filter = CustomFilter::findOrFail($id);
  52. Gate::authorize('view', $filter);
  53. $filter->load(['keywords', 'statuses']);
  54. $res = [
  55. 'id' => $filter->id,
  56. 'title' => $filter->title,
  57. 'context' => $filter->context,
  58. 'expires_at' => $filter->expires_at,
  59. 'filter_action' => $filter->filterAction,
  60. 'keywords' => $filter->keywords->map(function ($keyword) {
  61. return [
  62. 'id' => $keyword->id,
  63. 'keyword' => $keyword->keyword,
  64. 'whole_word' => (bool) $keyword->whole_word,
  65. ];
  66. }),
  67. 'statuses' => $filter->statuses->map(function ($status) {
  68. return [
  69. 'id' => $status->id,
  70. 'status_id' => $status->status_id,
  71. ];
  72. }),
  73. ];
  74. return response()->json($res);
  75. }
  76. public function store(Request $request)
  77. {
  78. abort_if(! $request->user() || ! $request->user()->token(), 403);
  79. abort_unless($request->user()->tokenCan('write'), 403);
  80. Gate::authorize('create', CustomFilter::class);
  81. $validatedData = $request->validate([
  82. 'title' => 'required|string|max:100',
  83. 'context' => 'required|array',
  84. 'context.*' => [
  85. 'string',
  86. Rule::in(self::ACTIVE_TYPES),
  87. ],
  88. 'filter_action' => 'string|in:warn,hide,blur',
  89. 'expires_in' => 'nullable|integer|min:0|max:63072000',
  90. 'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::getMaxKeywordsPerFilter(),
  91. 'keywords_attributes.*.keyword' => [
  92. 'required',
  93. 'string',
  94. 'min:1',
  95. 'max:'.CustomFilter::getMaxKeywordLength(),
  96. 'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
  97. function ($attribute, $value, $fail) {
  98. if (preg_match('/(.)\1{20,}/', $value)) {
  99. $fail('The keyword contains excessive character repetition.');
  100. }
  101. },
  102. ],
  103. 'keywords_attributes.*.whole_word' => 'boolean',
  104. ]);
  105. $profile_id = $request->user()->profile_id;
  106. $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
  107. $maxFiltersPerUser = CustomFilter::getMaxFiltersPerUser();
  108. if (! $request->user()->is_admin && $userFilterCount >= $maxFiltersPerUser) {
  109. return response()->json([
  110. 'error' => 'Filter limit exceeded',
  111. 'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
  112. ], 422);
  113. }
  114. $rateKey = 'filters_created:'.$request->user()->id;
  115. $maxFiltersPerHour = CustomFilter::getMaxCreatePerHour();
  116. $currentCount = Cache::get($rateKey, 0);
  117. if (! $request->user()->is_admin && $currentCount >= $maxFiltersPerHour) {
  118. return response()->json([
  119. 'error' => 'Rate limit exceeded',
  120. 'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
  121. ], 429);
  122. }
  123. DB::beginTransaction();
  124. try {
  125. $requestedKeywords = array_map(function ($item) {
  126. return mb_strtolower(trim($item['keyword']));
  127. }, $validatedData['keywords_attributes']);
  128. $existingKeywords = DB::table('custom_filter_keywords')
  129. ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
  130. ->where('custom_filters.profile_id', $profile_id)
  131. ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
  132. ->pluck('custom_filter_keywords.keyword')
  133. ->toArray();
  134. if (! empty($existingKeywords)) {
  135. return response()->json([
  136. 'error' => 'Duplicate keywords found',
  137. 'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
  138. ], 422);
  139. }
  140. $expiresAt = null;
  141. if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
  142. $expiresAt = now()->addSeconds($validatedData['expires_in']);
  143. }
  144. $action = CustomFilter::ACTION_WARN;
  145. if (isset($validatedData['filter_action'])) {
  146. $action = $this->filterActionToAction($validatedData['filter_action']);
  147. }
  148. $filter = CustomFilter::create([
  149. 'title' => $validatedData['title'],
  150. 'context' => $validatedData['context'],
  151. 'action' => $action,
  152. 'expires_at' => $expiresAt,
  153. 'profile_id' => $request->user()->profile_id,
  154. ]);
  155. if (isset($validatedData['keywords_attributes'])) {
  156. foreach ($validatedData['keywords_attributes'] as $keywordData) {
  157. $keyword = trim($keywordData['keyword']);
  158. $filter->keywords()->create([
  159. 'keyword' => $keyword,
  160. 'whole_word' => (bool) $keywordData['whole_word'] ?? true,
  161. ]);
  162. }
  163. }
  164. Cache::increment($rateKey);
  165. if (! Cache::has($rateKey)) {
  166. Cache::put($rateKey, 1, 3600);
  167. }
  168. Cache::forget("filters:v3:{$profile_id}");
  169. DB::commit();
  170. $filter->load(['keywords', 'statuses']);
  171. $res = [
  172. 'id' => $filter->id,
  173. 'title' => $filter->title,
  174. 'context' => $filter->context,
  175. 'expires_at' => $filter->expires_at,
  176. 'filter_action' => $filter->filterAction,
  177. 'keywords' => $filter->keywords->map(function ($keyword) {
  178. return [
  179. 'id' => $keyword->id,
  180. 'keyword' => $keyword->keyword,
  181. 'whole_word' => (bool) $keyword->whole_word,
  182. ];
  183. }),
  184. 'statuses' => $filter->statuses->map(function ($status) {
  185. return [
  186. 'id' => $status->id,
  187. 'status_id' => $status->status_id,
  188. ];
  189. }),
  190. ];
  191. return response()->json($res, 200);
  192. } catch (\Exception $e) {
  193. DB::rollBack();
  194. return response()->json([
  195. 'error' => 'Failed to create filter',
  196. 'message' => $e->getMessage(),
  197. ], 500);
  198. }
  199. }
  200. /**
  201. * Convert Mastodon filter_action string to internal action value
  202. *
  203. * @param string $filterAction
  204. * @return int
  205. */
  206. private function filterActionToAction($filterAction)
  207. {
  208. switch ($filterAction) {
  209. case 'warn':
  210. return CustomFilter::ACTION_WARN;
  211. case 'hide':
  212. return CustomFilter::ACTION_HIDE;
  213. case 'blur':
  214. return CustomFilter::ACTION_BLUR;
  215. default:
  216. return CustomFilter::ACTION_WARN;
  217. }
  218. }
  219. public function update(Request $request, $id)
  220. {
  221. abort_if(! $request->user() || ! $request->user()->token(), 403);
  222. abort_unless($request->user()->tokenCan('write'), 403);
  223. $filter = CustomFilter::findOrFail($id);
  224. $pid = $request->user()->profile_id;
  225. if ($filter->profile_id !== $pid) {
  226. return response()->json(['error' => 'This action is unauthorized'], 401);
  227. }
  228. Gate::authorize('update', $filter);
  229. $validatedData = $request->validate([
  230. 'title' => 'string|max:100',
  231. 'context' => 'array|max:10',
  232. 'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
  233. 'context.*' => [
  234. 'string',
  235. Rule::in(self::ACTIVE_TYPES),
  236. ],
  237. 'filter_action' => 'string|in:warn,hide,blur',
  238. 'expires_in' => 'nullable|integer|min:0|max:63072000',
  239. 'keywords_attributes' => [
  240. 'required',
  241. 'array',
  242. 'min:1',
  243. function ($attribute, $value, $fail) {
  244. $activeKeywords = collect($value)->filter(function ($keyword) {
  245. return ! isset($keyword['_destroy']) || $keyword['_destroy'] !== true;
  246. })->count();
  247. if ($activeKeywords > CustomFilter::getMaxKeywordsPerFilter()) {
  248. $fail('You may not have more than '.CustomFilter::getMaxKeywordsPerFilter().' active keywords.');
  249. }
  250. },
  251. ],
  252. 'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
  253. 'keywords_attributes.*.keyword' => [
  254. 'required_without:keywords_attributes.*.id',
  255. 'string',
  256. 'min:1',
  257. 'max:'.CustomFilter::getMaxKeywordLength(),
  258. 'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
  259. function ($attribute, $value, $fail) {
  260. if (preg_match('/(.)\1{20,}/', $value)) {
  261. $fail('The keyword contains excessive character repetition.');
  262. }
  263. },
  264. ],
  265. 'keywords_attributes.*.whole_word' => 'boolean',
  266. 'keywords_attributes.*._destroy' => 'boolean',
  267. ]);
  268. $rateKey = 'filters_updated:'.$request->user()->id;
  269. $maxUpdatesPerHour = CustomFilter::getMaxUpdatesPerHour();
  270. $currentCount = Cache::get($rateKey, 0);
  271. if (! $request->user()->is_admin && $currentCount >= $maxUpdatesPerHour) {
  272. return response()->json([
  273. 'error' => 'Rate limit exceeded',
  274. 'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
  275. ], 429);
  276. }
  277. DB::beginTransaction();
  278. try {
  279. $keywordIds = collect($validatedData['keywords_attributes'])->pluck('id')->filter()->toArray();
  280. if (count($keywordIds) && ! CustomFilterKeyword::whereCustomFilterId($filter->id)->whereIn('id', $keywordIds)->count()) {
  281. return response()->json([
  282. 'error' => 'Record not found',
  283. ], 404);
  284. }
  285. $requestedKeywords = [];
  286. foreach ($validatedData['keywords_attributes'] as $item) {
  287. if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
  288. $requestedKeywords[] = mb_strtolower(trim($item['keyword']));
  289. }
  290. }
  291. if (! empty($requestedKeywords)) {
  292. $existingKeywords = DB::table('custom_filter_keywords')
  293. ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
  294. ->where('custom_filters.profile_id', $pid)
  295. ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
  296. ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
  297. ->pluck('custom_filter_keywords.keyword')
  298. ->toArray();
  299. if (! empty($existingKeywords)) {
  300. return response()->json([
  301. 'error' => 'Duplicate keywords found',
  302. 'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
  303. ], 422);
  304. }
  305. }
  306. if (isset($validatedData['expires_in'])) {
  307. if ($validatedData['expires_in'] > 0) {
  308. $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
  309. } else {
  310. $filter->expires_at = null;
  311. }
  312. }
  313. if (isset($validatedData['title'])) {
  314. $filter->title = $validatedData['title'];
  315. }
  316. if (isset($validatedData['context'])) {
  317. $filter->context = $validatedData['context'];
  318. }
  319. if (isset($validatedData['filter_action'])) {
  320. $filter->action = $this->filterActionToAction($validatedData['filter_action']);
  321. }
  322. $filter->save();
  323. if (isset($validatedData['keywords_attributes'])) {
  324. $existingKeywords = $filter->keywords()->pluck('id')->toArray();
  325. $processedIds = [];
  326. foreach ($validatedData['keywords_attributes'] as $keywordData) {
  327. // Case 1: Explicit deletion with _destroy flag
  328. if (isset($keywordData['id']) && isset($keywordData['_destroy']) && (bool) $keywordData['_destroy']) {
  329. // Verify this ID belongs to this filter before deletion
  330. $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
  331. ->where('id', $keywordData['id'])
  332. ->first();
  333. if ($kwf) {
  334. $kwf->delete();
  335. $processedIds[] = $keywordData['id'];
  336. }
  337. }
  338. // Case 2: Update existing keyword
  339. elseif (isset($keywordData['id'])) {
  340. // Skip if we've already processed this ID
  341. if (in_array($keywordData['id'], $processedIds)) {
  342. continue;
  343. }
  344. // Verify this ID belongs to this filter before updating
  345. $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
  346. ->where('id', $keywordData['id'])
  347. ->first();
  348. if (! isset($keywordData['_destroy']) && $filter->keywords()->pluck('id')->search($keywordData['id']) === false) {
  349. return response()->json([
  350. 'error' => 'Duplicate keywords found',
  351. 'message' => 'The following keywords already exist: '.$keywordData['keyword'],
  352. ], 422);
  353. }
  354. if ($keyword) {
  355. $updateData = [];
  356. if (isset($keywordData['keyword'])) {
  357. $updateData['keyword'] = trim($keywordData['keyword']);
  358. }
  359. if (isset($keywordData['whole_word'])) {
  360. $updateData['whole_word'] = (bool) $keywordData['whole_word'];
  361. }
  362. if (! empty($updateData)) {
  363. $keyword->update($updateData);
  364. }
  365. $processedIds[] = $keywordData['id'];
  366. }
  367. }
  368. // Case 3: Create new keyword
  369. elseif (isset($keywordData['keyword'])) {
  370. // Check if we're about to exceed the keyword limit
  371. $existingKeywordCount = $filter->keywords()->count();
  372. $maxKeywordsPerFilter = CustomFilter::getMaxKeywordsPerFilter();
  373. if ($existingKeywordCount >= $maxKeywordsPerFilter) {
  374. return response()->json([
  375. 'error' => 'Keyword limit exceeded',
  376. 'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
  377. ], 422);
  378. }
  379. // Skip existing case-insensitive keywords
  380. if ($filter->keywords()->pluck('keyword')->search(mb_strtolower(trim($keywordData['keyword']))) !== false) {
  381. continue;
  382. }
  383. $filter->keywords()->create([
  384. 'keyword' => trim($keywordData['keyword']),
  385. 'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
  386. ]);
  387. }
  388. }
  389. }
  390. Cache::increment($rateKey);
  391. if (! Cache::has($rateKey)) {
  392. Cache::put($rateKey, 1, 3600);
  393. }
  394. Cache::forget("filters:v3:{$pid}");
  395. DB::commit();
  396. $filter->load(['keywords', 'statuses']);
  397. $res = [
  398. 'id' => $filter->id,
  399. 'title' => $filter->title,
  400. 'context' => $filter->context,
  401. 'expires_at' => $filter->expires_at,
  402. 'filter_action' => $filter->filterAction,
  403. 'keywords' => $filter->keywords->map(function ($keyword) {
  404. return [
  405. 'id' => $keyword->id,
  406. 'keyword' => $keyword->keyword,
  407. 'whole_word' => (bool) $keyword->whole_word,
  408. ];
  409. }),
  410. 'statuses' => $filter->statuses->map(function ($status) {
  411. return [
  412. 'id' => $status->id,
  413. 'status_id' => $status->status_id,
  414. ];
  415. }),
  416. ];
  417. return response()->json($res);
  418. } catch (\Exception $e) {
  419. DB::rollBack();
  420. return response()->json([
  421. 'error' => 'Failed to update filter',
  422. 'message' => $e->getMessage(),
  423. ], 500);
  424. }
  425. }
  426. public function delete(Request $request, $id)
  427. {
  428. abort_if(! $request->user() || ! $request->user()->token(), 403);
  429. abort_unless($request->user()->tokenCan('write'), 403);
  430. $filter = CustomFilter::findOrFail($id);
  431. Gate::authorize('delete', $filter);
  432. $filter->delete();
  433. return response()->json((object) [], 200);
  434. }
  435. }