diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 000000000..d8ba76668 --- /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); + } +} 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..95e399720 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -0,0 +1,147 @@ +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', + ]); + + $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) { + $domain_block = Instance::moderated()->find($id); + + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + return $this->json(new DomainBlockResource($domain_block)); + } + + 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', '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'); + + // 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'); + + // 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) { + return $this->json([ + 'error' => 'A domain block already exists for this domain', + 'existing_domain_block' => new DomainBlockResource($existing_domain_block) + ], [], 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)); + } + + 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', 'silence'); + $private_comment = $request->input('private_comment'); + + $domain_block = Instance::moderated()->find($id); + + 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($domain_block)); + } + + public function delete(Request $request, $id) { + $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(null, [], 200); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index df39ef60b..4ec8832e8 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/Http/Resources/MastoApi/Admin/DomainBlockResource.php b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php new file mode 100644 index 000000000..eeb3ddc09 --- /dev/null +++ b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php @@ -0,0 +1,42 @@ + + */ + 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, + // 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 + '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/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 43d12b592..dfd4518c3 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' && (bool) config_cache('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(); @@ -37,8 +37,10 @@ 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', - 'push' => 'Receive your push notifications', + 'admin:write:domain_blocks' => 'Perform moderation actions on domain blocks', + 'push' => 'Receive your push notifications' ]); Passport::setDefaultScope([ diff --git a/routes/api.php b/routes/api.php index 6aee0da99..524e91d9b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -169,6 +169,14 @@ 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); + 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); }); Route::group(['prefix' => 'v2'], function() use($middleware) {