From a224c71151b5bf664a29685fe08b5e8eebcd0c5f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 14 Jan 2023 20:33:46 -0700 Subject: [PATCH] Add admin api --- .../Controllers/Api/AdminApiController.php | 539 +++++++++++++++--- routes/api.php | 15 + routes/web.php | 3 - 3 files changed, 470 insertions(+), 87 deletions(-) diff --git a/app/Http/Controllers/Api/AdminApiController.php b/app/Http/Controllers/Api/AdminApiController.php index 902b00536..ad587c756 100644 --- a/app/Http/Controllers/Api/AdminApiController.php +++ b/app/Http/Controllers/Api/AdminApiController.php @@ -5,114 +5,485 @@ namespace App\Http\Controllers\Api; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Jobs\StatusPipeline\StatusDelete; -use Auth, Cache; +use Auth, Cache, DB; use Carbon\Carbon; use App\{ + AccountInterstitial, Like, Media, Profile, - Status + Report, + Status, + User }; - +use App\Services\AccountService; +use App\Services\AdminStatsService; +use App\Services\ConfigCacheService; +use App\Services\ModLogService; +use App\Services\StatusService; use App\Services\NotificationService; +use App\Http\Resources\AdminUser; class AdminApiController extends Controller { - public function __construct() + public function supported(Request $request) { - $this->middleware(['auth', 'admin']); + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + return response()->json(['supported' => true]); } - public function activity(Request $request) + public function getStats(Request $request) { - $activity = []; - - $limit = request()->input('limit', 20); - - $activity['captions'] = Status::select( - 'id', - 'caption', - 'rendered', - 'uri', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'created_at' - )->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->orderByDesc('created_at') - ->paginate($limit); - - $activity['comments'] = Status::select( - 'id', - 'caption', - 'rendered', - 'uri', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'created_at' - )->whereNotNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->orderByDesc('created_at') - ->paginate($limit); - - return response()->json($activity, 200, [], JSON_PRETTY_PRINT); + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + $res = AdminStatsService::summary(); + $res['autospam_count'] = AccountInterstitial::whereType('post.autospam') + ->whereNull('appeal_handled_at') + ->count(); + return $res; } - public function moderateStatus(Request $request) + public function autospam(Request $request) { - abort(400, 'Unpublished API'); - return; + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + $appeals = AccountInterstitial::whereType('post.autospam') + ->whereNull('appeal_handled_at') + ->latest() + ->simplePaginate(6) + ->map(function($report) { + $r = [ + 'id' => $report->id, + 'type' => $report->type, + 'item_id' => $report->item_id, + 'item_type' => $report->item_type, + 'created_at' => $report->created_at + ]; + if($report->item_type === 'App\\Status') { + $status = StatusService::get($report->item_id, false); + if(!$status) { + return; + } + + $r['status'] = $status; + + if($status['in_reply_to_id']) { + $r['parent'] = StatusService::get($status['in_reply_to_id'], false); + } + } + return $r; + }); + + return $appeals; + } + + public function autospamHandle(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + $this->validate($request, [ - 'type' => 'required|string|in:status,profile', - 'id' => 'required|integer|min:1', - 'action' => 'required|string|in:cw,unlink,unlist,suspend,delete' + 'action' => 'required|in:dismiss,approve,dismiss-all,approve-all', + 'id' => 'required' ]); - $type = $request->input('type'); - $id = $request->input('id'); $action = $request->input('action'); + $id = $request->input('id'); + $appeal = AccountInterstitial::whereType('post.autospam') + ->whereNull('appeal_handled_at') + ->findOrFail($id); + $now = now(); + $res = ['status' => 'success']; + $meta = json_decode($appeal->meta); - if ($type == 'status') { - $status = Status::findOrFail($id); - switch ($action) { - case 'cw': - $status->is_nsfw = true; - $status->save(); - break; - case 'unlink': - $status->rendered = $status->caption; - $status->save(); - break; - case 'unlist': - $status->scope = 'unlisted'; - $status->visibility = 'unlisted'; - $status->save(); - break; - - default: - break; - } - } else if ($type == 'profile') { - $profile = Profile::findOrFail($id); - switch ($action) { + if($action == 'dismiss') { + $appeal->is_spam = true; + $appeal->appeal_handled_at = $now; + $appeal->save(); - case 'delete': - StatusDelete::dispatch($status); - break; - - default: - break; - } + Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id); + Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id); + Cache::forget('admin-dash:reports:spam-count'); + return $res; } + if($action == 'dismiss-all') { + AccountInterstitial::whereType('post.autospam') + ->whereItemType('App\Status') + ->whereNull('appeal_handled_at') + ->whereUserId($appeal->user_id) + ->update(['appeal_handled_at' => $now, 'is_spam' => true]); + Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id); + Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id); + Cache::forget('admin-dash:reports:spam-count'); + return $res; + } + + if($action == 'approve') { + $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); + + Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id); + Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id); + Cache::forget('admin-dash:reports:spam-count'); + return $res; + } + + if($action == 'approve-all') { + AccountInterstitial::whereType('post.autospam') + ->whereItemType('App\Status') + ->whereNull('appeal_handled_at') + ->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, true); + } + }); + Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id); + Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id); + Cache::forget('admin-dash:reports:spam-count'); + return $res; + } + + return $res; } -} \ No newline at end of file + public function modReports(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + $reports = Report::whereNull('admin_seen') + ->orderBy('created_at','desc') + ->paginate(6) + ->map(function($report) { + $r = [ + 'id' => $report->id, + 'type' => $report->type, + 'message' => $report->message, + 'object_id' => $report->object_id, + 'object_type' => $report->object_type, + 'created_at' => $report->created_at + ]; + + if($report->profile_id) { + $r['reported_by_account'] = AccountService::get($report->profile_id, true); + } + + if($report->object_type === 'App\\Status') { + $status = StatusService::get($report->object_id, false); + if(!$status) { + return; + } + + $r['status'] = $status; + + if($status['in_reply_to_id']) { + $r['parent'] = StatusService::get($status['in_reply_to_id'], false); + } + } + + if($report->object_type === 'App\\Profile') { + $r['account'] = AccountService::get($report->object_id, false); + } + return $r; + }) + ->filter() + ->values(); + + return $reports; + } + + public function modReportHandle(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + $this->validate($request, [ + 'action' => 'required|string', + 'id' => 'required' + ]); + + $action = $request->input('action'); + $id = $request->input('id'); + + $actions = [ + 'ignore', + 'cw', + 'unlist' + ]; + + if (!in_array($action, $actions)) { + return abort(403); + } + + $report = Report::findOrFail($id); + $item = $report->reported(); + $report->admin_seen = now(); + + switch ($action) { + case 'ignore': + $report->not_interested = true; + break; + + case 'cw': + Cache::forget('status:thumb:'.$item->id); + $item->is_nsfw = true; + $item->save(); + $report->nsfw = true; + StatusService::del($item->id, true); + break; + + case 'unlist': + $item->visibility = 'unlisted'; + $item->save(); + StatusService::del($item->id, true); + break; + + default: + $report->admin_seen = null; + break; + } + + $report->save(); + Cache::forget('admin-dash:reports:list-cache'); + Cache::forget('admin:dashboard:home:data:v0:15min'); + + return ['success' => true]; + } + + public function getConfiguration(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + abort_unless(config('instance.enable_cc'), 400); + + return collect([ + [ + 'name' => 'ActivityPub Federation', + 'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.', + 'key' => 'federation.activitypub.enabled' + ], + + [ + 'name' => 'Open Registration', + 'description' => 'Allow new account registrations.', + 'key' => 'pixelfed.open_registration' + ], + + [ + 'name' => 'Stories', + 'description' => 'Enable the ephemeral Stories feature.', + 'key' => 'instance.stories.enabled' + ], + + [ + 'name' => 'Require Email Verification', + 'description' => 'Require new accounts to verify their email address.', + 'key' => 'pixelfed.enforce_email_verification' + ], + + [ + 'name' => 'AutoSpam Detection', + 'description' => 'Detect and remove spam from public timelines.', + 'key' => 'pixelfed.bouncer.enabled' + ], + ]) + ->map(function($s) { + $s['state'] = (bool) config_cache($s['key']); + return $s; + }); + } + + public function updateConfiguration(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + abort_unless(config('instance.enable_cc'), 400); + + $this->validate($request, [ + 'key' => 'required', + 'value' => 'required' + ]); + + $allowedKeys = [ + 'federation.activitypub.enabled', + 'pixelfed.open_registration', + 'instance.stories.enabled', + 'pixelfed.enforce_email_verification', + 'pixelfed.bouncer.enabled', + ]; + + $key = $request->input('key'); + $value = (bool) filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN); + abort_if(!in_array($key, $allowedKeys), 400, 'Invalid cache key.'); + + ConfigCacheService::put($key, $value); + + return collect([ + [ + 'name' => 'ActivityPub Federation', + 'description' => 'Enable activitypub federation support, compatible with Pixelfed, Mastodon and other platforms.', + 'key' => 'federation.activitypub.enabled' + ], + + [ + 'name' => 'Open Registration', + 'description' => 'Allow new account registrations.', + 'key' => 'pixelfed.open_registration' + ], + + [ + 'name' => 'Stories', + 'description' => 'Enable the ephemeral Stories feature.', + 'key' => 'instance.stories.enabled' + ], + + [ + 'name' => 'Require Email Verification', + 'description' => 'Require new accounts to verify their email address.', + 'key' => 'pixelfed.enforce_email_verification' + ], + + [ + 'name' => 'AutoSpam Detection', + 'description' => 'Detect and remove spam from public timelines.', + 'key' => 'pixelfed.bouncer.enabled' + ], + ]) + ->map(function($s) { + $s['state'] = (bool) config_cache($s['key']); + return $s; + }); + } + + public function getUsers(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + $q = $request->input('q'); + $sort = $request->input('sort', 'desc') === 'asc' ? 'asc' : 'desc'; + $res = User::whereNull('status') + ->when($q, function($query, $q) { + return $query->where('username', 'like', '%' . $q . '%'); + }) + ->orderBy('id', $sort) + ->cursorPaginate(10); + return AdminUser::collection($res); + } + + public function getUser(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + $id = $request->input('user_id'); + $user = User::findOrFail($id); + $profile = $user->profile; + $account = AccountService::get($user->profile_id, true); + return (new AdminUser($user))->additional(['meta' => [ + 'account' => $account, + 'moderation' => [ + 'unlisted' => (bool) $profile->unlisted, + 'cw' => (bool) $profile->cw, + 'no_autolink' => (bool) $profile->no_autolink + ] + ]]); + } + + public function userAdminAction(Request $request) + { + abort_if(!$request->user(), 404); + abort_unless($request->user()->is_admin === 1, 404); + + $this->validate($request, [ + 'id' => 'required', + 'action' => 'required|in:unlisted,cw,no_autolink,refresh_stats,verify_email', + 'value' => 'sometimes' + ]); + + $id = $request->input('id'); + $user = User::findOrFail($id); + $profile = Profile::findOrFail($user->profile_id); + $action = $request->input('action'); + + abort_if($user->is_admin == true && $action !== 'refresh_stats', 400, 'Cannot moderate admin accounts'); + + if($action === 'refresh_stats') { + $profile->following_count = DB::table('followers')->whereProfileId($user->profile_id)->count(); + $profile->followers_count = DB::table('followers')->whereFollowingId($user->profile_id)->count(); + $statusCount = Status::whereProfileId($user->profile_id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereIn('scope', ['public', 'unlisted', 'private']) + ->count(); + $profile->status_count = $statusCount; + $profile->save(); + } else if($action === 'verify_email') { + $user->email_verified_at = now(); + $user->save(); + + ModLogService::boot() + ->objectUid($user->id) + ->objectId($user->id) + ->objectType('App\User::class') + ->user($request->user()) + ->action('admin.user.moderate') + ->metadata([ + 'action' => 'Manually verified email address', + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + } else { + $profile->{$action} = filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN); + $profile->save(); + + ModLogService::boot() + ->objectUid($user->id) + ->objectId($user->id) + ->objectType('App\User::class') + ->user($request->user()) + ->action('admin.user.moderate') + ->metadata([ + 'action' => $action, + 'message' => 'Success!' + ]) + ->accessLevel('admin') + ->save(); + } + + AccountService::del($user->profile_id); + $account = AccountService::get($user->profile_id, true); + + return (new AdminUser($user))->additional(['meta' => [ + 'account' => $account, + 'moderation' => [ + 'unlisted' => (bool) $profile->unlisted, + 'cw' => (bool) $profile->cw, + 'no_autolink' => (bool) $profile->no_autolink + ] + ]]); + } +} diff --git a/routes/api.php b/routes/api.php index 3d4df4c54..f3ab0c31f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -179,4 +179,19 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('broadcast/publish', 'LiveStreamController@clientBroadcastPublish'); Route::post('broadcast/finish', 'LiveStreamController@clientBroadcastFinish'); }); + + Route::group(['prefix' => 'admin'], function() use($middleware) { + Route::get('supported', 'Api\AdminApiController@supported')->middleware($middleware); + Route::get('stats', 'Api\AdminApiController@getStats')->middleware($middleware); + + Route::get('autospam/list', 'Api\AdminApiController@autospam')->middleware($middleware); + Route::post('autospam/handle', 'Api\AdminApiController@autospamHandle')->middleware($middleware); + Route::get('mod-reports/list', 'Api\AdminApiController@modReports')->middleware($middleware); + Route::post('mod-reports/handle', 'Api\AdminApiController@modReportHandle')->middleware($middleware); + Route::get('config', 'Api\AdminApiController@getConfiguration')->middleware($middleware); + Route::post('config/update', 'Api\AdminApiController@updateConfiguration')->middleware($middleware); + Route::get('users/list', 'Api\AdminApiController@getUsers')->middleware($middleware); + Route::get('users/get', 'Api\AdminApiController@getUser')->middleware($middleware); + Route::post('users/action', 'Api\AdminApiController@userAdminAction')->middleware($middleware); + }); }); diff --git a/routes/web.php b/routes/web.php index 90faf6cbe..5949a708e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -286,9 +286,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('compose/location/search', 'ApiController@composeLocationSearch'); Route::post('compose/tag/untagme', 'MediaTagController@untagProfile'); }); - Route::group(['prefix' => 'admin'], function () { - Route::post('moderate', 'Api\AdminApiController@moderate'); - }); Route::group(['prefix' => 'web/stories'], function () { Route::get('v1/recent', 'StoryController@recent');