CustomFilterController.php 20 KB

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