diff --git a/src/API/FilterAuthorizer.php b/src/API/FilterAuthorizer.php index 2a7f6e9007..c66f77dce6 100644 --- a/src/API/FilterAuthorizer.php +++ b/src/API/FilterAuthorizer.php @@ -6,6 +6,8 @@ class FilterAuthorizer extends AbstractAuthorizer { + protected $configKey = 'allowed_filters'; + /** * Get allowed filters for resource. * @@ -17,7 +19,7 @@ class FilterAuthorizer extends AbstractAuthorizer */ public function allowedForResource($configFile, $queriedResource) { - $config = config("statamic.{$configFile}.resources.{$queriedResource}.allowed_filters"); + $config = config("statamic.{$configFile}.resources.{$queriedResource}.{$this->configKey}"); // Use explicitly configured `allowed_filters` array, otherwise no filters should be allowed. return is_array($config) @@ -54,7 +56,7 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine if any of our queried resources have filters explicitly disabled. $disabled = $resources - ->filter(fn ($resource) => Arr::get($config, "{$resource}.allowed_filters") === false) + ->filter(fn ($resource) => Arr::get($config, "{$resource}.{$this->configKey}") === false) ->isNotEmpty(); // If any queried resource is explicitly disabled, then no filters should be allowed. @@ -65,10 +67,10 @@ public function allowedForSubResources($configFile, $queriedResource, $queriedHa // Determine `allowed_filters` by filtering out any that don't appear in all of them. // A resource named `*` will apply to all enabled resources at once. return $resources - ->map(fn ($resource) => $config[$resource]['allowed_filters'] ?? []) + ->map(fn ($resource) => $config[$resource][$this->configKey] ?? []) ->reduce(function ($carry, $allowedFilters) use ($config) { - return $carry->intersect($allowedFilters)->merge($config['*']['allowed_filters'] ?? []); - }, collect($config[$resources[0] ?? '']['allowed_filters'] ?? [])) + return $carry->intersect($allowedFilters)->merge($config['*'][$this->configKey] ?? []); + }, collect($config[$resources[0] ?? ''][$this->configKey] ?? [])) ->all(); } } diff --git a/src/API/QueryScopeAuthorizer.php b/src/API/QueryScopeAuthorizer.php new file mode 100644 index 0000000000..91b5c98504 --- /dev/null +++ b/src/API/QueryScopeAuthorizer.php @@ -0,0 +1,8 @@ +filterSortScopeAndPaginate($query); + } + + /** + * Filter, sort, scope, and paginate query for API resource output. + * + * @param \Statamic\Query\Builder $query + * @return \Statamic\Extensions\Pagination\LengthAwarePaginator + */ + protected function filterSortScopeAndPaginate($query) { return $this ->filter($query) ->sort($query) + ->scope($query) ->paginate($query); } @@ -171,6 +187,52 @@ protected function doesntHaveFilter($field) ->contains($field); } + /** + * Apply query scopes a query based on conditions in the query_scope parameter. + * + * /endpoint?query_scope[scope_handle]=foo&query_scope[another_scope]=bar + * + * @param \Statamic\Query\Builder $query + * @return $this + */ + protected function scope($query) + { + $this->getScopes() + ->each(function ($value, $handle) use ($query) { + Scope::find($handle)?->apply($query, Arr::wrap($value)); + }); + + return $this; + } + + /** + * Get scopes for querying. + * + * @return \Illuminate\Support\Collection + */ + protected function getScopes() + { + if (! method_exists($this, 'allowedQueryScopes')) { + return collect(); + } + + $scopes = collect(request()->query_scope ?? []); + + $allowedScopes = collect($this->allowedQueryScopes()); + + $forbidden = $scopes + ->keys() + ->filter(fn ($handle) => ! Scope::find($handle) || ! $allowedScopes->contains($handle)); + + if ($forbidden->isNotEmpty()) { + throw ApiValidationException::withMessages([ + 'query_scope' => Str::plural('Forbidden query scope', $forbidden).': '.$forbidden->join(', '), + ]); + } + + return $scopes; + } + /** * Sorts the query based on the sort parameter. * diff --git a/src/Http/Controllers/API/AssetsController.php b/src/Http/Controllers/API/AssetsController.php index 9d144b4834..bd69eee02e 100644 --- a/src/Http/Controllers/API/AssetsController.php +++ b/src/Http/Controllers/API/AssetsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Http\Resources\API\AssetResource; class AssetsController extends ApiController @@ -37,4 +38,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'assets', $this->containerHandle); + } } diff --git a/src/Http/Controllers/API/CollectionEntriesController.php b/src/Http/Controllers/API/CollectionEntriesController.php index 892d823c96..0302f5fdf4 100644 --- a/src/Http/Controllers/API/CollectionEntriesController.php +++ b/src/Http/Controllers/API/CollectionEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Entry; use Statamic\Http\Resources\API\EntryResource; @@ -81,4 +82,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/CollectionTreeController.php b/src/Http/Controllers/API/CollectionTreeController.php index 9a58dbbe72..2a5cd9f0e9 100644 --- a/src/Http/Controllers/API/CollectionTreeController.php +++ b/src/Http/Controllers/API/CollectionTreeController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Http\Resources\API\TreeResource; use Statamic\Query\ItemQueryBuilder; @@ -48,4 +49,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->collectionHandle); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermEntriesController.php b/src/Http/Controllers/API/TaxonomyTermEntriesController.php index 7b732c9920..2aa261947f 100644 --- a/src/Http/Controllers/API/TaxonomyTermEntriesController.php +++ b/src/Http/Controllers/API/TaxonomyTermEntriesController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Facades\Statamic\API\ResourceAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Collection; @@ -72,4 +73,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'collections', $this->allowedCollections); + } } diff --git a/src/Http/Controllers/API/TaxonomyTermsController.php b/src/Http/Controllers/API/TaxonomyTermsController.php index b97b50f96f..4e71f1ebb7 100644 --- a/src/Http/Controllers/API/TaxonomyTermsController.php +++ b/src/Http/Controllers/API/TaxonomyTermsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Term; use Statamic\Http\Resources\API\TermResource; @@ -43,4 +44,9 @@ protected function allowedFilters() { return FilterAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForSubResources('api', 'taxonomies', $this->taxonomyHandle); + } } diff --git a/src/Http/Controllers/API/UsersController.php b/src/Http/Controllers/API/UsersController.php index de0a0a2f89..fc4ab91376 100644 --- a/src/Http/Controllers/API/UsersController.php +++ b/src/Http/Controllers/API/UsersController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\API; use Facades\Statamic\API\FilterAuthorizer; +use Facades\Statamic\API\QueryScopeAuthorizer; use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\User; use Statamic\Http\Resources\API\UserResource; @@ -42,4 +43,9 @@ protected function allowedFilters() ->reject(fn ($field) => in_array($field, ['password', 'password_hash'])) ->all(); } + + protected function allowedQueryScopes() + { + return QueryScopeAuthorizer::allowedForResource('api', 'users'); + } } diff --git a/tests/API/APITest.php b/tests/API/APITest.php index 4b19592702..68792463d8 100644 --- a/tests/API/APITest.php +++ b/tests/API/APITest.php @@ -10,6 +10,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Token; use Statamic\Facades\User; +use Statamic\Query\Scopes\Scope; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -136,6 +137,25 @@ public function it_filters_out_past_entries_from_past_private_collection() $response->assertJsonPath('data.0.id', 'a'); } + #[Test] + public function it_can_use_a_query_scope_on_collection_entries_when_configuration_allows_for_it() + { + app('statamic.scopes')['test_scope'] = TestScope::class; + + Facades\Config::set('statamic.api.resources.collections.pages', [ + 'allowed_query_scopes' => ['test_scope'], + ]); + + Facades\Collection::make('pages')->save(); + + Facades\Entry::make()->collection('pages')->id('about')->slug('about')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('dance')->slug('dance')->published(true)->save(); + Facades\Entry::make()->collection('pages')->id('nectar')->slug('nectar')->published(true)->save(); + + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=is&query_scope[test_scope][value]=about', 1); + $this->assertEndpointDataCount('/api/collections/pages/entries?query_scope[test_scope][operator]=isnt&query_scope[test_scope][value]=about', 2); + } + #[Test] public function it_can_filter_collection_entries_when_configuration_allows_for_it() { @@ -592,3 +612,11 @@ public function handle(\Statamic\Contracts\Tokens\Token $token, \Illuminate\Http return $next($token); } } + +class TestScope extends Scope +{ + public function apply($query, $values) + { + $query->where('id', $values['operator'] == 'is' ? '=' : '!=', $values['value']); + } +}