diff --git a/CHANGELOG.md b/CHANGELOG.md index e8729a93b..f7505491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - New media:fix-nonlocal-driver command. Fixes s3 media created with invalid FILESYSTEM_DRIVER=s3 configuration ([672cccd4](https://github.com/pixelfed/pixelfed/commit/672cccd4)) - New landing page design ([09c0032b](https://github.com/pixelfed/pixelfed/commit/09c0032b)) - Add cloud ip bans to BouncerService ([50ab2e20](https://github.com/pixelfed/pixelfed/commit/50ab2e20)) +- Redesigned Admin Dashboard Reports/Moderation ([c6cc6327](https://github.com/pixelfed/pixelfed/commit/c6cc6327)) ### Updates - Update ApiV1Controller, fix blocking remote accounts. Closes #4256 ([8e71e0c0](https://github.com/pixelfed/pixelfed/commit/8e71e0c0)) diff --git a/app/Http/Controllers/Admin/AdminReportController.php b/app/Http/Controllers/Admin/AdminReportController.php index 59ae9dfe9..2ee8e02c5 100644 --- a/app/Http/Controllers/Admin/AdminReportController.php +++ b/app/Http/Controllers/Admin/AdminReportController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use Cache; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use App\Services\AccountService; use App\Services\StatusService; @@ -24,6 +25,13 @@ use Illuminate\Validation\Rule; use App\Services\StoryService; use App\Services\ModLogService; use App\Jobs\DeletePipeline\DeleteAccountPipeline; +use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; +use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline; +use App\Jobs\StatusPipeline\StatusDelete; +use App\Http\Resources\AdminReport; +use App\Http\Resources\AdminSpamReport; +use App\Services\PublicTimelineService; +use App\Services\NetworkTimelineService; trait AdminReportController { @@ -74,6 +82,9 @@ trait AdminReportController public function showReport(Request $request, $id) { $report = Report::with('status')->findOrFail($id); + if($request->has('ref') && $request->input('ref') == 'email') { + return redirect('/i/admin/reports?tab=report&id=' . $report->id); + } return view('admin.reports.show', compact('report')); } @@ -200,6 +211,9 @@ trait AdminReportController { $appeal = AccountInterstitial::whereType('post.autospam') ->findOrFail($id); + if($request->has('ref') && $request->input('ref') == 'email') { + return redirect('/i/admin/reports?tab=autospam&id=' . $appeal->id); + } $meta = json_decode($appeal->meta); return view('admin.reports.show_spam', compact('appeal', 'meta')); } @@ -601,4 +615,588 @@ trait AdminReportController Redis::del('email:manual-ignored'); return [200]; } + + public function reportsStats(Request $request) + { + $stats = [ + 'total' => Report::count(), + 'open' => Report::whereNull('admin_seen')->count(), + 'closed' => Report::whereNotNull('admin_seen')->count(), + 'autospam' => AccountInterstitial::whereType('post.autospam')->count(), + 'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(), + 'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(), + 'email_verification_requests' => Redis::scard('email:manual') + ]; + return $stats; + } + + public function reportsApiAll(Request $request) + { + $filter = $request->input('filter') == 'closed' ? 'closed' : 'open'; + + $reports = AdminReport::collection( + Report::orderBy('id','desc') + ->when($filter, function($q, $filter) { + return $filter == 'open' ? + $q->whereNull('admin_seen') : + $q->whereNotNull('admin_seen'); + }) + ->groupBy(['object_id', 'object_type']) + ->cursorPaginate(6) + ->withQueryString() + ); + + return $reports; + } + + public function reportsApiGet(Request $request, $id) + { + $report = Report::findOrFail($id); + return new AdminReport($report); + } + + public function reportsApiHandle(Request $request) + { + $this->validate($request, [ + 'object_id' => 'required', + 'object_type' => 'required', + 'id' => 'required', + 'action' => 'required|in:ignore,nsfw,unlist,private,delete', + 'action_type' => 'required|in:post,profile' + ]); + + $report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id')); + + if($request->input('action_type') === 'profile') { + return $this->reportsHandleProfileAction($report, $request->input('action')); + } else if($request->input('action_type') === 'post') { + return $this->reportsHandleStatusAction($report, $request->input('action')); + } + + return $report; + } + + protected function reportsHandleProfileAction($report, $action) + { + switch($action) { + case 'ignore': + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'nsfw': + if($report->object_type === 'App\Profile') { + $profile = Profile::find($report->object_id); + } else if($report->object_type === 'App\Status') { + $status = Status::find($report->object_id); + if(!$status) { + return [200]; + } + $profile = Profile::find($status->profile_id); + } + + if(!$profile) { + return; + } + + abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.'); + + $profile->cw = true; + $profile->save(); + + foreach(Status::whereProfileId($profile->id)->cursor() as $status) { + $status->is_nsfw = true; + $status->save(); + StatusService::del($status->id); + PublicTimelineService::rem($status->id); + } + + ModLogService::boot() + ->objectUid($profile->id) + ->objectId($profile->id) + ->objectType('App\Profile::class') + ->user(request()->user()) + ->action('admin.user.moderate') + ->metadata([ + 'action' => 'cw', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'nsfw' => true, + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'unlist': + if($report->object_type === 'App\Profile') { + $profile = Profile::find($report->object_id); + } else if($report->object_type === 'App\Status') { + $status = Status::find($report->object_id); + if(!$status) { + return [200]; + } + $profile = Profile::find($status->profile_id); + } + + if(!$profile) { + return; + } + + abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.'); + + $profile->unlisted = true; + $profile->save(); + + foreach(Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) { + $status->scope = 'unlisted'; + $status->visibility = 'unlisted'; + $status->save(); + StatusService::del($status->id); + PublicTimelineService::rem($status->id); + } + + ModLogService::boot() + ->objectUid($profile->id) + ->objectId($profile->id) + ->objectType('App\Profile::class') + ->user(request()->user()) + ->action('admin.user.moderate') + ->metadata([ + 'action' => 'unlisted', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'private': + if($report->object_type === 'App\Profile') { + $profile = Profile::find($report->object_id); + } else if($report->object_type === 'App\Status') { + $status = Status::find($report->object_id); + if(!$status) { + return [200]; + } + $profile = Profile::find($status->profile_id); + } + + if(!$profile) { + return; + } + + abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.'); + + $profile->unlisted = true; + $profile->save(); + + foreach(Status::whereProfileId($profile->id)->cursor() as $status) { + $status->scope = 'private'; + $status->visibility = 'private'; + $status->save(); + StatusService::del($status->id); + PublicTimelineService::rem($status->id); + } + + ModLogService::boot() + ->objectUid($profile->id) + ->objectId($profile->id) + ->objectType('App\Profile::class') + ->user(request()->user()) + ->action('admin.user.moderate') + ->metadata([ + 'action' => 'private', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'delete': + if(config('pixelfed.account_deletion') == false) { + abort(404); + } + + if($report->object_type === 'App\Profile') { + $profile = Profile::find($report->object_id); + } else if($report->object_type === 'App\Status') { + $status = Status::find($report->object_id); + if(!$status) { + return [200]; + } + $profile = Profile::find($status->profile_id); + } + + if(!$profile) { + return; + } + + abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.'); + + $ts = now()->addMonth(); + + if($profile->user_id) { + $user = $profile->user; + abort_if($user->is_admin, 403, 'You cannot delete admin accounts.'); + $user->status = 'delete'; + $user->delete_after = $ts; + $user->save(); + } + + $profile->status = 'delete'; + $profile->delete_after = $ts; + $profile->save(); + + ModLogService::boot() + ->objectUid($profile->id) + ->objectId($profile->id) + ->objectType('App\Profile::class') + ->user(request()->user()) + ->action('admin.user.delete') + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + + if($profile->user_id) { + DB::table('oauth_access_tokens')->whereUserId($user->id)->delete(); + DB::table('oauth_auth_codes')->whereUserId($user->id)->delete(); + $user->email = $user->id; + $user->password = ''; + $user->status = 'delete'; + $user->save(); + $profile->status = 'delete'; + $profile->delete_after = now()->addMonth(); + $profile->save(); + AccountService::del($profile->id); + DeleteAccountPipeline::dispatch($user)->onQueue('high'); + } else { + $profile->status = 'delete'; + $profile->delete_after = now()->addMonth(); + $profile->save(); + AccountService::del($profile->id); + DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high'); + } + return [200]; + break; + } + } + + protected function reportsHandleStatusAction($report, $action) + { + switch($action) { + case 'ignore': + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'nsfw': + $status = Status::find($report->object_id); + + if(!$status) { + return [200]; + } + + abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.'); + $status->is_nsfw = true; + $status->save(); + StatusService::del($status->id); + + ModLogService::boot() + ->objectUid($status->profile_id) + ->objectId($status->profile_id) + ->objectType('App\Status::class') + ->user(request()->user()) + ->action('admin.status.moderate') + ->metadata([ + 'action' => 'cw', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'nsfw' => true, + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'private': + $status = Status::find($report->object_id); + + if(!$status) { + return [200]; + } + + abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.'); + + $status->scope = 'private'; + $status->visibility = 'private'; + $status->save(); + StatusService::del($status->id); + PublicTimelineService::rem($status->id); + + ModLogService::boot() + ->objectUid($status->profile_id) + ->objectId($status->profile_id) + ->objectType('App\Status::class') + ->user(request()->user()) + ->action('admin.status.moderate') + ->metadata([ + 'action' => 'private', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'unlist': + $status = Status::find($report->object_id); + + if(!$status) { + return [200]; + } + + abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.'); + + if($status->scope === 'public') { + $status->scope = 'unlisted'; + $status->visibility = 'unlisted'; + $status->save(); + StatusService::del($status->id); + PublicTimelineService::rem($status->id); + } + + ModLogService::boot() + ->objectUid($status->profile_id) + ->objectId($status->profile_id) + ->objectType('App\Status::class') + ->user(request()->user()) + ->action('admin.status.moderate') + ->metadata([ + 'action' => 'unlist', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + return [200]; + break; + + case 'delete': + $status = Status::find($report->object_id); + + if(!$status) { + return [200]; + } + + $profile = $status->profile; + + abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.'); + + StatusService::del($status->id); + + if($profile->user_id != null && $profile->domain == null) { + PublicTimelineService::del($status->id); + StatusDelete::dispatch($status)->onQueue('high'); + } else { + NetworkTimelineService::del($status->id); + DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high'); + } + + Report::whereObjectId($report->object_id) + ->whereObjectType($report->object_type) + ->update([ + 'admin_seen' => now() + ]); + + return [200]; + break; + } + } + + public function reportsApiSpamAll(Request $request) + { + $tab = $request->input('tab', 'home'); + + $appeals = AdminSpamReport::collection( + AccountInterstitial::orderBy('id', 'desc') + ->whereType('post.autospam') + ->whereNull('appeal_handled_at') + ->cursorPaginate(6) + ->withQueryString() + ); + + return $appeals; + } + + public function reportsApiSpamHandle(Request $request) + { + $this->validate($request, [ + 'id' => 'required', + 'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile', + ]); + + $action = $request->input('action'); + + abort_if( + $action === 'delete-profile' && + !config('pixelfed.account_deletion'), + 404, + "Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config." + ); + + $report = AccountInterstitial::with('user') + ->whereType('post.autospam') + ->whereNull('appeal_handled_at') + ->findOrFail($request->input('id')); + + $this->reportsHandleSpamAction($report, $action); + Cache::forget('admin-dash:reports:spam-count'); + Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id); + Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id); + PublicTimelineService::warmCache(true, 400); + return [$action, $report]; + } + + public function reportsHandleSpamAction($appeal, $action) + { + $meta = json_decode($appeal->meta); + + if($action == 'mark-read') { + $appeal->is_spam = true; + $appeal->appeal_handled_at = now(); + $appeal->save(); + } + + if($action == 'mark-not-spam') { + $status = $appeal->status; + $status->is_nsfw = $meta->is_nsfw; + $status->scope = 'public'; + $status->visibility = 'public'; + $status->save(); + + $appeal->is_spam = false; + $appeal->appeal_handled_at = now(); + $appeal->save(); + + StatusService::del($status->id); + } + + if($action == 'mark-all-read') { + AccountInterstitial::whereType('post.autospam') + ->whereItemType('App\Status') + ->whereNull('appeal_handled_at') + ->whereUserId($appeal->user_id) + ->update([ + 'appeal_handled_at' => now(), + 'is_spam' => true + ]); + } + + if($action == 'mark-all-not-spam') { + AccountInterstitial::whereType('post.autospam') + ->whereItemType('App\Status') + ->whereUserId($appeal->user_id) + ->get() + ->each(function($report) use($meta) { + $report->is_spam = false; + $report->appeal_handled_at = now(); + $report->save(); + $status = Status::find($report->item_id); + if($status) { + $status->is_nsfw = $meta->is_nsfw; + $status->scope = 'public'; + $status->visibility = 'public'; + $status->save(); + StatusService::del($status->id); + } + }); + } + + if($action == 'delete-profile') { + $user = User::findOrFail($appeal->user_id); + $profile = $user->profile; + + if($user->is_admin == true) { + $mid = request()->user()->id; + abort_if($user->id < $mid, 403, 'You cannot delete an admin account.'); + } + + $ts = now()->addMonth(); + $user->status = 'delete'; + $profile->status = 'delete'; + $user->delete_after = $ts; + $profile->delete_after = $ts; + $user->save(); + $profile->save(); + + $appeal->appeal_handled_at = now(); + $appeal->save(); + + ModLogService::boot() + ->objectUid($user->id) + ->objectId($user->id) + ->objectType('App\User::class') + ->user(request()->user()) + ->action('admin.user.delete') + ->accessLevel('admin') + ->save(); + + Cache::forget('profiles:private'); + DeleteAccountPipeline::dispatch($user); + } + } + + public function reportsApiSpamGet(Request $request, $id) + { + $report = AccountInterstitial::findOrFail($id); + return new AdminSpamReport($report); + } } diff --git a/app/Http/Resources/AdminReport.php b/app/Http/Resources/AdminReport.php new file mode 100644 index 000000000..c541e58cd --- /dev/null +++ b/app/Http/Resources/AdminReport.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + $res = [ + 'id' => $this->id, + 'reporter' => AccountService::get($this->profile_id, true), + 'type' => $this->type, + 'object_id' => (string) $this->object_id, + 'object_type' => $this->object_type, + 'reported' => AccountService::get($this->reported_profile_id, true), + 'status' => null, + 'reporter_message' => $this->message, + 'admin_seen_at' => $this->admin_seen, + 'created_at' => $this->created_at, + ]; + + if($this->object_id && $this->object_type === 'App\Status') { + $res['status'] = StatusService::get($this->object_id, false); + } + + return $res; + } +} diff --git a/app/Http/Resources/AdminSpamReport.php b/app/Http/Resources/AdminSpamReport.php new file mode 100644 index 000000000..6e2badd0f --- /dev/null +++ b/app/Http/Resources/AdminSpamReport.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + $res = [ + 'id' => $this->id, + 'type' => $this->type, + 'status' => null, + 'read_at' => $this->read_at, + 'created_at' => $this->created_at, + ]; + + if($this->item_id && $this->item_type === 'App\Status') { + $res['status'] = StatusService::get($this->item_id, false); + } + + return $res; + } +} diff --git a/public/js/admin.js b/public/js/admin.js index c51e4892d..b2efdf1dd 100644 Binary files a/public/js/admin.js and b/public/js/admin.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index fdef32cdc..c1d72cb20 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/components/admin/AdminReports.vue b/resources/assets/components/admin/AdminReports.vue new file mode 100644 index 000000000..fdd11b012 --- /dev/null +++ b/resources/assets/components/admin/AdminReports.vue @@ -0,0 +1,937 @@ + + + diff --git a/resources/views/admin/reports/home.blade.php b/resources/views/admin/reports/home.blade.php index 996ad1b1e..de0d9ea92 100644 --- a/resources/views/admin/reports/home.blade.php +++ b/resources/views/admin/reports/home.blade.php @@ -1,93 +1,12 @@ @extends('admin.partial.template-full') @section('section') -
-

Reports

-
- @if(request()->has('filter') && request()->filter == 'closed') - - View Open Reports - - @else - - View Closed Reports - - @endif -
-
- -
-
- -

{{$mailVerifications}}

- Email Verify {{$mailVerifications == 1 ? 'Request' : 'Requests'}} -
- -

{{$ai}}

- Appeal {{$ai == 1 ? 'Request' : 'Requests'}} -
- -

{{$spam}}

- Flagged {{$ai == 1 ? 'Post' : 'Posts'}} -
-
-
- @if($reports->count()) -
-
-
- @foreach($reports as $report) -
-
-
- - - -
-

{{$report->type}}

- @if($report->reporter && $report->status) -

{{$report->reporter->username}} reported this post

- @else -

- @if(!$report->reporter) - Deleted user - @else - {{$report->reporter->username}} - @endif - reported this - @if(!$report->status) - deleted post - @else - post - @endif - -

- - @endif -
-
- @if($report->status) - - View - - @endif -
-
-
-
- @endforeach -
-
-
- @else -
-
-

No reports found

-
-
- @endif - -
- {{$reports->appends(['layout'=>request()->layout, 'filter' => request()->filter])->links()}} -
+ + @endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/reports/show.blade.php b/resources/views/admin/reports/show.blade.php index 3ab9d5f7b..da0579613 100644 --- a/resources/views/admin/reports/show.blade.php +++ b/resources/views/admin/reports/show.blade.php @@ -1,6 +1,14 @@ @extends('admin.partial.template-full') @section('section') +
+
+

Try the new Report UI

+

We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.

+
+ View in new Report UI +
+

@@ -16,16 +24,17 @@

- @if($report->status->media()->count()) + @if($report->status && $report->status->media()->count()) @endif
- @if($report->status->caption) + @if($report->status && $report->status->caption)

{{$report->status->media()->count() ? 'Caption' : 'Comment'}}: {{$report->status->caption}}

@endif + @if($report->status)

Like Count: {{$report->status->likes_count}}

@@ -41,7 +50,8 @@

Local URL: {{url('/i/web/post/' . $report->status->id)}}

- @if($report->status->in_reply_to_id) + @endif + @if($report->status && $report->status->in_reply_to_id)

Parent Post: {{url('/i/web/post/' . $report->status->in_reply_to_id)}}

diff --git a/resources/views/admin/reports/show_spam.blade.php b/resources/views/admin/reports/show_spam.blade.php index c001b39a1..2559bfaa8 100644 --- a/resources/views/admin/reports/show_spam.blade.php +++ b/resources/views/admin/reports/show_spam.blade.php @@ -1,6 +1,13 @@ @extends('admin.partial.template-full') @section('section') +
+
+

Try the new Report UI

+

We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.

+
+ View in new Report UI +

Autospam

@@ -15,7 +22,7 @@
Unlisted + Content Warning
@if($appeal->has_media) - + @endif
@@ -34,7 +41,7 @@ Timestamp: {{now()->parse($meta->created_at)->format('r')}}

- URL: {{$meta->url}} + URL: {{$meta->url}}

@@ -49,6 +56,8 @@
+
+ @endif
@@ -107,6 +116,12 @@ } break; + case 'mark-spammer': + if(!window.confirm('Are you sure you want to mark this account as a spammer?')) { + return; + } + break; + case 'delete-account': if(!window.confirm('Are you sure you want to delete this account?')) { return; diff --git a/routes/web.php b/routes/web.php index 828b13fcf..c0d12153b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -122,6 +122,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi'); Route::get('instances/download-backup', 'AdminController@downloadBackup'); Route::post('instances/import-data', 'AdminController@importBackup'); + Route::get('reports/stats', 'AdminController@reportsStats'); + Route::get('reports/all', 'AdminController@reportsApiAll'); + Route::get('reports/get/{id}', 'AdminController@reportsApiGet'); + Route::post('reports/handle', 'AdminController@reportsApiHandle'); + Route::get('reports/spam/all', 'AdminController@reportsApiSpamAll'); + Route::get('reports/spam/get/{id}', 'AdminController@reportsApiSpamGet'); + Route::post('reports/spam/handle', 'AdminController@reportsApiSpamHandle'); }); });