From 652654e24fe21a50ad01e044bc003a55442e34be Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 29 Mar 2024 22:48:22 +0100 Subject: [PATCH 1/8] WIP: Implement domain blocks --- app/Http/Controllers/Api/ApiController.php | 38 ++++++++++ .../Api/V1/Admin/DomainBlocksController.php | 76 +++++++++++++++++++ .../MastoApi/Admin/DomainBlockResource.php | 39 ++++++++++ app/Instance.php | 7 ++ routes/api.php | 6 ++ 5 files changed, 166 insertions(+) create mode 100644 app/Http/Controllers/Api/ApiController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php create mode 100644 app/Http/Resources/MastoApi/Admin/DomainBlockResource.php diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 000000000..76d9d7db2 --- /dev/null +++ b/app/Http/Controllers/Api/ApiController.php @@ -0,0 +1,38 @@ +json($res, $code, $this->filterHeaders($headers), JSON_UNESCAPED_SLASHES); + } + + public function linksForCollection($paginator) { + $link = null; + + if ($paginator->onFirstPage()) { + if ($paginator->hasMorePages()) { + $link = '<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } else { + if ($paginator->previousPageUrl()) { + $link = '<'.$paginator->previousPageUrl().'>; rel="next"'; + } + + if ($paginator->hasMorePages()) { + $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } + + return $link; + } + + private function filterHeaders($headers) { + return array_filter($headers, function($v, $k) { + return $v != null; + }, ARRAY_FILTER_USE_BOTH); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php new file mode 100644 index 000000000..f88da495a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -0,0 +1,76 @@ +validate($request, [ + 'limit' => 'sometimes|integer|max:100|min:1', + ]); + + $limit = $request->input('limit', 100); + + $res = Instance::moderated() + ->orderBy('id') + ->cursorPaginate($limit) + ->withQueryString(); + + return $this->json(DomainBlockResource::collection($res), [ + 'Link' => $this->linksForCollection($res) + ]); + } + + public function show(Request $request, $id) { + $res = Instance::moderated() + ->findOrFail($id); + + return $this->json(new DomainBlockResource($res)); + } + + public function create(Request $request) { + $this->validate($request, [ + 'domain' => 'required|string|min:1|max:120', + 'severity' => [ + 'sometimes', + Rule::in(['noop', 'silence', 'suspend']) + ], + 'reject_media' => 'sometimes|required|boolean', + 'reject_reports' => 'sometimes|required|boolean', + 'private_comment' => 'sometimes|string|min:1|max:1000', + 'public_comment' => 'sometimes|string|min:1|max:1000', + 'obfuscate' => 'sometimes|required|boolean' + ]); + + $domain = $request->input('domain'); + $severity = $request->input('severity'); + $private_comment = $request->input('private_comment'); + + abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); + abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); + + $existing = Instance::moderated()->whereDomain($domain)->first(); + + if ($existing) { + return $this->json([ + 'error' => 'A domain block already exists for this domain', + 'existing_domain_block' => new DomainBlockResource($existing) + ], [], 422); + } + + $domain_block = Instance::updateOrCreate( + [ 'domain' => $domain ], + [ 'banned' => $severity === 'suspend', 'unlisted' => $severity === 'silence', 'notes' => [$private_comment]] + ); + + InstanceService::refresh(); + + return $this->json(new DomainBlockResource($domain_block)); + } +} \ No newline at end of file diff --git a/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php new file mode 100644 index 000000000..a2056c94b --- /dev/null +++ b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + $severity = 'noop'; + if ($this->banned) { + $severity = 'suspend'; + } else if ($this->unlisted) { + $severity = 'silence'; + } + + return [ + 'id' => $this->id, + 'domain' => $this->domain, + 'severity' => $severity, + // Using the updated_at value as this is going to be the closest to + // when the domain was banned + 'created_at' => $this->updated_at, + // We don't have data for these fields + 'reject_media' => false, + 'reject_reports' => false, + 'private_comment' => $this->notes ? join('; ', $this->notes) : null, + 'public_comment' => $this->limit_reason, + 'obfuscate' => false + ]; + } +} diff --git a/app/Instance.php b/app/Instance.php index 77752d498..a93d9e95e 100644 --- a/app/Instance.php +++ b/app/Instance.php @@ -22,6 +22,13 @@ class Instance extends Model 'notes' ]; + // To get all moderated instances, we need to search where (banned OR unlisted) + public function scopeModerated($query): void { + $query->where(function ($query) { + $query->where('banned', true)->orWhere('unlisted', true); + }); + } + public function profiles() { return $this->hasMany(Profile::class, 'domain', 'domain'); diff --git a/routes/api.php b/routes/api.php index af40e27bc..95440d3c0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -101,6 +101,12 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('statuses/{id}/history', 'StatusEditController@history')->middleware($middleware); Route::put('statuses/{id}', 'StatusEditController@store')->middleware($middleware); + + Route::group(['prefix' => 'admin'], function() use($middleware) { + Route::get('domain_blocks', 'Api\V1\Admin\DomainBlocksController@index')->middleware($middleware); + Route::post('domain_blocks', 'Api\V1\Admin\DomainBlocksController@create')->middleware($middleware); + Route::get('domain_blocks/{id}', 'Api\V1\Admin\DomainBlocksController@show')->middleware($middleware); + })->middleware($middleware); }); Route::group(['prefix' => 'v2'], function() use($middleware) { From 21466556d46dbf2066694ca456c2b89f7657f582 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 29 Mar 2024 23:05:48 +0100 Subject: [PATCH 2/8] Add update and delete --- app/Http/Controllers/Api/ApiController.php | 2 +- .../Api/V1/Admin/DomainBlocksController.php | 41 ++++++++++++++++++- routes/api.php | 2 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 76d9d7db2..d8ba76668 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -35,4 +35,4 @@ class ApiController extends Controller { return $v != null; }, ARRAY_FILTER_USE_BOTH); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php index f88da495a..56ba584cc 100644 --- a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -73,4 +73,43 @@ class DomainBlocksController extends ApiController { return $this->json(new DomainBlockResource($domain_block)); } -} \ No newline at end of file + + public function update(Request $request, $id) { + $this->validate($request, [ + 'severity' => [ + 'sometimes', + Rule::in(['noop', 'silence', 'suspend']) + ], + 'reject_media' => 'sometimes|required|boolean', + 'reject_reports' => 'sometimes|required|boolean', + 'private_comment' => 'sometimes|string|min:1|max:1000', + 'public_comment' => 'sometimes|string|min:1|max:1000', + 'obfuscate' => 'sometimes|required|boolean' + ]); + + $severity = $request->input('severity'); + $private_comment = $request->input('private_comment'); + + $instance = Instance::moderated()->findOrFail($id); + + $instance->banned = $severity === 'suspend'; + $instance->unlisted = $severity === 'silence'; + $instance->notes = [$private_comment]; + $instance->save(); + + InstanceService::refresh(); + + return $this->json(new DomainBlockResource($instance)); + } + + public function delete(Request $request, $id) { + $instance = Instance::moderated()->findOrFail($id); + $instance->banned = false; + $instance->unlisted = false; + $instance->save(); + + InstanceService::refresh(); + + return $this->json([], [], 200); + } +} diff --git a/routes/api.php b/routes/api.php index 95440d3c0..85e441823 100644 --- a/routes/api.php +++ b/routes/api.php @@ -106,6 +106,8 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('domain_blocks', 'Api\V1\Admin\DomainBlocksController@index')->middleware($middleware); Route::post('domain_blocks', 'Api\V1\Admin\DomainBlocksController@create')->middleware($middleware); Route::get('domain_blocks/{id}', 'Api\V1\Admin\DomainBlocksController@show')->middleware($middleware); + Route::put('domain_blocks/{id}', 'Api\V1\Admin\DomainBlocksController@update')->middleware($middleware); + Route::delete('domain_blocks/{id}', 'Api\V1\Admin\DomainBlocksController@delete')->middleware($middleware); })->middleware($middleware); }); From feed580f51c3b1a08cf0c32769cce70b20651ea4 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 29 Mar 2024 23:10:38 +0100 Subject: [PATCH 3/8] Minor compatibility fixes --- .../Api/V1/Admin/DomainBlocksController.php | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php index 56ba584cc..287ba58e2 100644 --- a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -28,10 +28,13 @@ class DomainBlocksController extends ApiController { } public function show(Request $request, $id) { - $res = Instance::moderated() - ->findOrFail($id); + $domain_block = Instance::moderated()->find($id); - return $this->json(new DomainBlockResource($res)); + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + return $this->json(new DomainBlockResource($domain_block)); } public function create(Request $request) { @@ -55,12 +58,12 @@ class DomainBlocksController extends ApiController { abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); - $existing = Instance::moderated()->whereDomain($domain)->first(); + $existing_domain_block = Instance::moderated()->whereDomain($domain)->first(); - if ($existing) { + if ($existing_domain_block) { return $this->json([ 'error' => 'A domain block already exists for this domain', - 'existing_domain_block' => new DomainBlockResource($existing) + 'existing_domain_block' => new DomainBlockResource($existing_domain_block) ], [], 422); } @@ -90,26 +93,35 @@ class DomainBlocksController extends ApiController { $severity = $request->input('severity'); $private_comment = $request->input('private_comment'); - $instance = Instance::moderated()->findOrFail($id); + $domain_block = Instance::moderated()->find($id); - $instance->banned = $severity === 'suspend'; - $instance->unlisted = $severity === 'silence'; - $instance->notes = [$private_comment]; - $instance->save(); + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + $domain_block->banned = $severity === 'suspend'; + $domain_block->unlisted = $severity === 'silence'; + $domain_block->notes = [$private_comment]; + $domain_block->save(); InstanceService::refresh(); - return $this->json(new DomainBlockResource($instance)); + return $this->json(new DomainBlockResource($domain_block)); } public function delete(Request $request, $id) { - $instance = Instance::moderated()->findOrFail($id); - $instance->banned = false; - $instance->unlisted = false; - $instance->save(); + $domain_block = Instance::moderated()->find($id); + + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + $domain_block->banned = false; + $domain_block->unlisted = false; + $domain_block->save(); InstanceService::refresh(); - return $this->json([], [], 200); + return $this->json(null, [], 200); } } From 4afe72e62f63122ca12bc98fa798ceb5a30168bf Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 30 Mar 2024 00:16:06 +0100 Subject: [PATCH 4/8] Add oauth protection to admin domain blocks API --- .../Api/V1/Admin/DomainBlocksController.php | 6 +++++ app/Http/Kernel.php | 3 +++ app/Http/Middleware/Api/Admin.php | 26 +++++++++++++++++++ app/Providers/AuthServiceProvider.php | 2 ++ 4 files changed, 37 insertions(+) create mode 100644 app/Http/Middleware/Api/Admin.php diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php index 287ba58e2..66bd2f3a8 100644 --- a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -10,6 +10,12 @@ use App\Services\InstanceService; use App\Http\Resources\MastoApi\Admin\DomainBlockResource; class DomainBlocksController extends ApiController { + + public function __construct() { + $this->middleware(['auth:api', 'api.admin', 'scope:admin:read,admin:read:domain_blocks'])->only(['index', 'show']); + $this->middleware(['auth:api', 'api.admin', 'scope:admin:write,admin:write:domain_blocks'])->only(['create', 'update', 'delete']); + } + public function index(Request $request) { $this->validate($request, [ 'limit' => 'sometimes|integer|max:100|min:1', diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 5cc99014b..bb1931555 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -54,6 +54,7 @@ class Kernel extends HttpKernel * @var array */ protected $routeMiddleware = [ + 'api.admin' => \App\Http\Middleware\Api\Admin::class, 'admin' => \App\Http\Middleware\Admin::class, 'auth' => \Illuminate\Auth\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, @@ -68,6 +69,8 @@ class Kernel extends HttpKernel 'twofactor' => \App\Http\Middleware\TwoFactorAuth::class, 'validemail' => \App\Http\Middleware\EmailVerificationCheck::class, 'interstitial' => \App\Http\Middleware\AccountInterstitial::class, + 'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class, + 'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class, // 'restricted' => \App\Http\Middleware\RestrictedAccess::class, ]; } diff --git a/app/Http/Middleware/Api/Admin.php b/app/Http/Middleware/Api/Admin.php new file mode 100644 index 000000000..65d24758d --- /dev/null +++ b/app/Http/Middleware/Api/Admin.php @@ -0,0 +1,26 @@ +is_admin == false) { + return abort(403, "You must be an administrator to do that"); + } + + return $next($request); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 52e992ce0..4301fc818 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -37,7 +37,9 @@ class AuthServiceProvider extends ServiceProvider 'write' => 'Full write access to your account', 'follow' => 'Ability to follow other profiles', 'admin:read' => 'Read all data on the server', + 'admin:read:domain_blocks' => 'Read sensitive information of all domain blocks', 'admin:write' => 'Modify all data on the server', + 'admin:write:domain_blocks' => 'Perform moderation actions on domain blocks', 'push' => 'Receive your push notifications' ]); From fcdfc73eafec476a0ff64731b34aa7d85c4e034e Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 30 Mar 2024 00:17:20 +0100 Subject: [PATCH 5/8] Remove production environment check in AuthServiceProvider This check resulted in the /oauth/scopes route returning nothing, meaning in development you couldn't use access tokens with scopes; It probably also affected other logic --- app/Providers/AuthServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 4301fc818..1a41b9e51 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -24,7 +24,7 @@ class AuthServiceProvider extends ServiceProvider */ public function boot() { - if(config('app.env') === 'production' && config('pixelfed.oauth_enabled') == true) { + if(config('pixelfed.oauth_enabled') == true) { Passport::tokensExpireIn(now()->addDays(config('instance.oauth.token_expiration', 356))); Passport::refreshTokensExpireIn(now()->addDays(config('instance.oauth.refresh_expiration', 400))); Passport::enableImplicitGrant(); From 2d34e86f26b6e80fe07a04bb6d5d292507816ca5 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 30 Mar 2024 00:26:26 +0100 Subject: [PATCH 6/8] Add digest to Admin DomainBlockResource, coming in Mastodon 4.3 --- app/Http/Resources/MastoApi/Admin/DomainBlockResource.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php index a2056c94b..eeb3ddc09 100644 --- a/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php +++ b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php @@ -24,6 +24,9 @@ class DomainBlockResource extends JsonResource return [ 'id' => $this->id, 'domain' => $this->domain, + // This property is coming in Mastodon 4.3, although it'll only be + // useful if Pixelfed supports obfuscating domains: + 'digest' => hash('sha256', $this->domain), 'severity' => $severity, // Using the updated_at value as this is going to be the closest to // when the domain was banned From 96a226d90cd59773d43d9fabd243d39f73bea878 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 30 Mar 2024 00:48:09 +0100 Subject: [PATCH 7/8] Fix issue with wildcard domain blocks --- .../Api/V1/Admin/DomainBlocksController.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php index 66bd2f3a8..955891d12 100644 --- a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -64,6 +64,19 @@ class DomainBlocksController extends ApiController { abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); + $parts = explode('.', $domain); + + if ($parts[0] == '*') { + // If we only have two parts, e.g., "*", "example", then we want to fail: + abort_if(count($parts) <= 2, 400, 'Invalid domain: This API does not support wildcard domain blocks yet'); + + // Otherwise we convert the *.foo.example to foo.example + $domain = implode('.', array_slice($parts, 1)); + } + + // Double check we definitely haven't let anything through: + abort_if(str_contains($domain, '*'), 400, 'Invalid domain'); + $existing_domain_block = Instance::moderated()->whereDomain($domain)->first(); if ($existing_domain_block) { From 626c87a420cf7ce0853bb5c3aa510120287ad3fb Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 30 Mar 2024 00:54:55 +0100 Subject: [PATCH 8/8] Fix default severity for domain blocks --- .../Api/V1/Admin/DomainBlocksController.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php index 955891d12..95e399720 100644 --- a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -56,16 +56,17 @@ class DomainBlocksController extends ApiController { 'public_comment' => 'sometimes|string|min:1|max:1000', 'obfuscate' => 'sometimes|required|boolean' ]); - + $domain = $request->input('domain'); - $severity = $request->input('severity'); + $severity = $request->input('severity', 'silence'); $private_comment = $request->input('private_comment'); - abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); - abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); + abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); + abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); + // This is because Pixelfed can't currently support wildcard domain blocks + // We have to find something that could plausibly be an instance $parts = explode('.', $domain); - if ($parts[0] == '*') { // If we only have two parts, e.g., "*", "example", then we want to fail: abort_if(count($parts) <= 2, 400, 'Invalid domain: This API does not support wildcard domain blocks yet'); @@ -108,8 +109,8 @@ class DomainBlocksController extends ApiController { 'public_comment' => 'sometimes|string|min:1|max:1000', 'obfuscate' => 'sometimes|required|boolean' ]); - - $severity = $request->input('severity'); + + $severity = $request->input('severity', 'silence'); $private_comment = $request->input('private_comment'); $domain_block = Instance::moderated()->find($id);