diff --git a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js index 74fdc0364e0..a303c9bbaac 100644 --- a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js @@ -14,7 +14,7 @@ function mountFieldtype({ value, maxItems, selectedOptions, fetchedOptions, shal handle: 'country', value, config: { max_items: maxItems }, - meta: { url: '/cp/fieldtypes/dictionaries/partial_countries', selectedOptions }, + meta: { url: '/!/fieldtypes/dictionaries/partial_countries', selectedOptions }, }, global: { mocks: { diff --git a/routes/cp.php b/routes/cp.php index 2227c479a98..2626fc0f7f2 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -57,7 +57,6 @@ use Statamic\Http\Controllers\CP\Fields\FieldsetController; use Statamic\Http\Controllers\CP\Fields\FieldtypesController; use Statamic\Http\Controllers\CP\Fields\MetaController; -use Statamic\Http\Controllers\CP\Fieldtypes\DictionaryFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\FilesFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\IconFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController; @@ -385,7 +384,6 @@ Route::get('relationship/filters', [RelationshipFieldtypeController::class, 'filters'])->name('relationship.filters'); Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview'); Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); - Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set'); }); diff --git a/routes/web.php b/routes/web.php index a87bccc7eb8..fed982d55fb 100755 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use Statamic\Facades\TwoFactor; use Statamic\Http\Controllers\ActivateAccountController; use Statamic\Http\Controllers\Auth\ElevatedSessionController; +use Statamic\Http\Controllers\DictionaryFieldtypeController; use Statamic\Http\Controllers\ForgotPasswordController; use Statamic\Http\Controllers\FormController; use Statamic\Http\Controllers\FrontendController; @@ -41,6 +42,8 @@ Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]); Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store'); + Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); + Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () { Route::get('logout', [LoginController::class, 'logout'])->name('logout'); diff --git a/src/Dictionaries/Countries.php b/src/Dictionaries/Countries.php index 12c71168797..293c3e081bf 100644 --- a/src/Dictionaries/Countries.php +++ b/src/Dictionaries/Countries.php @@ -343,4 +343,9 @@ protected function getItems(): array ['name' => __('statamic::dictionary-countries.names.ZWE'), 'iso3' => 'ZWE', 'iso2' => 'ZW', 'region' => $this->regions['africa'], 'subregion' => $this->subregions['eastern_africa'], 'emoji' => 'πŸ‡ΏπŸ‡Ό'], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Currencies.php b/src/Dictionaries/Currencies.php index 83fb6ea3734..56b4019685a 100644 --- a/src/Dictionaries/Currencies.php +++ b/src/Dictionaries/Currencies.php @@ -173,4 +173,9 @@ protected function getItems(): array ['code' => 'ZWG', 'name' => __('statamic::dictionary-currencies.ZWG'), 'symbol' => '$', 'decimals' => 2], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Dictionary.php b/src/Dictionaries/Dictionary.php index 269b00d04ff..6ffb40f064c 100644 --- a/src/Dictionaries/Dictionary.php +++ b/src/Dictionaries/Dictionary.php @@ -81,4 +81,9 @@ public function keywords(): array { return $this->keywords; } + + public function allowsPublicAccess(): bool + { + return false; + } } diff --git a/src/Dictionaries/Languages.php b/src/Dictionaries/Languages.php index c9f354ed57f..6fc955bc5cf 100644 --- a/src/Dictionaries/Languages.php +++ b/src/Dictionaries/Languages.php @@ -109,4 +109,9 @@ protected function getItems(): array ['code' => 'gl', 'name' => __('statamic::dictionary-languages.gl')], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Locales.php b/src/Dictionaries/Locales.php index 70a73f2cfc1..ce334335d0d 100644 --- a/src/Dictionaries/Locales.php +++ b/src/Dictionaries/Locales.php @@ -43,4 +43,9 @@ private function buildLocalesCommand(): array return ['locale', '-a']; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Timezones.php b/src/Dictionaries/Timezones.php index d333633b546..13d50fd8faa 100644 --- a/src/Dictionaries/Timezones.php +++ b/src/Dictionaries/Timezones.php @@ -31,4 +31,9 @@ private function getOffset(string $tz): string return stripos($offsetInSecs, '-') === false ? "+{$hoursAndSec}" : "-{$hoursAndSec}"; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index e63edc2abb7..fd1358d015f 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -76,7 +76,7 @@ protected function configFieldItems(): array public function preload(): array { return [ - 'url' => cp_route('dictionary-fieldtype', $this->dictionary()->handle()), + 'url' => route('statamic.dictionary-fieldtype', $this->dictionary()->handle()), 'selectedOptions' => $this->getItemData($this->field->value()), ]; } diff --git a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php similarity index 78% rename from src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php rename to src/Http/Controllers/DictionaryFieldtypeController.php index e4e0a53603f..1a7f8183f2a 100644 --- a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -1,17 +1,24 @@ fieldtype($request)->dictionary()->options($request->search); + $dictionary = $this->fieldtype($request)->dictionary(); + + if (Gate::denies('access cp') && ! $dictionary->allowsPublicAccess()) { + throw new ForbiddenHttpException; + } + + $options = $dictionary->options($request->search); // Return an ordered list of key/value pairs rather than a value-keyed object. // When the values are integers, the browser would re-sort the object's keys ascending, diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index c0ce886afda..a8a07eefc93 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -6,14 +6,19 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Dictionaries\Countries; +use Statamic\Dictionaries\Dictionary; use Statamic\Dictionaries\Item; use Statamic\Exceptions\DictionaryNotFoundException; use Statamic\Exceptions\UndefinedDictionaryException; +use Statamic\Facades\User; use Statamic\Fields\Field; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class DictionaryTest extends TestCase { + use PreventSavingStacheItemsToDisk; + #[Test] #[DataProvider('dictionaryConfigProvider')] public function it_gets_the_dictionary($dictionaryConfig, $expectedConfig) @@ -91,7 +96,7 @@ public function it_returns_preload_data() $preload = $fieldtype->preload(); $this->assertArraySubset([ - 'url' => 'http://localhost/cp/fieldtypes/dictionaries/countries', + 'url' => 'http://localhost/!/fieldtypes/dictionaries/countries', 'selectedOptions' => [ ['value' => 'USA', 'label' => 'πŸ‡ΊπŸ‡Έ United States', 'invalid' => false], ['value' => 'AUS', 'label' => 'πŸ‡¦πŸ‡Ί Australia', 'invalid' => false], @@ -103,6 +108,44 @@ public function it_returns_preload_data() ], $preload); } + #[Test] + public function a_guest_can_request_options_from_a_dictionary_that_allows_public_access() + { + $this + ->getJson($this->optionsUrl('countries')) + ->assertOk() + ->assertJsonStructure(['data' => [['key', 'value']]]); + } + + #[Test] + public function a_guest_cannot_request_options_from_a_dictionary_that_does_not_allow_public_access() + { + RestrictedDictionary::register(); + + $this + ->getJson($this->optionsUrl('restricted')) + ->assertForbidden(); + } + + #[Test] + public function a_cp_user_can_request_options_from_a_dictionary_that_does_not_allow_public_access() + { + RestrictedDictionary::register(); + + $this + ->actingAs(User::make()->makeSuper()) + ->getJson($this->optionsUrl('restricted')) + ->assertOk() + ->assertJsonStructure(['data' => [['key', 'value']]]); + } + + private function optionsUrl(string $dictionary): string + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => $dictionary])); + + return route('statamic.dictionary-fieldtype', $dictionary).'?config='.$config; + } + #[Test] public function it_augments_a_single_option() { @@ -280,3 +323,18 @@ public function it_returns_extra_renderable_field_data() ], $extraRenderableFieldData); } } + +class RestrictedDictionary extends Dictionary +{ + protected static $handle = 'restricted'; + + public function options(?string $search = null): array + { + return ['foo' => 'Foo', 'bar' => 'Bar']; + } + + public function get(string $key): ?Item + { + return new Item($key, $this->options()[$key], []); + } +}