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..8fc9882f3a5 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)->middleware('throttle:statamic.dictionaries')->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..acec621da3e 100644 --- a/src/Dictionaries/Locales.php +++ b/src/Dictionaries/Locales.php @@ -3,6 +3,7 @@ namespace Statamic\Dictionaries; use Facades\Statamic\Console\Processes\Process; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class Locales extends BasicDictionary @@ -17,17 +18,19 @@ protected function getItemLabel(array $item): string protected function getItems(): array { - $output = Process::run($this->buildLocalesCommand()); - - return collect(explode(PHP_EOL, $output)) - ->filter(fn ($locale) => mb_check_encoding($locale, 'UTF-8')) - ->map(fn ($locale) => Str::before($locale, '.')) - ->reject(fn ($locale) => in_array($locale, ['C', 'POSIX'])) - ->filter() - ->sort() - ->values() - ->map(fn ($locale) => ['name' => $locale]) - ->all(); + return Cache::rememberForever('statamic.dictionaries.locales', function () { + $output = Process::run($this->buildLocalesCommand()); + + return collect(explode(PHP_EOL, $output)) + ->filter(fn ($locale) => mb_check_encoding($locale, 'UTF-8')) + ->map(fn ($locale) => Str::before($locale, '.')) + ->reject(fn ($locale) => in_array($locale, ['C', 'POSIX'])) + ->filter() + ->sort() + ->values() + ->map(fn ($locale) => ['name' => $locale]) + ->all(); + }); } private function buildLocalesCommand(): array @@ -43,4 +46,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 61% rename from src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php rename to src/Http/Controllers/DictionaryFieldtypeController.php index e4e0a53603f..a54ee4ade84 100644 --- a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -1,17 +1,31 @@ fieldtype($request)->dictionary()->options($request->search); + try { + $dictionary = $this->fieldtype($request)->dictionary(); + } catch (UndefinedDictionaryException|DictionaryNotFoundException $e) { + throw new NotFoundHttpException; + } + + 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, @@ -28,7 +42,11 @@ protected function fieldtype($request) { $config = $this->getConfig($request); - return Fieldtype::find($config['type'])->setField( + if (! is_array($config)) { + throw new NotFoundHttpException; + } + + return (new Dictionary)->setField( new Field('relationship', $config) ); } diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index 961b130ac08..0b14ccd176d 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -203,6 +203,10 @@ public function boot() : Limit::perMinute(10)->by('submission:'.$request->ip()); }); + RateLimiter::for('statamic.dictionaries', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); + }); + RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); diff --git a/tests/Dictionaries/LocalesTest.php b/tests/Dictionaries/LocalesTest.php new file mode 100644 index 00000000000..3facd384a6a --- /dev/null +++ b/tests/Dictionaries/LocalesTest.php @@ -0,0 +1,32 @@ +once()->andReturn(implode(PHP_EOL, ['en_US.UTF-8', 'fr_FR.UTF-8', 'C', 'POSIX'])); + + $expected = ['en_US' => 'en_US', 'fr_FR' => 'fr_FR']; + + $this->assertEquals($expected, (new Locales)->options()); + $this->assertEquals($expected, (new Locales)->options()); + } + + #[Test] + public function cached_items_can_still_be_searched() + { + Process::shouldReceive('run')->once()->andReturn(implode(PHP_EOL, ['en_US.UTF-8', 'fr_FR.UTF-8'])); + + (new Locales)->options(); + + $this->assertEquals(['fr_FR' => 'fr_FR'], (new Locales)->options('fr')); + } +} diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php index efffeb9eda4..45403774513 100644 --- a/tests/Feature/RateLimitingTest.php +++ b/tests/Feature/RateLimitingTest.php @@ -219,6 +219,29 @@ public function passkeys_bucket_is_independent_from_auth_bucket() $this->post('/!/auth/passkeys/auth')->assertNotRateLimited(); } + #[Test] + public function dictionary_fieldtype_endpoint_is_rate_limited() + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'countries'])); + $url = route('statamic.dictionary-fieldtype', 'countries').'?config='.$config; + + collect(range(1, 60))->each(fn () => $this->getJson($url)->assertNotRateLimited()); + $this->getJson($url)->assertRateLimited(); + } + + #[Test] + public function dictionary_fieldtype_rate_limiter_can_be_overridden() + { + RateLimiter::for('statamic.dictionaries', fn ($request) => Limit::perMinute(2)->by($request->ip())); + + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'countries'])); + $url = route('statamic.dictionary-fieldtype', 'countries').'?config='.$config; + + $this->getJson($url)->assertNotRateLimited(); + $this->getJson($url)->assertNotRateLimited(); + $this->getJson($url)->assertRateLimited(); + } + #[Test] public function passkeys_rate_limiter_can_be_overridden() { diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index c0ce886afda..ba8351fdcc4 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,64 @@ 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']]]); + } + + #[Test] + public function a_request_with_a_missing_dictionary_is_rejected() + { + $config = base64_encode(json_encode(['type' => 'dictionary'])); + + $this + ->getJson(route('statamic.dictionary-fieldtype', 'countries').'?config='.$config) + ->assertNotFound(); + } + + #[Test] + public function a_request_with_an_unknown_dictionary_is_rejected() + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'not_a_real_dictionary'])); + + $this + ->getJson(route('statamic.dictionary-fieldtype', 'countries').'?config='.$config) + ->assertNotFound(); + } + + 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 +343,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], []); + } +}