1
0
Эх сурвалжийг харах

Update CustomFilterController

Daniel Supernault 2 сар өмнө
parent
commit
168816db2d

+ 343 - 99
app/Http/Controllers/CustomFilterController.php

@@ -5,96 +5,217 @@ namespace App\Http\Controllers;
 use App\Models\CustomFilter;
 use App\Models\CustomFilterKeyword;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
 
 class CustomFilterController extends Controller
 {
-    public function __construct()
-    {
-        $this->middleware('auth');
-    }
-
     public function index(Request $request)
     {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
         $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
             ->unexpired()
             ->with(['keywords', 'statuses'])
-            ->get();
-
-        return response()->json([
-            'filters' => $filters,
-        ]);
+            ->orderByDesc('updated_at')
+            ->get()
+            ->map(function ($filter) {
+                return [
+                    'id' => $filter->id,
+                    'title' => $filter->title,
+                    'context' => $filter->context,
+                    'expires_at' => $filter->expires_at,
+                    'filter_action' => $filter->filterAction,
+                    'keywords' => $filter->keywords->map(function ($keyword) {
+                        return [
+                            'id' => $keyword->id,
+                            'keyword' => $keyword->keyword,
+                            'whole_word' => (bool) $keyword->whole_word,
+                        ];
+                    }),
+                    'statuses' => $filter->statuses->map(function ($status) {
+                        return [
+                            'id' => $status->id,
+                            'status_id' => $status->status_id,
+                        ];
+                    }),
+                ];
+            });
+
+        return response()->json($filters);
     }
 
-    public function show(CustomFilter $filter)
+    public function show(Request $request, $id)
     {
-        $this->authorize('view', $filter);
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('view', $filter);
 
         $filter->load(['keywords', 'statuses']);
 
-        return response()->json([
-            'filter' => $filter,
-        ]);
+        $res = [
+            'id' => $filter->id,
+            'title' => $filter->title,
+            'context' => $filter->context,
+            'expires_at' => $filter->expires_at,
+            'filter_action' => $filter->filterAction,
+            'keywords' => $filter->keywords->map(function ($keyword) {
+                return [
+                    'id' => $keyword->id,
+                    'keyword' => $keyword->keyword,
+                    'whole_word' => (bool) $keyword->whole_word,
+                ];
+            }),
+            'statuses' => $filter->statuses->map(function ($status) {
+                return [
+                    'id' => $status->id,
+                    'status_id' => $status->status_id,
+                ];
+            }),
+        ];
+
+        return response()->json($res);
     }
 
     public function store(Request $request)
     {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        Gate::authorize('create', CustomFilter::class);
+
         $validatedData = $request->validate([
-            'title' => 'required|string',
+            'title' => 'required|string|max:100',
             'context' => 'required|array',
-            'context.*' => 'string|in:'.implode(',', CustomFilter::VALID_CONTEXTS),
-            'filter_action' => 'integer|in:0,1,2',
-            'expires_in' => 'nullable|integer|min:0',
-            'irreversible' => 'boolean',
-            'keywords' => 'array',
-            'keywords.*.keyword' => 'required|string',
-            'keywords.*.whole_word' => 'boolean',
-            'status_ids' => 'array',
-            'status_ids.*' => 'integer|exists:statuses,id',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::MAX_KEYWORDS_PER_FILTER,
+            'keywords_attributes.*.keyword' => [
+                'required',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::MAX_KEYWORD_LEN,
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
         ]);
 
+        $rateKey = 'filters_created:'.$request->user()->id;
+        $maxFiltersPerHour = CustomFilter::MAX_PER_HOUR;
+        $currentCount = Cache::get($rateKey, 0);
+
+        if ($currentCount >= $maxFiltersPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
+            ], 429);
+        }
+
         DB::beginTransaction();
 
         try {
+            $profile_id = $request->user()->profile_id;
+
+            $requestedKeywords = array_map(function ($item) {
+                return $item['keyword'];
+            }, $validatedData['keywords_attributes']);
+
+            $existingKeywords = DB::table('custom_filter_keywords')
+                ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                ->where('custom_filters.profile_id', $profile_id)
+                ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                ->pluck('custom_filter_keywords.keyword')
+                ->toArray();
+
+            if (! empty($existingKeywords)) {
+                return response()->json([
+                    'error' => 'Duplicate keywords found',
+                    'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                ], 422);
+            }
+
+            $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
+            $maxFiltersPerUser = CustomFilter::MAX_LIMIT;
+
+            if ($userFilterCount >= $maxFiltersPerUser) {
+                return response()->json([
+                    'error' => 'Filter limit exceeded',
+                    'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
+                ], 422);
+            }
+
             $expiresAt = null;
             if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
                 $expiresAt = now()->addSeconds($validatedData['expires_in']);
             }
 
+            $action = CustomFilter::ACTION_WARN;
+            if (isset($validatedData['filter_action'])) {
+                $action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
             $filter = CustomFilter::create([
-                'phrase' => $validatedData['title'],
+                'title' => $validatedData['title'],
                 'context' => $validatedData['context'],
-                'action' => $validatedData['filter_action'] ??
-                           (isset($validatedData['irreversible']) && $validatedData['irreversible'] ?
-                            CustomFilter::ACTION_HIDE : CustomFilter::ACTION_WARN),
+                'action' => $action,
                 'expires_at' => $expiresAt,
                 'profile_id' => $request->user()->profile_id,
             ]);
 
-            if (isset($validatedData['keywords'])) {
-                foreach ($validatedData['keywords'] as $keywordData) {
+            if (isset($validatedData['keywords_attributes'])) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    $keyword = trim($keywordData['keyword']);
+
                     $filter->keywords()->create([
-                        'keyword' => $keywordData['keyword'],
-                        'whole_word' => $keywordData['whole_word'] ?? true,
+                        'keyword' => $keyword,
+                        'whole_word' => (bool) $keywordData['whole_word'] ?? true,
                     ]);
                 }
             }
 
-            if (isset($validatedData['status_ids'])) {
-                foreach ($validatedData['status_ids'] as $statusId) {
-                    $filter->statuses()->create([
-                        'status_id' => $statusId,
-                    ]);
-                }
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
             }
 
+            Cache::forget("filters:v3:{$profile_id}");
+
             DB::commit();
 
             $filter->load(['keywords', 'statuses']);
 
-            return response()->json([
-                'filter' => $filter,
-            ], 201);
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res, 200);
 
         } catch (\Exception $e) {
             DB::rollBack();
@@ -106,29 +227,99 @@ class CustomFilterController extends Controller
         }
     }
 
-    public function update(Request $request, CustomFilter $filter)
+    /**
+     * Convert Mastodon filter_action string to internal action value
+     *
+     * @param  string  $filterAction
+     * @return int
+     */
+    private function filterActionToAction($filterAction)
     {
-        $this->authorize('update', $filter);
+        switch ($filterAction) {
+            case 'warn':
+                return CustomFilter::ACTION_WARN;
+            case 'hide':
+                return CustomFilter::ACTION_HIDE;
+            case 'blur':
+                return CustomFilter::ACTION_BLUR;
+            default:
+                return CustomFilter::ACTION_WARN;
+        }
+    }
+
+    public function update(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+
+        Gate::authorize('update', $filter);
 
         $validatedData = $request->validate([
-            'title' => 'string',
-            'context' => 'array',
-            'context.*' => 'string|in:'.implode(',', CustomFilter::VALID_CONTEXTS),
-            'filter_action' => 'integer|in:0,1,2',
-            'expires_in' => 'nullable|integer|min:0',
-            'irreversible' => 'boolean',
-            'keywords' => 'array',
-            'keywords.*.id' => 'nullable|exists:custom_filter_keywords,id',
-            'keywords.*.keyword' => 'required|string',
-            'keywords.*.whole_word' => 'boolean',
-            'keywords.*._destroy' => 'boolean',
-            'status_ids' => 'array',
-            'status_ids.*' => 'integer|exists:statuses,id',
+            'title' => 'string|max:100',
+            'context' => 'array|max:10',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::MAX_KEYWORDS_PER_FILTER,
+            'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
+            'keywords_attributes.*.keyword' => [
+                'required_without:keywords_attributes.*.id',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::MAX_KEYWORD_LEN,
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+            'keywords_attributes.*._destroy' => 'boolean',
         ]);
 
+        $rateKey = 'filters_updated:'.$request->user()->id;
+        $maxUpdatesPerHour = 30;
+        $currentCount = Cache::get($rateKey, 0);
+
+        if ($currentCount >= $maxUpdatesPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
+            ], 429);
+        }
+
         DB::beginTransaction();
 
         try {
+            $pid = $request->user()->profile_id;
+
+            $requestedKeywords = [];
+            foreach ($validatedData['keywords_attributes'] as $item) {
+                if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
+                    $requestedKeywords[] = $item['keyword'];
+                }
+            }
+
+            if (! empty($requestedKeywords)) {
+                $existingKeywords = DB::table('custom_filter_keywords')
+                    ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                    ->where('custom_filters.profile_id', $pid)
+                    ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
+                    ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                    ->pluck('custom_filter_keywords.keyword')
+                    ->toArray();
+
+                if (! empty($existingKeywords)) {
+                    return response()->json([
+                        'error' => 'Duplicate keywords found',
+                        'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                    ], 422);
+                }
+            }
+
             if (isset($validatedData['expires_in'])) {
                 if ($validatedData['expires_in'] > 0) {
                     $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
@@ -138,7 +329,7 @@ class CustomFilterController extends Controller
             }
 
             if (isset($validatedData['title'])) {
-                $filter->phrase = $validatedData['title'];
+                $filter->title = $validatedData['title'];
             }
 
             if (isset($validatedData['context'])) {
@@ -146,63 +337,113 @@ class CustomFilterController extends Controller
             }
 
             if (isset($validatedData['filter_action'])) {
-                $filter->action = $validatedData['filter_action'];
-            } elseif (isset($validatedData['irreversible'])) {
-                $filter->irreversible = $validatedData['irreversible'];
+                $filter->action = $this->filterActionToAction($validatedData['filter_action']);
             }
 
             $filter->save();
 
-            if (isset($validatedData['keywords'])) {
-                $existingKeywordIds = $filter->keywords->pluck('id')->toArray();
+            if (isset($validatedData['keywords_attributes'])) {
+                $existingKeywords = $filter->keywords()->pluck('id')->toArray();
+
                 $processedIds = [];
 
-                foreach ($validatedData['keywords'] as $keywordData) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    // Case 1: Explicit deletion with _destroy flag
                     if (isset($keywordData['id']) && isset($keywordData['_destroy']) && $keywordData['_destroy']) {
-                        CustomFilterKeyword::destroy($keywordData['id']);
-
-                        continue;
+                        // Verify this ID belongs to this filter before deletion
+                        $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($kwf) {
+                            $kwf->delete();
+                            $processedIds[] = $keywordData['id'];
+                        }
                     }
-
-                    if (isset($keywordData['id']) && in_array($keywordData['id'], $existingKeywordIds)) {
-                        $keyword = CustomFilterKeyword::find($keywordData['id']);
-                        $keyword->update([
-                            'keyword' => $keywordData['keyword'],
-                            'whole_word' => $keywordData['whole_word'] ?? $keyword->whole_word,
-                        ]);
-                        $processedIds[] = $keywordData['id'];
-                    } else {
-                        $newKeyword = $filter->keywords()->create([
-                            'keyword' => $keywordData['keyword'],
-                            'whole_word' => $keywordData['whole_word'] ?? true,
+                    // Case 2: Update existing keyword
+                    elseif (isset($keywordData['id'])) {
+                        // Skip if we've already processed this ID
+                        if (in_array($keywordData['id'], $processedIds)) {
+                            continue;
+                        }
+
+                        // Verify this ID belongs to this filter before updating
+                        $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($keyword) {
+                            $updateData = [];
+
+                            if (isset($keywordData['keyword'])) {
+                                $updateData['keyword'] = trim($keywordData['keyword']);
+                            }
+
+                            if (isset($keywordData['whole_word'])) {
+                                $updateData['whole_word'] = (bool) $keywordData['whole_word'];
+                            }
+
+                            if (! empty($updateData)) {
+                                $keyword->update($updateData);
+                            }
+
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 3: Create new keyword
+                    elseif (isset($keywordData['keyword'])) {
+                        // Check if we're about to exceed the keyword limit
+                        $existingKeywordCount = $filter->keywords()->count();
+                        $maxKeywordsPerFilter = CustomFilter::MAX_KEYWORDS_PER_FILTER;
+
+                        if ($existingKeywordCount >= $maxKeywordsPerFilter) {
+                            return response()->json([
+                                'error' => 'Keyword limit exceeded',
+                                'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
+                            ], 422);
+                        }
+
+                        $filter->keywords()->create([
+                            'keyword' => trim($keywordData['keyword']),
+                            'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
                         ]);
-                        $processedIds[] = $newKeyword->id;
                     }
                 }
-
-                $keywordsToDelete = array_diff($existingKeywordIds, $processedIds);
-                if (! empty($keywordsToDelete)) {
-                    CustomFilterKeyword::destroy($keywordsToDelete);
-                }
             }
 
-            if (isset($validatedData['status_ids'])) {
-                $filter->statuses()->delete();
-
-                foreach ($validatedData['status_ids'] as $statusId) {
-                    $filter->statuses()->create([
-                        'status_id' => $statusId,
-                    ]);
-                }
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
             }
 
+            Cache::forget("filters:v3:{$request->user()->profile_id}");
+
             DB::commit();
 
             $filter->load(['keywords', 'statuses']);
 
-            return response()->json([
-                'filter' => $filter,
-            ]);
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res);
 
         } catch (\Exception $e) {
             DB::rollBack();
@@ -214,12 +455,15 @@ class CustomFilterController extends Controller
         }
     }
 
-    public function destroy(CustomFilter $filter)
+    public function delete(Request $request, $id)
     {
-        $this->authorize('delete', $filter);
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
 
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('delete', $filter);
         $filter->delete();
 
-        return response()->json(null, 204);
+        return response()->json([], 200);
     }
 }