Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 0 additions & 2 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand Down
5 changes: 5 additions & 0 deletions src/Dictionaries/Countries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
5 changes: 5 additions & 0 deletions src/Dictionaries/Currencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,9 @@ protected function getItems(): array
['code' => 'ZWG', 'name' => __('statamic::dictionary-currencies.ZWG'), 'symbol' => '$', 'decimals' => 2],
];
}

public function allowsPublicAccess(): bool
{
return true;
}
}
5 changes: 5 additions & 0 deletions src/Dictionaries/Dictionary.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,9 @@ public function keywords(): array
{
return $this->keywords;
}

public function allowsPublicAccess(): bool
{
return false;
}
}
5 changes: 5 additions & 0 deletions src/Dictionaries/Languages.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,9 @@ protected function getItems(): array
['code' => 'gl', 'name' => __('statamic::dictionary-languages.gl')],
];
}

public function allowsPublicAccess(): bool
{
return true;
}
}
30 changes: 19 additions & 11 deletions src/Dictionaries/Locales.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -43,4 +46,9 @@ private function buildLocalesCommand(): array

return ['locale', '-a'];
}

public function allowsPublicAccess(): bool
{
return true;
}
}
5 changes: 5 additions & 0 deletions src/Dictionaries/Timezones.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ private function getOffset(string $tz): string

return stripos($offsetInSecs, '-') === false ? "+{$hoursAndSec}" : "-{$hoursAndSec}";
}

public function allowsPublicAccess(): bool
{
return true;
}
}
2 changes: 1 addition & 1 deletion src/Fieldtypes/Dictionary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
<?php

namespace Statamic\Http\Controllers\CP\Fieldtypes;
namespace Statamic\Http\Controllers;

use Facades\Statamic\Fields\FieldtypeRepository as Fieldtype;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Statamic\Exceptions\DictionaryNotFoundException;
use Statamic\Exceptions\ForbiddenHttpException;
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Exceptions\UndefinedDictionaryException;
use Statamic\Fields\Field;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Fieldtypes\Dictionary;

class DictionaryFieldtypeController extends CpController
class DictionaryFieldtypeController extends Controller
{
public function __invoke(Request $request, string $dictionary)
{
$options = $this->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,
Expand All @@ -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)
);
}
Expand Down
4 changes: 4 additions & 0 deletions src/Providers/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
Expand Down
32 changes: 32 additions & 0 deletions tests/Dictionaries/LocalesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Tests\Dictionaries;

use Facades\Statamic\Console\Processes\Process;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Dictionaries\Locales;
use Tests\TestCase;

class LocalesTest extends TestCase
{
#[Test]
public function it_only_runs_the_locale_process_once_and_caches_the_result()
{
Process::shouldReceive('run')->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'));
}
}
23 changes: 23 additions & 0 deletions tests/Feature/RateLimitingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
80 changes: 79 additions & 1 deletion tests/Fieldtypes/DictionaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Expand All @@ -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()
{
Expand Down Expand Up @@ -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], []);
}
}
Loading