name('taxonomies.terms.published.store');
Route::delete('/', [PublishedTermsController::class, 'destroy'])->name('taxonomies.terms.published.destroy');
- Route::resource('revisions', TermRevisionsController::class, [
- 'as' => 'taxonomies.terms',
- 'only' => ['index', 'store', 'show'],
- ]);
-
- Route::post('restore-revision', RestoreTermRevisionController::class)->name('taxonomies.terms.restore-revision');
Route::post('preview', [TermPreviewController::class, 'edit'])->name('taxonomies.terms.preview.edit');
Route::get('preview', [TermPreviewController::class, 'show'])->name('taxonomies.terms.preview.popout');
Route::patch('/', [TermsController::class, 'update'])->name('taxonomies.terms.update');
diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php
index de31d71fed5..2bf3fd584a9 100644
--- a/src/Assets/Asset.php
+++ b/src/Assets/Asset.php
@@ -8,6 +8,7 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use League\Flysystem\PathTraversalDetected;
+use Rhukster\DomSanitizer\DOMSanitizer;
use Statamic\Assets\AssetUploader as Uploader;
use Statamic\Contracts\Assets\Asset as AssetContract;
use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
@@ -945,6 +946,17 @@ public function reupload(ReplacementFile $file)
$file->writeTo($this->disk()->filesystem(), $this->path());
+ if ($this->isSvg() && config('statamic.assets.svg_sanitization_on_upload', true)) {
+ $contents = $this->disk()->get($this->path());
+
+ $this->disk()->put(
+ $this->path(),
+ (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($contents, [
+ 'remove-xml-tags' => ! Str::startsWith($contents, 'clearCaches();
$this->writeMeta($this->generateMeta());
@@ -1104,7 +1116,7 @@ public function hasDuration()
public function getQueryableValue(string $field)
{
- if (method_exists($this, $method = Str::camel($field))) {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
return $this->{$method}();
}
@@ -1117,6 +1129,17 @@ public function getQueryableValue(string $field)
return $field->fieldtype()->toQueryableValue($value);
}
+ private function queryableMethods(): array
+ {
+ return [
+ 'absoluteUrl', 'apiUrl', 'basename', 'blueprint', 'containerId', 'containerHandle', 'dimensions',
+ 'duration', 'editUrl', 'exists', 'extension', 'filename', 'folder', 'guessedExtension',
+ 'hasDimensions', 'hasDuration', 'height', 'id', 'isAudio', 'isImage', 'isMedia', 'isPdf',
+ 'isPreviewable', 'isSvg', 'isVideo', 'lastModified', 'mimeType', 'orientation', 'path', 'pdfUrl',
+ 'ratio', 'reference', 'size', 'thumbnailUrl', 'title', 'url', 'width',
+ ];
+ }
+
public function getCurrentDirtyStateAttributes(): array
{
return array_merge([
diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php
index e73fc1d3377..2a555a0af12 100644
--- a/src/Assets/AssetContainer.php
+++ b/src/Assets/AssetContainer.php
@@ -7,6 +7,7 @@
use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
use Statamic\Contracts\Data\Augmentable;
use Statamic\Contracts\Data\Augmented;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasAugmentedInstance;
use Statamic\Events\AssetContainerBlueprintFound;
@@ -27,9 +28,10 @@
use Statamic\Facades\Stache;
use Statamic\Facades\URL;
use Statamic\Support\Arr;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class AssetContainer implements Arrayable, ArrayAccess, AssetContainerContract, Augmentable
+class AssetContainer implements Arrayable, ArrayAccess, AssetContainerContract, Augmentable, ContainsQueryableValues
{
use ExistsAsFile, FluentlyGetsAndSets, HasAugmentedInstance;
@@ -693,6 +695,24 @@ public static function __callStatic($method, $parameters)
return Facades\AssetContainer::{$method}(...$parameters);
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'absoluteUrl', 'accessible', 'allowDownloading', 'allowMoving', 'allowRenaming', 'allowUploads',
+ 'blueprint', 'createFolders', 'diskHandle', 'diskPath', 'editUrl', 'handle', 'hasSearchIndex',
+ 'id', 'path', 'private', 'searchIndex', 'showUrl', 'sortDirection', 'sortField', 'title', 'url',
+ ];
+ }
+
public function __toString()
{
return $this->handle();
diff --git a/src/Assets/AssetFolder.php b/src/Assets/AssetFolder.php
index 794c9f72085..e190d9f1b13 100644
--- a/src/Assets/AssetFolder.php
+++ b/src/Assets/AssetFolder.php
@@ -6,6 +6,7 @@
use League\Flysystem\PathTraversalDetected;
use Statamic\Assets\AssetUploader as Uploader;
use Statamic\Contracts\Assets\AssetFolder as Contract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Events\AssetFolderDeleted;
use Statamic\Events\AssetFolderSaved;
use Statamic\Facades\AssetContainer;
@@ -13,7 +14,7 @@
use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class AssetFolder implements Arrayable, Contract
+class AssetFolder implements Arrayable, ContainsQueryableValues, Contract
{
use FluentlyGetsAndSets;
@@ -237,4 +238,20 @@ public function toArray()
'basename' => (string) $this->basename(),
];
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'basename', 'count', 'lastModified', 'path', 'resolvedPath', 'size', 'title',
+ ];
+ }
}
diff --git a/src/Auth/Protect/Protectors/Password/PasswordProtector.php b/src/Auth/Protect/Protectors/Password/PasswordProtector.php
index e137cf4db32..9c9ec87ea84 100644
--- a/src/Auth/Protect/Protectors/Password/PasswordProtector.php
+++ b/src/Auth/Protect/Protectors/Password/PasswordProtector.php
@@ -20,7 +20,7 @@ public function protect()
throw new ForbiddenHttpException();
}
- if (request()->isLivePreview()) {
+ if (request()->isLivePreviewOf($this->data)) {
return;
}
diff --git a/src/Auth/SendsPasswordResetEmails.php b/src/Auth/SendsPasswordResetEmails.php
index e9d3b1d604f..cf8e956cf2a 100644
--- a/src/Auth/SendsPasswordResetEmails.php
+++ b/src/Auth/SendsPasswordResetEmails.php
@@ -37,16 +37,12 @@ public function sendResetLinkEmail(Request $request)
{
$this->validateEmail($request);
- // We will send the password reset link to this user. Once we have attempted
- // to send the link, we will examine the response then see the message we
- // need to show to the user. Finally, we'll send out a proper response.
- $response = $this->broker()->sendResetLink(
- $this->credentials($request)
- );
+ // Always return the generic "reset link sent" response regardless of the broker's
+ // actual result. INVALID_USER and RESET_THROTTLED would each reveal whether the
+ // email belongs to a registered account (the broker only throttles real users).
+ $this->broker()->sendResetLink($this->credentials($request));
- return $response == Password::RESET_LINK_SENT
- ? $this->sendResetLinkResponse($request, $response)
- : $this->sendResetLinkFailedResponse($request, $response);
+ return $this->sendResetLinkResponse($request, Password::RESET_LINK_SENT);
}
/**
@@ -97,31 +93,6 @@ protected function sendResetLinkResponse(Request $request, $response)
: $redirect->with('status', trans($response));
}
- /**
- * Get the response for a failed password reset link.
- *
- * @param string $response
- * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
- */
- protected function sendResetLinkFailedResponse(Request $request, $response)
- {
- $errorRedirect = $request->input('_error_redirect');
-
- $redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect)
- ? redirect($errorRedirect)
- : back();
-
- if ($request->wantsJson()) {
- throw ValidationException::withMessages([
- 'email' => [trans($response)],
- ]);
- }
-
- return $redirect
- ->withInput($request->only('email'))
- ->withErrors(['email' => trans($response)], 'user.forgot_password');
- }
-
/**
* Get the broker to be used during password reset.
*
diff --git a/src/Auth/User.php b/src/Auth/User.php
index 86d935d6cff..e3cd7dfda80 100644
--- a/src/Auth/User.php
+++ b/src/Auth/User.php
@@ -365,7 +365,7 @@ protected function getComputedCallbacks()
public function getQueryableValue(string $field)
{
- if (method_exists($this, $method = Str::camel($field))) {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
return $this->{$method}();
}
@@ -377,4 +377,13 @@ public function getQueryableValue(string $field)
return $field->fieldtype()->toQueryableValue($value);
}
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'apiUrl', 'avatar', 'blueprint', 'editUrl', 'email', 'gravatarUrl', 'groups', 'hasAvatarField',
+ 'id', 'initials', 'isSuper', 'isTaxonomizable', 'lastLogin', 'name', 'path', 'preferredLocale',
+ 'preferredTheme', 'reference', 'roles', 'title',
+ ];
+ }
}
diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php
index 61f34cd36b0..52a0b21740a 100644
--- a/src/Auth/UserTags.php
+++ b/src/Auth/UserTags.php
@@ -352,7 +352,7 @@ public function forgotPasswordForm()
$params['error_redirect'] = $this->parseRedirect($errorRedirect);
}
- if ($resetUrl = $this->params->get('reset_url')) {
+ if ($resetUrl = $this->getPasswordResetUrl($this->params->get('reset_url'))) {
$params['reset_url'] = $resetUrl;
}
@@ -374,6 +374,19 @@ public function forgotPasswordForm()
return $html;
}
+ private function getPasswordResetUrl(?string $url = null): ?string
+ {
+ if (! $url) {
+ return null;
+ }
+
+ if (! preg_match('#^https?://#', $url) && ! str_starts_with($url, '/')) {
+ $url = '/'.$url;
+ }
+
+ return encrypt($url);
+ }
+
/**
* Output a reset password form.
*
@@ -431,7 +444,7 @@ public function resetPasswordForm()
$html .= '
';
if ($redirect) {
- $html .= '
';
+ $html .= '
';
}
$html .= $this->parse($data);
diff --git a/src/Dictionaries/File.php b/src/Dictionaries/File.php
index 62765f74b95..942e46db479 100644
--- a/src/Dictionaries/File.php
+++ b/src/Dictionaries/File.php
@@ -2,6 +2,7 @@
namespace Statamic\Dictionaries;
+use League\Flysystem\PathTraversalDetected;
use Statamic\Facades\Antlers;
use Statamic\Facades\YAML;
@@ -55,7 +56,13 @@ protected function getItemLabel(array $item): string
protected function getItems(): array
{
- $path = resource_path('dictionaries').'/'.$this->config['filename'];
+ $filename = $this->config['filename'];
+
+ if (str_contains($filename, '..')) {
+ throw PathTraversalDetected::forPath($filename);
+ }
+
+ $path = resource_path('dictionaries/'.$filename);
if (! file_exists($path)) {
throw new \Exception('Dictionary file ['.$path.'] does not exist.');
diff --git a/src/Entries/Collection.php b/src/Entries/Collection.php
index 179389248e2..b66dd9b9c68 100644
--- a/src/Entries/Collection.php
+++ b/src/Entries/Collection.php
@@ -7,6 +7,7 @@
use InvalidArgumentException;
use Statamic\Contracts\Data\Augmentable as AugmentableContract;
use Statamic\Contracts\Entries\Collection as Contract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ContainsCascadingData;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasAugmentedData;
@@ -35,7 +36,7 @@
use function Statamic\trans as __;
-class Collection implements Arrayable, ArrayAccess, AugmentableContract, Contract
+class Collection implements Arrayable, ArrayAccess, AugmentableContract, ContainsQueryableValues, Contract
{
use ContainsCascadingData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData, HasDirtyState;
@@ -948,6 +949,24 @@ public function augmentedArrayData()
];
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'dated', 'defaultPublishState', 'editUrl', 'handle', 'hasStructure', 'id', 'layout', 'mount',
+ 'orderable', 'path', 'requiresSlugs', 'revisionsEnabled', 'sites', 'sortDirection', 'sortField',
+ 'structureHandle', 'taxonomies', 'template', 'title', 'url', 'uri',
+ ];
+ }
+
public function getCurrentDirtyStateAttributes(): array
{
return $this->fileData();
diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php
index ae850a8f2dc..c84be36a875 100644
--- a/src/Entries/Entry.php
+++ b/src/Entries/Entry.php
@@ -1099,7 +1099,7 @@ public function getQueryableValue(string $field)
Blink::store('entry-uris')->forget($this->id());
}
- if (method_exists($this, $method = Str::camel($field))) {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
return $this->{$method}();
}
@@ -1112,6 +1112,16 @@ public function getQueryableValue(string $field)
return $field->fieldtype()->toQueryableValue($value);
}
+ private function queryableMethods(): array
+ {
+ return [
+ 'apiUrl', 'blueprint', 'collection', 'collectionHandle', 'date', 'editUrl', 'hasDate', 'hasExplicitDate', 'hasOrigin',
+ 'hasSeconds', 'hasStructure', 'hasTime', 'id', 'isRedirect', 'isRoot', 'lastModified', 'lastModifiedBy',
+ 'layout', 'locale', 'order', 'path', 'private', 'published', 'redirectUrl', 'reference', 'site', 'sites', 'slug',
+ 'status', 'template', 'uri', 'url', 'urlWithoutRedirect',
+ ];
+ }
+
public function getSearchValue(string $field)
{
return method_exists($this, $field) ? $this->$field() : $this->value($field);
diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php
index 34d0c7c3edb..a3958b3a169 100644
--- a/src/Facades/Endpoint/URL.php
+++ b/src/Facades/Endpoint/URL.php
@@ -247,25 +247,53 @@ public function isExternal($url)
/**
* Check whether a URL is external to whole Statamic application.
+ * Ambiguous URLs are considered external.
*/
public function isExternalToApplication(?string $url): bool
{
- if (Str::startsWith($url, '//')) {
+ if ($url === null || $url === '') {
+ return false;
+ }
+
+ $urlLower = strtolower($url);
+
+ if (Str::startsWith($urlLower, '//')) {
return true;
}
- $urlDomain = parse_url($url, PHP_URL_HOST);
- $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST);
-
- return $urlDomain
- ? Site::all()
- ->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST))
- ->push($currentRequestDomain)
- ->filter(fn ($siteDomain) => ! is_null($siteDomain))
- ->unique()
- ->filter(fn ($siteDomain) => $siteDomain === $urlDomain)
- ->isEmpty()
- : false;
+ if (Str::startsWith($urlLower, ['/', '#', '?'])) {
+ return false;
+ }
+
+ if (! Str::startsWith($urlLower, ['http://', 'https://'])) {
+ return true;
+ }
+
+ // Normalize backslashes to forward slashes.
+ // Browsers treat \ as / for special schemes (http/https), which can
+ // cause parse_url() to extract a different host than the browser uses.
+ $url = str_replace('\\', '/', $url);
+ $url = preg_replace('/%5c/i', '/', $url);
+
+ // If we can't extract a host from an absolute http(s) URL, treat it as external.
+ // Non-http(s) and relative URLs are handled by the guards above.
+ if (! $urlDomain = parse_url($url, PHP_URL_HOST)) {
+ return true;
+ }
+
+ $sites = Site::all();
+ $siteDomains = $sites->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST));
+
+ if ($sites->contains(fn ($site) => Str::startsWith((string) $site->url(), '/'))) {
+ $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST);
+ $siteDomains->push($currentRequestDomain);
+ }
+
+ return $siteDomains
+ ->filter(fn ($siteDomain) => ! is_null($siteDomain))
+ ->unique()
+ ->filter(fn ($siteDomain) => $siteDomain === $urlDomain)
+ ->isEmpty();
}
public function clearExternalUrlCache()
diff --git a/src/Fields/Blueprint.php b/src/Fields/Blueprint.php
index c1e3b14c217..d72863dddcb 100644
--- a/src/Fields/Blueprint.php
+++ b/src/Fields/Blueprint.php
@@ -8,6 +8,7 @@
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use Statamic\Contracts\Data\Augmentable;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Query\QueryableValue;
use Statamic\CP\Column;
use Statamic\CP\Columns;
@@ -30,7 +31,7 @@
use function Statamic\trans as __;
-class Blueprint implements Arrayable, ArrayAccess, Augmentable, QueryableValue
+class Blueprint implements Arrayable, ArrayAccess, Augmentable, ContainsQueryableValues, QueryableValue
{
use ExistsAsFile, HasAugmentedData;
@@ -645,6 +646,11 @@ protected function ensureFieldInTabHasConfig($handle, $tab, $config)
return $this;
}
+ // If field is deferred as an ensured field, we'll need to update it instead
+ if (! isset($fields[$handle]) && isset($this->ensuredFields[$handle])) {
+ return $this->ensureEnsuredFieldHasConfig($handle, $config);
+ }
+
$fieldKey = $fields[$handle]['fieldIndex'];
$sectionKey = $fields[$handle]['sectionIndex'];
@@ -664,6 +670,19 @@ protected function ensureFieldInTabHasConfig($handle, $tab, $config)
return $this->resetBlueprintCache()->resetFieldsCache();
}
+ private function ensureEnsuredFieldHasConfig($handle, $config)
+ {
+ if (! isset($this->ensuredFields[$handle])) {
+ return $this;
+ }
+
+ $existingConfig = Arr::get($this->ensuredFields[$handle], 'config', []);
+
+ $this->ensuredFields[$handle]['config'] = array_merge($existingConfig, $config);
+
+ return $this->resetBlueprintCache()->resetFieldsCache();
+ }
+
public function validateUniqueHandles()
{
$fields = $this->fieldsCache ?? new Fields($this->tabs()->map->fields()->flatMap->items());
@@ -774,4 +793,21 @@ public function writeFile($path = null)
{
File::put($path ?? $this->buildPath(), $this->fileContents());
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'columns', 'fields', 'handle', 'hidden', 'isEmpty', 'isDeletable', 'isResettable',
+ 'namespace', 'order', 'path', 'tabs', 'title',
+ ];
+ }
}
diff --git a/src/Fields/Fieldset.php b/src/Fields/Fieldset.php
index 44e43ddd9e7..7dd20fcb88f 100644
--- a/src/Fields/Fieldset.php
+++ b/src/Fields/Fieldset.php
@@ -2,6 +2,7 @@
namespace Statamic\Fields;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Events\FieldsetCreated;
use Statamic\Events\FieldsetCreating;
use Statamic\Events\FieldsetDeleted;
@@ -21,7 +22,7 @@
use Statamic\Support\Arr;
use Statamic\Support\Str;
-class Fieldset
+class Fieldset implements ContainsQueryableValues
{
protected $handle;
protected $contents = [];
@@ -296,6 +297,23 @@ public function reset()
return true;
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'editUrl', 'fields', 'handle', 'isDeletable', 'isResettable',
+ 'namespace', 'path', 'title',
+ ];
+ }
+
public static function __callStatic($method, $parameters)
{
return Facades\Fieldset::{$method}(...$parameters);
diff --git a/src/Fields/Value.php b/src/Fields/Value.php
index 8e17639a736..5ac457691ef 100644
--- a/src/Fields/Value.php
+++ b/src/Fields/Value.php
@@ -12,6 +12,7 @@
use Statamic\Facades\Compare;
use Statamic\Support\Str;
use Statamic\View\Antlers\Language\Parser\DocumentTransformer;
+use Statamic\View\Cascade;
use Traversable;
class Value implements ArrayAccess, IteratorAggregate, JsonSerializable
@@ -159,6 +160,10 @@ public function antlersValue(Parser $parser, $variables)
$value = (new DocumentTransformer())->correct($value);
+ if (isset($variables['config'])) {
+ $variables['config'] = Cascade::config();
+ }
+
$parsed = $parser->parse($value, $variables);
if (! $parseFromRawString) {
diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php
index 7631364c301..763392ac6b7 100644
--- a/src/Fieldtypes/Entries.php
+++ b/src/Fieldtypes/Entries.php
@@ -7,6 +7,7 @@
use Statamic\Contracts\Entries\Entry as EntryContract;
use Statamic\CP\Column;
use Statamic\CP\Columns;
+use Statamic\Exceptions\AuthorizationException;
use Statamic\Exceptions\CollectionNotFoundException;
use Statamic\Facades\Blink;
use Statamic\Facades\Collection;
@@ -18,6 +19,7 @@
use Statamic\Facades\User;
use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntries;
use Statamic\Http\Resources\CP\Entries\EntriesFieldtypeEntry as EntryResource;
+use Statamic\Query\OrderBy;
use Statamic\Query\OrderedQueryBuilder;
use Statamic\Query\Scopes\Filter;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;
@@ -132,12 +134,24 @@ protected function configFieldItems(): array
public function getIndexItems($request)
{
+ $configuredCollections = $this->getConfiguredCollections();
+ $this->authorizeCollectionAccess($configuredCollections);
+
$query = $this->getIndexQuery($request);
$filters = $request->filters;
if (! isset($filters['collection'])) {
- $query->whereIn('collection', $this->getConfiguredCollections());
+ $user = User::current();
+
+ $query->whereIn(
+ 'collection',
+ collect($configuredCollections)
+ ->map(fn (string $collectionHandle) => Collection::findByHandle($collectionHandle))
+ ->filter(fn ($collection) => $collection && $user->can('view', $collection))
+ ->map->handle()
+ ->all()
+ );
}
if ($blueprints = $this->config('blueprints')) {
@@ -157,6 +171,17 @@ public function getIndexItems($request)
return $paginate ? $results->setCollection($items) : $items;
}
+ private function authorizeCollectionAccess(array $collections): void
+ {
+ $user = User::current();
+
+ $authorizedCollections = collect($collections)
+ ->map(fn (string $collectionHandle) => Collection::findByHandle($collectionHandle))
+ ->filter(fn ($collection) => $collection && $user->can('view', $collection));
+
+ throw_if($authorizedCollections->isEmpty(), new AuthorizationException);
+ }
+
public function getResourceCollection($request, $items)
{
return (new EntriesFieldtypeEntries($items, $this))
@@ -191,7 +216,7 @@ protected function getFirstCollectionFromRequest($request)
public function getSortColumn($request)
{
- $column = $request->sort ?? 'title';
+ $column = OrderBy::column($request->sort, 'title');
if (! $request->sort && ! $request->search && count($this->getConfiguredCollections()) < 2) {
$column = $this->getFirstCollectionFromRequest($request)->sortField();
diff --git a/src/Fieldtypes/Relationship.php b/src/Fieldtypes/Relationship.php
index 48077226504..08666ed97e0 100644
--- a/src/Fieldtypes/Relationship.php
+++ b/src/Fieldtypes/Relationship.php
@@ -8,6 +8,7 @@
use Statamic\CP\Column;
use Statamic\Facades\Scope;
use Statamic\Fields\Fieldtype;
+use Statamic\Query\OrderBy;
abstract class Relationship extends Fieldtype
{
@@ -307,7 +308,7 @@ public function filterExcludedItems($items, $exclusions)
public function getSortColumn($request)
{
- return $request->get('sort');
+ return OrderBy::column($request->get('sort'));
}
public function getSortDirection($request)
diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php
index ce404f113b3..4d20960f760 100644
--- a/src/Fieldtypes/Terms.php
+++ b/src/Fieldtypes/Terms.php
@@ -7,6 +7,7 @@
use Statamic\Contracts\Entries\Entry;
use Statamic\Contracts\Taxonomies\Term as TermContract;
use Statamic\CP\Column;
+use Statamic\Exceptions\AuthorizationException;
use Statamic\Exceptions\TaxonomyNotFoundException;
use Statamic\Exceptions\TermsFieldtypeBothOptionsUsedException;
use Statamic\Exceptions\TermsFieldtypeTaxonomyOptionUsed;
@@ -20,6 +21,7 @@
use Statamic\Facades\User;
use Statamic\GraphQL\Types\TermInterface;
use Statamic\Http\Resources\CP\Taxonomies\TermsFieldtypeTerms as TermsResource;
+use Statamic\Query\OrderBy;
use Statamic\Query\OrderedQueryBuilder;
use Statamic\Query\Scopes\Filter;
use Statamic\Query\Scopes\Filters\Fields\Terms as TermsFilter;
@@ -219,8 +221,13 @@ public function process($data)
$id = $this->createTermFromString($id, $taxonomy);
}
+ if (! $id) {
+ return null;
+ }
+
return explode('::', $id, 2)[1];
})
+ ->filter()
->unique()
->values()
->all();
@@ -257,6 +264,8 @@ public function getIndexItems($request)
return collect();
}
+ $this->authorizeTaxonomyAccess($this->getConfiguredTaxonomies());
+
$query = $this->getIndexQuery($request);
if ($sort = $this->getSortColumn($request)) {
@@ -266,6 +275,18 @@ public function getIndexItems($request)
return $request->boolean('paginate', true) ? $query->paginate() : $query->get();
}
+ private function authorizeTaxonomyAccess(array $taxonomies): void
+ {
+ $user = User::current();
+
+ $authorizedTaxonomies = collect($taxonomies)
+ ->map(fn (string $taxonomyHandle) => Taxonomy::findByHandle($taxonomyHandle))
+ ->filter()
+ ->filter(fn ($taxonomy) => $user->can('view', $taxonomy));
+
+ throw_if($authorizedTaxonomies->isEmpty(), new AuthorizationException);
+ }
+
public function getResourceCollection($request, $items)
{
return (new TermsResource($items, $this))
@@ -280,14 +301,18 @@ protected function getBlueprint($request)
protected function getFirstTaxonomyFromRequest($request)
{
- return $request->taxonomies
- ? Facades\Taxonomy::findByHandle($request->taxonomies[0])
- : Facades\Taxonomy::all()->first();
+ $taxonomies = $this->getConfiguredTaxonomies();
+
+ $taxonomy = Taxonomy::findByHandle($taxonomyHandle = Arr::first($taxonomies));
+
+ throw_if(! $taxonomy, new TaxonomyNotFoundException($taxonomyHandle));
+
+ return $taxonomy;
}
public function getSortColumn($request)
{
- $column = $request->get('sort');
+ $column = OrderBy::column($request->get('sort'));
if (! $column && ! $request->search) {
$column = 'title'; // todo: get from taxonomy or config
@@ -403,10 +428,15 @@ protected function getColumns()
protected function getIndexQuery($request)
{
$query = Term::query();
+ $user = User::current();
- if ($taxonomies = $request->taxonomies) {
- $query->whereIn('taxonomy', $taxonomies);
- }
+ $taxonomies = collect($this->getConfiguredTaxonomies())
+ ->map(fn (string $taxonomyHandle) => Taxonomy::findByHandle($taxonomyHandle))
+ ->filter(fn ($taxonomy) => $taxonomy && $user->can('view', $taxonomy))
+ ->map->handle()
+ ->all();
+
+ $query->whereIn('taxonomy', $taxonomies);
if ($search = $request->search) {
$query->where('title', 'like', '%'.$search.'%');
@@ -459,9 +489,15 @@ protected function createTermFromString($string, $taxonomy)
$slug = Str::slug($string, '-', $lang);
if (! $term = Facades\Term::find("{$taxonomy}::{$slug}")) {
+ $taxonomy = Facades\Taxonomy::findByHandle($taxonomy);
+
+ if (User::current()->cant('create', [TermContract::class, $taxonomy])) {
+ return null;
+ }
+
$term = Facades\Term::make()
->slug($slug)
- ->taxonomy(Facades\Taxonomy::findByHandle($taxonomy))
+ ->taxonomy($taxonomy)
->set('title', $string);
$term->save();
diff --git a/src/Forms/Form.php b/src/Forms/Form.php
index d093537ead2..6257703dbfe 100644
--- a/src/Forms/Form.php
+++ b/src/Forms/Form.php
@@ -8,6 +8,7 @@
use Statamic\Contracts\Forms\Form as FormContract;
use Statamic\Contracts\Forms\Submission;
use Statamic\Contracts\Forms\SubmissionQueryBuilder;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ContainsData;
use Statamic\Data\HasAugmentedInstance;
use Statamic\Events\FormBlueprintFound;
@@ -26,9 +27,10 @@
use Statamic\Forms\Exporters\Exporter;
use Statamic\Statamic;
use Statamic\Support\Arr;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class Form implements Arrayable, Augmentable, FormContract
+class Form implements Arrayable, Augmentable, ContainsQueryableValues, FormContract
{
use ContainsData, FluentlyGetsAndSets, HasAugmentedInstance;
@@ -444,4 +446,27 @@ public function exporter(string $handle): ?Exporter
{
return $this->exporters()->get($handle);
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ $value = $this->get($field);
+
+ if (! $field = $this->blueprint()->field($field)) {
+ return $value;
+ }
+
+ return $field->fieldtype()->toQueryableValue($value);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'actionUrl', 'apiUrl', 'blueprint', 'dateFormat', 'editUrl', 'fields',
+ 'handle', 'honeypot', 'path', 'submissions', 'title',
+ ];
+ }
}
diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php
index b623de4b9cb..80c7e5c6028 100644
--- a/src/Forms/Submission.php
+++ b/src/Forms/Submission.php
@@ -5,6 +5,7 @@
use Carbon\Carbon;
use Statamic\Contracts\Data\Augmentable;
use Statamic\Contracts\Forms\Submission as SubmissionContract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ContainsData;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasAugmentedData;
@@ -21,9 +22,10 @@
use Statamic\Facades\Stache;
use Statamic\Forms\Uploaders\AssetsUploader;
use Statamic\Forms\Uploaders\FilesUploader;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class Submission implements Augmentable, SubmissionContract
+class Submission implements Augmentable, ContainsQueryableValues, SubmissionContract
{
use ContainsData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData, TracksQueriedColumns, TracksQueriedRelations;
@@ -83,7 +85,7 @@ public function form($form = null)
/**
* Get the form fields.
*
- * @return array
+ * @return \Illuminate\Support\Collection
*/
public function fields()
{
@@ -91,9 +93,9 @@ public function fields()
}
/**
- * Get or set the columns.
+ * Get the columns.
*
- * @return array
+ * @return \Statamic\CP\Columns
*/
public function columns()
{
@@ -274,6 +276,28 @@ public function fileData()
return $this->data()->all();
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ $value = $this->get($field);
+
+ if (! $field = $this->blueprint()->field($field)) {
+ return $value;
+ }
+
+ return $field->fieldtype()->toQueryableValue($value);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'blueprint', 'date', 'form', 'formattedDate', 'id', 'path',
+ ];
+ }
+
public function __get($key)
{
return $this->get($key);
diff --git a/src/Globals/GlobalSet.php b/src/Globals/GlobalSet.php
index d248e1b55f9..ba8050226a5 100644
--- a/src/Globals/GlobalSet.php
+++ b/src/Globals/GlobalSet.php
@@ -4,6 +4,7 @@
use Statamic\Contracts\Globals\GlobalSet as Contract;
use Statamic\Contracts\Globals\Variables;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ExistsAsFile;
use Statamic\Events\GlobalSetCreated;
use Statamic\Events\GlobalSetCreating;
@@ -18,9 +19,10 @@
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Support\Arr;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class GlobalSet implements Contract
+class GlobalSet implements ContainsQueryableValues, Contract
{
use ExistsAsFile, FluentlyGetsAndSets;
@@ -244,6 +246,22 @@ public function deleteUrl()
return cp_route('globals.destroy', $this->handle());
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'blueprint', 'editUrl', 'handle', 'id', 'localizations', 'path', 'sites', 'title',
+ ];
+ }
+
public static function __callStatic($method, $parameters)
{
return Facades\GlobalSet::{$method}(...$parameters);
diff --git a/src/Globals/Variables.php b/src/Globals/Variables.php
index 8d7992f4467..afb5ce94066 100644
--- a/src/Globals/Variables.php
+++ b/src/Globals/Variables.php
@@ -10,6 +10,7 @@
use Statamic\Contracts\Globals\GlobalSet;
use Statamic\Contracts\Globals\Variables as Contract;
use Statamic\Contracts\GraphQL\ResolvesValues as ResolvesValuesContract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Data\ContainsData;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasAugmentedInstance;
@@ -27,9 +28,10 @@
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\GraphQL\ResolvesValues;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class Variables implements Arrayable, ArrayAccess, Augmentable, Contract, Localization, ResolvesValuesContract
+class Variables implements Arrayable, ArrayAccess, Augmentable, ContainsQueryableValues, Contract, Localization, ResolvesValuesContract
{
use ContainsData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedInstance, HasOrigin, ResolvesValues, TracksQueriedRelations;
@@ -281,4 +283,26 @@ public function fresh()
{
return Facades\GlobalSet::find($this->handle())->in($this->locale);
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ $value = $this->value($field);
+
+ if (! $field = $this->blueprint()->field($field)) {
+ return $value;
+ }
+
+ return $field->fieldtype()->toQueryableValue($value);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'blueprint', 'editUrl', 'handle', 'id', 'locale', 'path', 'reference', 'site', 'sites', 'title',
+ ];
+ }
}
diff --git a/src/GraphQL/Queries/AssetsQuery.php b/src/GraphQL/Queries/AssetsQuery.php
index ff393d2bdd1..9b616ea3f37 100644
--- a/src/GraphQL/Queries/AssetsQuery.php
+++ b/src/GraphQL/Queries/AssetsQuery.php
@@ -13,6 +13,7 @@
use Statamic\GraphQL\Queries\Concerns\FiltersQuery;
use Statamic\GraphQL\Types\AssetInterface;
use Statamic\GraphQL\Types\JsonArgument;
+use Statamic\Query\OrderBy;
use Statamic\Support\Str;
class AssetsQuery extends Query
@@ -69,7 +70,9 @@ private function sortQuery($query, $sorts)
[$sort, $order] = explode(' ', $sort);
}
- $query->orderBy($sort, $order);
+ if ($sort = OrderBy::column($sort)) {
+ $query->orderBy($sort, $order);
+ }
}
}
diff --git a/src/GraphQL/Queries/EntriesQuery.php b/src/GraphQL/Queries/EntriesQuery.php
index 434ec3cf22f..b4c6b8e70ba 100644
--- a/src/GraphQL/Queries/EntriesQuery.php
+++ b/src/GraphQL/Queries/EntriesQuery.php
@@ -16,6 +16,7 @@
use Statamic\GraphQL\Queries\Concerns\ScopesQuery;
use Statamic\GraphQL\Types\EntryInterface;
use Statamic\GraphQL\Types\JsonArgument;
+use Statamic\Query\OrderBy;
use Statamic\Support\Str;
class EntriesQuery extends Query
@@ -96,7 +97,9 @@ private function sortQuery($query, $sorts)
[$sort, $order] = explode(' ', $sort);
}
- $query->orderBy($sort, $order);
+ if ($sort = OrderBy::column($sort)) {
+ $query->orderBy($sort, $order);
+ }
}
}
diff --git a/src/GraphQL/Queries/EntryQuery.php b/src/GraphQL/Queries/EntryQuery.php
index bb6f2d0577b..c1938723344 100644
--- a/src/GraphQL/Queries/EntryQuery.php
+++ b/src/GraphQL/Queries/EntryQuery.php
@@ -75,6 +75,10 @@ public function resolve($root, $args)
$entry = $query->limit(1)->get()->first();
+ if ($entry && $entry->status() !== 'published' && request()->isLivePreview() && ! request()->isLivePreviewOf($entry)) {
+ return null;
+ }
+
// The `AuthorizeSubResources` middleware will authorize when using `collection` arg,
// but this is still required when the user queries entry using other args.
if ($entry && ! in_array($collection = $entry->collection()->handle(), $this->allowedSubResources())) {
diff --git a/src/GraphQL/Queries/TermsQuery.php b/src/GraphQL/Queries/TermsQuery.php
index 7fb3ae0f705..90b8b39726f 100644
--- a/src/GraphQL/Queries/TermsQuery.php
+++ b/src/GraphQL/Queries/TermsQuery.php
@@ -13,6 +13,7 @@
use Statamic\GraphQL\Queries\Concerns\FiltersQuery;
use Statamic\GraphQL\Types\JsonArgument;
use Statamic\GraphQL\Types\TermInterface;
+use Statamic\Query\OrderBy;
use Statamic\Support\Str;
class TermsQuery extends Query
@@ -76,7 +77,9 @@ private function sortQuery($query, $sorts)
[$sort, $order] = explode(' ', $sort);
}
- $query->orderBy($sort, $order);
+ if ($sort = OrderBy::column($sort)) {
+ $query->orderBy($sort, $order);
+ }
}
}
diff --git a/src/GraphQL/Queries/UsersQuery.php b/src/GraphQL/Queries/UsersQuery.php
index 9cebad31950..21b1385c39b 100644
--- a/src/GraphQL/Queries/UsersQuery.php
+++ b/src/GraphQL/Queries/UsersQuery.php
@@ -11,6 +11,7 @@
use Statamic\GraphQL\Queries\Concerns\FiltersQuery;
use Statamic\GraphQL\Types\JsonArgument;
use Statamic\GraphQL\Types\UserType;
+use Statamic\Query\OrderBy;
use Statamic\Support\Str;
class UsersQuery extends Query
@@ -65,7 +66,9 @@ private function sortQuery($query, $sorts)
[$sort, $order] = explode(' ', $sort);
}
- $query->orderBy($sort, $order);
+ if ($sort = OrderBy::column($sort)) {
+ $query->orderBy($sort, $order);
+ }
}
}
diff --git a/src/Http/Controllers/API/ApiController.php b/src/Http/Controllers/API/ApiController.php
index dfd61b05fab..d732ef5d6e4 100644
--- a/src/Http/Controllers/API/ApiController.php
+++ b/src/Http/Controllers/API/ApiController.php
@@ -8,6 +8,7 @@
use Statamic\Facades\Scope;
use Statamic\Facades\Site;
use Statamic\Http\Controllers\Controller;
+use Statamic\Query\OrderBy;
use Statamic\Support\Arr;
use Statamic\Support\Str;
use Statamic\Tags\Concerns\QueriesConditions;
@@ -27,7 +28,7 @@ class ApiController extends Controller
*/
protected function abortIfUnpublished($item)
{
- if (request()->isLivePreview()) {
+ if (request()->isLivePreviewOf($item)) {
return;
}
@@ -259,7 +260,9 @@ protected function sort($query)
$order = 'desc';
}
- $query->orderBy($sort, $order);
+ if ($sort = OrderBy::column($sort)) {
+ $query->orderBy($sort, $order);
+ }
});
return $this;
diff --git a/src/Http/Controllers/CP/Assets/BrowserController.php b/src/Http/Controllers/CP/Assets/BrowserController.php
index f394ff73858..bd50a5375b7 100644
--- a/src/Http/Controllers/CP/Assets/BrowserController.php
+++ b/src/Http/Controllers/CP/Assets/BrowserController.php
@@ -14,6 +14,7 @@
use Statamic\Http\Resources\CP\Assets\Folder;
use Statamic\Http\Resources\CP\Assets\FolderAsset;
use Statamic\Http\Resources\CP\Assets\SearchedAssetsCollection;
+use Statamic\Query\OrderBy;
use Statamic\Support\Arr;
class BrowserController extends CpController
@@ -87,8 +88,7 @@ public function folder(Request $request, $container, $path = '/')
$totalAssets = $folder->queryAssets()->count();
$totalItems = $totalAssets + $totalFolders;
- if ($request->sort) {
- $sort = $request->sort;
+ if ($sort = OrderBy::column($request->sort)) {
$order = $request->order ?? 'asc';
} else {
$sort = $container->sortField();
@@ -98,7 +98,7 @@ public function folder(Request $request, $container, $path = '/')
$sortByMethod = $order === 'desc' ? 'sortByDesc' : 'sortBy';
$folders = $folders->$sortByMethod(
- fn (AssetFolder $folder) => method_exists($folder, $sort) ? $folder->$sort() : $folder->basename()
+ fn (AssetFolder $folder) => in_array($sort, ['basename', 'title', 'lastModified', 'size', 'count', 'path']) ? $folder->$sort() : $folder->basename()
);
$folders = $folders->slice(($page - 1) * $perPage, $perPage);
@@ -153,8 +153,8 @@ public function search(Request $request, $container, $path = null)
$query->where('folder', $path);
}
- if ($request->sort) {
- $query->orderBy($request->sort, $request->order ?? 'asc');
+ if ($sort = OrderBy::column($request->sort)) {
+ $query->orderBy($sort, $request->order ?? 'asc');
}
$this->applyQueryScopes($query, $request->all());
diff --git a/src/Http/Controllers/CP/Assets/SvgController.php b/src/Http/Controllers/CP/Assets/SvgController.php
index f1a714bee63..5c307d48c8c 100644
--- a/src/Http/Controllers/CP/Assets/SvgController.php
+++ b/src/Http/Controllers/CP/Assets/SvgController.php
@@ -21,7 +21,9 @@ public function show($asset)
$this->authorize('view', $asset);
- return response($contents)->header('Content-Type', 'image/svg+xml');
+ return response($contents)
+ ->header('Content-Type', 'image/svg+xml')
+ ->header('Content-Security-Policy', "script-src 'none'");
}
/**
diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php
index d21796205dd..a907e18a519 100644
--- a/src/Http/Controllers/CP/Collections/EntriesController.php
+++ b/src/Http/Controllers/CP/Collections/EntriesController.php
@@ -19,6 +19,7 @@
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Http\Resources\CP\Entries\Entries;
use Statamic\Http\Resources\CP\Entries\Entry as EntryResource;
+use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -39,7 +40,7 @@ public function index(FilteredRequest $request, $collection)
'blueprints' => $collection->entryBlueprints()->map->handle(),
]);
- $sortField = request('sort');
+ $sortField = OrderBy::column(request('sort'));
$sortDirection = request('order', 'asc');
if (! $sortField && ! request('search')) {
diff --git a/src/Http/Controllers/CP/Collections/EntryRevisionsController.php b/src/Http/Controllers/CP/Collections/EntryRevisionsController.php
index cb405d74ee6..3a6d55107db 100644
--- a/src/Http/Controllers/CP/Collections/EntryRevisionsController.php
+++ b/src/Http/Controllers/CP/Collections/EntryRevisionsController.php
@@ -12,6 +12,8 @@ class EntryRevisionsController extends CpController
{
public function index(Request $request, $collection, $entry)
{
+ $this->authorize('view', $entry);
+
$revisions = $entry
->revisions()
->reverse()
@@ -46,6 +48,8 @@ public function index(Request $request, $collection, $entry)
public function store(Request $request, $collection, $entry)
{
+ $this->authorize('edit', $entry);
+
$entry->createRevision([
'message' => $request->message,
'user' => User::fromUser($request->user()),
@@ -56,6 +60,8 @@ public function store(Request $request, $collection, $entry)
public function show(Request $request, $collection, $entry, $revision)
{
+ $this->authorize('view', $entry);
+
$entry = $entry->makeFromRevision($revision);
// TODO: Most of this is duplicated with EntriesController@edit. DRY it off.
diff --git a/src/Http/Controllers/CP/Fieldtypes/MarkdownFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/MarkdownFieldtypeController.php
index 1888068defb..1b84d17024e 100644
--- a/src/Http/Controllers/CP/Fieldtypes/MarkdownFieldtypeController.php
+++ b/src/Http/Controllers/CP/Fieldtypes/MarkdownFieldtypeController.php
@@ -12,7 +12,11 @@ class MarkdownFieldtypeController extends CpController
{
public function preview(Request $request)
{
- return $this->fieldtype($request->config)->augment($request->value);
+ $config = $request->config;
+
+ abort_unless(($config['type'] ?? null) === 'markdown', 400, 'Bad Request');
+
+ return $this->fieldtype($config)->augment($request->value);
}
protected function fieldtype($config)
diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
index 843919d3004..2b447caee4c 100644
--- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
+++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
@@ -6,6 +6,7 @@
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Http\Resources\CP\Submissions\Submissions;
+use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;
class FormSubmissionsController extends CpController
@@ -26,7 +27,7 @@ public function index(FilteredRequest $request, $form)
'form' => $form->handle(),
]);
- $sortField = request('sort', 'date');
+ $sortField = OrderBy::column(request('sort'), 'date');
$sortDirection = request('order', $sortField === 'date' ? 'desc' : 'asc');
if ($sortField) {
diff --git a/src/Http/Controllers/CP/Taxonomies/RestoreTermRevisionController.php b/src/Http/Controllers/CP/Taxonomies/RestoreTermRevisionController.php
deleted file mode 100644
index 35290002784..00000000000
--- a/src/Http/Controllers/CP/Taxonomies/RestoreTermRevisionController.php
+++ /dev/null
@@ -1,26 +0,0 @@
-revision($request->revision)) {
- dd('no such revision', $request->revision);
- // todo: handle invalid revision reference
- }
-
- if ($entry->published()) {
- WorkingCopy::fromRevision($target)->date(now())->save();
- } else {
- $entry->makeFromRevision($target)->published(false)->save();
- }
-
- session()->flash('success', __('Revision restored'));
- }
-}
diff --git a/src/Http/Controllers/CP/Taxonomies/TermRevisionsController.php b/src/Http/Controllers/CP/Taxonomies/TermRevisionsController.php
deleted file mode 100644
index dafc0af0488..00000000000
--- a/src/Http/Controllers/CP/Taxonomies/TermRevisionsController.php
+++ /dev/null
@@ -1,118 +0,0 @@
-revisions()
- ->reverse()
- ->prepend($this->workingCopy($term))
- ->filter();
-
- // The first non manually created revision would be considered the "current"
- // version. It's what corresponds to what's in the content directory.
- optional($revisions->first(function ($revision) {
- return $revision->action() != 'revision';
- }))->attribute('current', true);
-
- return $revisions
- ->groupBy(function ($revision) {
- return $revision->date()->clone()->startOfDay()->format('U');
- })->map(function ($revisions, $day) {
- return compact('day', 'revisions');
- })->reverse()->values();
- }
-
- public function store(Request $request, $taxonomy, $term)
- {
- $term->createRevision([
- 'message' => $request->message,
- 'user' => User::fromUser($request->user()),
- ]);
-
- return new TermResource($term);
- }
-
- public function show(Request $request, $taxonomy, $term, $site, $revision)
- {
- $term = $term->makeFromRevision($revision);
-
- // TODO: Most of this is duplicated with EntriesController@edit. DRY it off.
-
- $blueprint = $term->blueprint();
-
- $fields = $blueprint
- ->fields()
- ->addValues($term->data())
- ->preProcess();
-
- $values = array_merge($fields->values()->all(), [
- 'title' => $term->get('title'),
- 'slug' => $term->slug(),
- ]);
-
- return [
- 'title' => $term->value('title'),
- 'editing' => true,
- 'actions' => [
- 'save' => $term->updateUrl(),
- 'publish' => $term->publishUrl(),
- 'unpublish' => $term->unpublishUrl(),
- 'revisions' => $term->revisionsUrl(),
- 'restore' => $term->restoreRevisionUrl(),
- 'createRevision' => $term->createRevisionUrl(),
- ],
- 'values' => $values,
- 'meta' => $fields->meta(),
- 'taxonomy' => $this->taxonomyToArray($term->taxonomy()),
- 'blueprint' => $blueprint->toPublishArray(),
- 'readOnly' => User::fromUser($request->user())->cant('edit', $term),
- 'published' => $term->published(),
- 'locale' => $term->locale(),
- 'localizations' => $term->taxonomy()->sites()->map(function ($handle) use ($term) {
- $localized = $term->in($handle);
- $exists = $localized !== null;
-
- return [
- 'handle' => $handle,
- 'name' => Site::get($handle)->name(),
- 'active' => $handle === $term->locale(),
- 'exists' => $exists,
- 'root' => $exists ? $localized->isRoot() : false,
- 'origin' => $exists ? $localized->id() === optional($term->origin())->id() : null,
- 'published' => $exists ? $localized->published() : false,
- 'url' => $exists ? $localized->editUrl() : null,
- ];
- })->all(),
- ];
- }
-
- protected function workingCopy($term)
- {
- if ($term->published()) {
- return $term->workingCopy();
- }
-
- return $term
- ->makeWorkingCopy()
- ->date($term->lastModified())
- ->user($term->lastModifiedBy());
- }
-
- protected function taxonomyToArray($taxonomy)
- {
- return [
- 'title' => $taxonomy->title(),
- 'url' => cp_route('taxonomies.show', $taxonomy->handle()),
- ];
- }
-}
diff --git a/src/Http/Controllers/CP/Taxonomies/TermsController.php b/src/Http/Controllers/CP/Taxonomies/TermsController.php
index 5dc382080a3..6957877fe9f 100644
--- a/src/Http/Controllers/CP/Taxonomies/TermsController.php
+++ b/src/Http/Controllers/CP/Taxonomies/TermsController.php
@@ -14,6 +14,7 @@
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Http\Resources\CP\Taxonomies\Term as TermResource;
use Statamic\Http\Resources\CP\Taxonomies\Terms;
+use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;
use Statamic\Rules\Slug;
use Statamic\Rules\UniqueTermValue;
@@ -34,7 +35,7 @@ public function index(FilteredRequest $request, $taxonomy)
'blueprints' => $taxonomy->termBlueprints()->map->handle(),
]);
- $sortField = request('sort');
+ $sortField = OrderBy::column(request('sort'));
$sortDirection = request('order', 'asc');
if (! $sortField && ! request('search')) {
@@ -87,8 +88,6 @@ public function edit(Request $request, $taxonomy, $term)
{
$this->authorize('view', $term);
- $term = $term->fromWorkingCopy();
-
$blueprint = $term->blueprint();
[$values, $meta] = $this->extractFromFields($term, $blueprint);
@@ -105,9 +104,6 @@ public function edit(Request $request, $taxonomy, $term)
'save' => $term->updateUrl(),
'publish' => $term->publishUrl(),
'unpublish' => $term->unpublishUrl(),
- 'revisions' => $term->revisionsUrl(),
- 'restore' => $term->restoreRevisionUrl(),
- 'createRevision' => $term->createRevisionUrl(),
'editBlueprint' => cp_route('taxonomies.blueprints.edit', [$taxonomy, $blueprint]),
],
'values' => array_merge($values, ['id' => $term->id()]),
@@ -138,9 +134,7 @@ public function edit(Request $request, $taxonomy, $term)
'livePreviewUrl' => $localized->livePreviewUrl(),
];
})->all(),
- 'hasWorkingCopy' => $term->hasWorkingCopy(),
'preloadedAssets' => $this->extractAssetsFromValues($values),
- 'revisionsEnabled' => $term->revisionsEnabled(),
'breadcrumbs' => $this->breadcrumbs($taxonomy),
'previewTargets' => $taxonomy->previewTargets()->all(),
'itemActions' => Action::for($term, ['taxonomy' => $taxonomy->handle(), 'view' => 'form']),
@@ -166,8 +160,6 @@ public function update(Request $request, $taxonomy, $term, $site)
$this->authorize('update', $term);
- $term = $term->fromWorkingCopy();
-
$term->term()->syncOriginal();
$fields = $term->blueprint()->fields()->addValues($request->except('id'));
@@ -197,18 +189,9 @@ public function update(Request $request, $taxonomy, $term, $site)
$term->slug($request->slug);
- if ($term->revisionsEnabled() && $term->published()) {
- $term
- ->makeWorkingCopy()
- ->user(User::current())
- ->save();
- } else {
- if (! $term->revisionsEnabled()) {
- $term->published($request->published);
- }
+ $term->published($request->published);
- $saved = $term->updateLastModified(User::current())->save();
- }
+ $saved = $term->updateLastModified(User::current())->save();
[$values] = $this->extractFromFields($term, $term->blueprint());
@@ -315,14 +298,7 @@ public function store(Request $request, $taxonomy, $site)
->data($values)
->slug($slug);
- if ($term->revisionsEnabled()) {
- $term->store([
- 'message' => $request->message,
- 'user' => User::current(),
- ]);
- } else {
- $saved = $term->updateLastModified(User::current())->save();
- }
+ $saved = $term->updateLastModified(User::current())->save();
return (new TermResource($term))
->additional(['saved' => $saved]);
diff --git a/src/Http/Controllers/CP/Users/UsersController.php b/src/Http/Controllers/CP/Users/UsersController.php
index 1d63059b5a8..b2b5005f7d8 100644
--- a/src/Http/Controllers/CP/Users/UsersController.php
+++ b/src/Http/Controllers/CP/Users/UsersController.php
@@ -16,6 +16,7 @@
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Http\Resources\CP\Users\Users;
use Statamic\Notifications\ActivateAccount;
+use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;
use Statamic\Rules\UniqueUserValue;
use Statamic\Search\Result;
@@ -71,7 +72,7 @@ protected function json($request)
'blueprints' => ['user'],
]);
- $sortField = request('sort');
+ $sortField = OrderBy::column(request('sort'));
$sortDirection = request('order', 'asc');
if (! $sortField && ! request('search')) {
diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php
index 325cc795d5e..7bfbd320adb 100644
--- a/src/Http/Controllers/ForgotPasswordController.php
+++ b/src/Http/Controllers/ForgotPasswordController.php
@@ -2,11 +2,11 @@
namespace Statamic\Http\Controllers;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Statamic\Auth\Passwords\PasswordReset;
use Statamic\Auth\SendsPasswordResetEmails;
-use Statamic\Exceptions\ValidationException;
use Statamic\Facades\URL;
use Statamic\Http\Middleware\RedirectIfAuthenticated;
@@ -30,17 +30,40 @@ public function showLinkRequestForm()
public function sendResetLinkEmail(Request $request)
{
- if ($url = $request->_reset_url) {
- throw_if(URL::isExternalToApplication($url), ValidationException::withMessages([
- '_reset_url' => trans('validation.url', ['attribute' => '_reset_url']),
- ]));
-
+ if ($url = $this->getResetFormUrl($request)) {
PasswordReset::resetFormUrl(URL::makeAbsolute($url));
}
return $this->traitSendResetLinkEmail($request);
}
+ private function getResetFormUrl(Request $request): ?string
+ {
+ if (! $url = $request->_reset_url) {
+ return null;
+ }
+
+ if (strlen($url) > 2048) {
+ return null;
+ }
+
+ try {
+ $url = decrypt($url);
+ } catch (DecryptException $e) {
+ if (! str_starts_with($url, '/') || str_starts_with($url, '//')) {
+ return null;
+ }
+
+ if (preg_match('/[\x00-\x1F\x7F]/', $url)) {
+ return null;
+ }
+
+ return $url;
+ }
+
+ return URL::isExternalToApplication($url) ? null : $url;
+ }
+
public function broker()
{
$broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS);
diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php
index 5913d199906..9de616b57e9 100644
--- a/src/Http/Controllers/FormController.php
+++ b/src/Http/Controllers/FormController.php
@@ -154,9 +154,7 @@ private function formSuccess($params, $submission, $silentFailure = false)
]);
}
- $response = $redirect && ! \Statamic\Facades\URL::isExternalToApplication($redirect)
- ? redirect($redirect)
- : back();
+ $response = $redirect ? redirect($redirect) : back();
if (! \Statamic\Facades\URL::isExternal($redirect)) {
session()->flash("form.{$submission->form()->handle()}.success", __('Submission successful.'));
@@ -169,8 +167,14 @@ private function formSuccess($params, $submission, $silentFailure = false)
private function formSuccessRedirect($params, $submission)
{
- if (! $redirect = Form::getSubmissionRedirect($submission)) {
- $redirect = Arr::get($params, '_redirect');
+ if ($redirect = Form::getSubmissionRedirect($submission)) {
+ return $redirect;
+ }
+
+ $redirect = Arr::get($params, '_redirect');
+
+ if ($redirect && \Statamic\Facades\URL::isExternalToApplication($redirect)) {
+ return null;
}
return $redirect;
diff --git a/src/Http/Responses/DataResponse.php b/src/Http/Responses/DataResponse.php
index 83e1e8bc39d..7ae6885df3e 100644
--- a/src/Http/Responses/DataResponse.php
+++ b/src/Http/Responses/DataResponse.php
@@ -112,7 +112,7 @@ protected function handleDraft()
return $this;
}
- throw_unless($this->request->isLivePreview(), new NotFoundHttpException);
+ throw_unless($this->isLivePreviewing(), new NotFoundHttpException);
$this->headers['X-Statamic-Draft'] = true;
@@ -129,13 +129,18 @@ protected function handlePrivateEntries()
return $this;
}
- throw_unless($this->request->isLivePreview(), new NotFoundHttpException);
+ throw_unless($this->isLivePreviewing(), new NotFoundHttpException);
$this->headers['X-Statamic-Private'] = true;
return $this;
}
+ private function isLivePreviewing()
+ {
+ return $this->request->isLivePreviewOf($this->data);
+ }
+
protected function view()
{
return app(View::class)
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index a543a475cd1..2069cff5ecc 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -96,6 +96,18 @@ public function boot()
return optional($this->statamicToken())->handler() === LivePreview::class;
});
+ Request::macro('isLivePreviewOf', function ($item) {
+ $token = $this->statamicToken();
+
+ if (! $token || $token->handler() !== LivePreview::class) {
+ return false;
+ }
+
+ $previewItem = \Facades\Statamic\CP\LivePreview::item($token);
+
+ return $item && $previewItem && method_exists($item, 'reference') && $previewItem->reference() === $item->reference();
+ });
+
TrimStrings::skipWhen(function (Request $request) {
$route = config('statamic.cp.route');
diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php
index ed0899f4dec..f344c0f7d22 100644
--- a/src/Providers/RouteServiceProvider.php
+++ b/src/Providers/RouteServiceProvider.php
@@ -361,8 +361,6 @@ protected function bindRevisions()
if ($route->hasParameter('entry')) {
$content = $route->parameter('entry');
- } elseif ($route->hasParameter('term')) {
- $content = $route->parameter('term');
} else {
throw new NotFoundHttpException;
}
diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php
index a1f87f50b12..7024b87efcf 100644
--- a/src/Providers/ViewServiceProvider.php
+++ b/src/Providers/ViewServiceProvider.php
@@ -103,183 +103,15 @@ private function registerAntlers()
$runtimeConfig->guardedContentVariablePatterns = config('statamic.antlers.guardedContentVariables', []);
$runtimeConfig->guardedContentTagPatterns = config('statamic.antlers.guardedContentTags', []);
$runtimeConfig->guardedContentModifiers = config('statamic.antlers.guardedContentModifiers', []);
- $runtimeConfig->allowedContentTagPatterns = config('statamic.antlers.allowedContentTags', [
- 'obfuscate:*',
- 'trans:*',
- 'trans_choice:*',
- 'widont:*',
- ...$this->getAppTagPatternsForContentAllowlist($app),
- ]);
+ $runtimeConfig->allowedContentTagPatterns = $this->mergeContentAllowlist(
+ config('statamic.antlers.allowedContentTags'),
+ $this->defaultAllowedContentTagPatterns($app)
+ );
- $runtimeConfig->allowedContentModifiers = config('statamic.antlers.allowedContentModifiers', [
- 'add_query_param',
- 'add_slashes',
- 'alias',
- 'ampersand_list',
- 'ascii',
- 'at',
- 'backspace',
- 'background_position',
- 'bool_string',
- 'camelize',
- 'cdata',
- 'ceil',
- 'chunk',
- 'classes',
- 'collapse',
- 'collapse_whitespace',
- 'contains_any',
- 'count',
- 'count_substring',
- 'dashify',
- 'days_ago',
- 'decode',
- 'deslugify',
- 'diff_for_humans',
- 'divide',
- 'dl',
- 'embed_url',
- 'ends_with',
- 'ensure_left',
- 'ensure_right',
- 'entities',
- 'excerpt',
- 'explode',
- 'extension',
- 'favicon',
- 'filter_empty',
- 'first',
- 'flatten',
- 'flip',
- 'floor',
- 'format',
- 'format_number',
- 'format_translated',
- 'full_urls',
- 'has_lower_case',
- 'has_upper_case',
- 'headline',
- 'hex_to_rgb',
- 'hours_ago',
- 'insert',
- 'is_alpha',
- 'is_alphanumeric',
- 'is_array',
- 'is_blank',
- 'is_email',
- 'is_embeddable',
- 'is_empty',
- 'is_external_url',
- 'is_future',
- 'is_iterable',
- 'is_json',
- 'is_leap_year',
- 'is_lowercase',
- 'is_numeric',
- 'is_past',
- 'is_today',
- 'is_tomorrow',
- 'is_uppercase',
- 'is_url',
- 'is_weekday',
- 'is_weekend',
- 'is_yesterday',
- 'iso_format',
- 'join',
- 'key_by',
- 'keys',
- 'kebab',
- 'last',
- 'lcfirst',
- 'length',
- 'limit',
- 'localize',
- 'lower',
- 'markdown',
- 'md5',
- 'minutes_ago',
- 'mod',
- 'modify_date',
- 'months_ago',
- 'multiply',
- 'nl2br',
- 'obfuscate',
- 'obfuscate_email',
- 'offset',
- 'ol',
- 'option_list',
- 'parse_url',
- 'pathinfo',
- 'pluck',
- 'random',
- 'rawurlencode',
- 'read_time',
- 'relative',
- 'remove_left',
- 'remove_query_param',
- 'remove_right',
- 'replace',
- 'resolve',
- 'reverse',
- 'round',
- 'safe_truncate',
- 'sanitize',
- 'scope',
- 'seconds_ago',
- 'select',
- 'sentence_list',
- 'set_query_param',
- 'shuffle',
- 'singular',
- 'slugify',
- 'smartypants',
- 'snake',
- 'sort',
- 'spaceless',
- 'split',
- 'starts_with',
- 'str_pad',
- 'str_pad_both',
- 'str_pad_left',
- 'str_pad_right',
- 'strip_tags',
- 'studly',
- 'subtract',
- 'substr',
- 'sum',
- 'surround',
- 'swap_case',
- 'table',
- 'tidy',
- 'timestamp',
- 'timezone',
- 'title',
- 'to_bool',
- 'to_qs',
- 'to_spaces',
- 'to_tabs',
- 'to_string',
- 'trans',
- 'trans_choice',
- 'trackable_embed_url',
- 'trim',
- 'truncate',
- 'type_of',
- 'ucfirst',
- 'underscored',
- 'unique',
- 'upper',
- 'urldecode',
- 'urlencode',
- 'values',
- 'weeks_ago',
- 'where',
- 'where_in',
- 'widont',
- 'word_count',
- 'years_ago',
- ...$this->getAppModifierHandlesForContentAllowlist($app),
- ]);
+ $runtimeConfig->allowedContentModifiers = $this->mergeContentAllowlist(
+ config('statamic.antlers.allowedContentModifiers'),
+ $this->defaultAllowedContentModifiers($app)
+ );
$runtimeConfig->allowPhpInUserContent = config('statamic.antlers.allowPhpInContent', false);
$runtimeConfig->allowMethodsInUserContent = config('statamic.antlers.allowMethodsInContent', false);
@@ -358,6 +190,204 @@ private function getAppModifierHandlesForContentAllowlist(Application $app): arr
->all();
}
+ private function defaultAllowedContentTagPatterns(Application $app): array
+ {
+ return [
+ 'link:*',
+ 'obfuscate:*',
+ 'trans:*',
+ 'trans_choice:*',
+ 'widont:*',
+ ...$this->getAppTagPatternsForContentAllowlist($app),
+ ];
+ }
+
+ private function defaultAllowedContentModifiers(Application $app): array
+ {
+ return [
+ 'add_query_param',
+ 'add_slashes',
+ 'alias',
+ 'ampersand_list',
+ 'ascii',
+ 'at',
+ 'backspace',
+ 'background_position',
+ 'bool_string',
+ 'camelize',
+ 'cdata',
+ 'ceil',
+ 'chunk',
+ 'classes',
+ 'collapse',
+ 'collapse_whitespace',
+ 'contains_any',
+ 'count',
+ 'count_substring',
+ 'dashify',
+ 'days_ago',
+ 'decode',
+ 'deslugify',
+ 'diff_for_humans',
+ 'divide',
+ 'dl',
+ 'embed_url',
+ 'ends_with',
+ 'ensure_left',
+ 'ensure_right',
+ 'entities',
+ 'excerpt',
+ 'explode',
+ 'extension',
+ 'favicon',
+ 'filter_empty',
+ 'first',
+ 'flatten',
+ 'flip',
+ 'floor',
+ 'format',
+ 'format_number',
+ 'format_translated',
+ 'full_urls',
+ 'has_lower_case',
+ 'has_upper_case',
+ 'headline',
+ 'hex_to_rgb',
+ 'hours_ago',
+ 'insert',
+ 'is_alpha',
+ 'is_alphanumeric',
+ 'is_array',
+ 'is_blank',
+ 'is_email',
+ 'is_embeddable',
+ 'is_empty',
+ 'is_external_url',
+ 'is_future',
+ 'is_iterable',
+ 'is_json',
+ 'is_leap_year',
+ 'is_lowercase',
+ 'is_numeric',
+ 'is_past',
+ 'is_today',
+ 'is_tomorrow',
+ 'is_uppercase',
+ 'is_url',
+ 'is_weekday',
+ 'is_weekend',
+ 'is_yesterday',
+ 'iso_format',
+ 'join',
+ 'key_by',
+ 'keys',
+ 'kebab',
+ 'last',
+ 'lcfirst',
+ 'length',
+ 'limit',
+ 'localize',
+ 'lower',
+ 'markdown',
+ 'md5',
+ 'minutes_ago',
+ 'mod',
+ 'modify_date',
+ 'months_ago',
+ 'multiply',
+ 'nl2br',
+ 'obfuscate',
+ 'obfuscate_email',
+ 'offset',
+ 'ol',
+ 'option_list',
+ 'parse_url',
+ 'pathinfo',
+ 'pluck',
+ 'random',
+ 'rawurlencode',
+ 'read_time',
+ 'relative',
+ 'remove_left',
+ 'remove_query_param',
+ 'remove_right',
+ 'replace',
+ 'resolve',
+ 'reverse',
+ 'round',
+ 'safe_truncate',
+ 'sanitize',
+ 'scope',
+ 'seconds_ago',
+ 'select',
+ 'sentence_list',
+ 'set_query_param',
+ 'shuffle',
+ 'singular',
+ 'slugify',
+ 'smartypants',
+ 'snake',
+ 'sort',
+ 'spaceless',
+ 'split',
+ 'starts_with',
+ 'str_pad',
+ 'str_pad_both',
+ 'str_pad_left',
+ 'str_pad_right',
+ 'strip_tags',
+ 'studly',
+ 'subtract',
+ 'substr',
+ 'sum',
+ 'surround',
+ 'swap_case',
+ 'table',
+ 'tidy',
+ 'timestamp',
+ 'timezone',
+ 'title',
+ 'to_bool',
+ 'to_qs',
+ 'to_spaces',
+ 'to_tabs',
+ 'to_string',
+ 'trans',
+ 'trans_choice',
+ 'trackable_embed_url',
+ 'trim',
+ 'truncate',
+ 'type_of',
+ 'ucfirst',
+ 'underscored',
+ 'unique',
+ 'upper',
+ 'urldecode',
+ 'urlencode',
+ 'values',
+ 'weeks_ago',
+ 'where',
+ 'where_in',
+ 'widont',
+ 'word_count',
+ 'years_ago',
+ ...$this->getAppModifierHandlesForContentAllowlist($app),
+ ];
+ }
+
+ private function mergeContentAllowlist(mixed $configured, array $defaults): array
+ {
+ if ($configured === null) {
+ return $defaults;
+ }
+
+ return collect((array) $configured)
+ ->flatMap(fn ($item) => $item === '@default' ? $defaults : [$item])
+ ->unique()
+ ->values()
+ ->all();
+ }
+
public function registerBladeDirectives()
{
Blade::directive('tags', function ($expression) {
diff --git a/src/Query/OrderBy.php b/src/Query/OrderBy.php
index 10bacaab599..dce95d88972 100644
--- a/src/Query/OrderBy.php
+++ b/src/Query/OrderBy.php
@@ -22,6 +22,15 @@ public function __construct(string $sort, string $direction)
$this->direction = $direction;
}
+ public static function column(?string $value, ?string $default = null): ?string
+ {
+ if ($value && preg_match('/^[\w]+((\->|[.])[\w]+)*$/', $value)) {
+ return $value;
+ }
+
+ return $default;
+ }
+
/**
* Instantiate order by object.
*
diff --git a/src/Query/ResolveValue.php b/src/Query/ResolveValue.php
index 09a10392a26..052d2240f90 100644
--- a/src/Query/ResolveValue.php
+++ b/src/Query/ResolveValue.php
@@ -9,6 +9,12 @@
class ResolveValue
{
+ private array $denylist = [
+ 'delete', 'deleteFile', 'deleteQuietly',
+ 'destroy', 'forceDelete', 'save', 'saveQuietly',
+ 'truncate', 'update', 'updateQuietly', 'write', 'writeFile',
+ ];
+
public function __invoke($item, $name)
{
if (Str::startsWith($name, 'data->')) {
@@ -52,7 +58,7 @@ private function getItemPartValue($item, $name)
return $item->getQueryableValue($name);
}
- if (method_exists($item, $method = Str::camel($name))) {
+ if (method_exists($item, $method = Str::camel($name)) && ! in_array($method, $this->denylist)) {
return $item->{$method}();
}
diff --git a/src/Query/Scopes/Filters/Collection.php b/src/Query/Scopes/Filters/Collection.php
index ddfa4cf9b04..169b1a58c75 100644
--- a/src/Query/Scopes/Filters/Collection.php
+++ b/src/Query/Scopes/Filters/Collection.php
@@ -2,7 +2,9 @@
namespace Statamic\Query\Scopes\Filters;
+use Statamic\Exceptions\AuthorizationException;
use Statamic\Facades;
+use Statamic\Facades\User;
use Statamic\Query\Scopes\Filter;
class Collection extends Filter
@@ -29,6 +31,8 @@ public function fieldItems()
public function apply($query, $values)
{
+ $this->authorizeCollectionAccess($values['collections']);
+
$query->whereIn('collection', $values['collections']);
}
@@ -44,8 +48,25 @@ public function visibleTo($key)
protected function options()
{
- return collect($this->context['collections'])->mapWithKeys(function ($collection) {
- return [$collection => Facades\Collection::findByHandle($collection)->title()];
+ $user = User::current();
+
+ return collect($this->context['collections'])
+ ->map(fn ($collection) => Facades\Collection::findByHandle($collection))
+ ->filter(fn ($collection) => $collection && $user->can('view', $collection))
+ ->mapWithKeys(fn ($collection) => [$collection->handle() => $collection->title()]);
+ }
+
+ private function authorizeCollectionAccess(array $collections): void
+ {
+ $user = User::current();
+
+ collect($collections)->each(function (string $collectionHandle) use ($user) {
+ $collection = Facades\Collection::findByHandle($collectionHandle);
+
+ throw_if(
+ ! $collection || ! $user->can('view', $collection),
+ new AuthorizationException
+ );
});
}
}
diff --git a/src/Stache/Indexes/Index.php b/src/Stache/Indexes/Index.php
index b4a6f3df6d0..61182a2e005 100644
--- a/src/Stache/Indexes/Index.php
+++ b/src/Stache/Indexes/Index.php
@@ -11,7 +11,7 @@ abstract class Index
protected $name;
protected $items = [];
protected $loaded = false;
- private static ?string $currentlyLoading = null;
+ private static array $loadingStack = [];
public function __construct($store, $name)
{
@@ -66,27 +66,29 @@ public function load()
}
$loadingKey = $this->store->key().'/'.$this->name;
- $currentlyLoadingThis = static::$currentlyLoading === $loadingKey;
+ $currentlyLoadingThis = in_array($loadingKey, static::$loadingStack);
- static::$currentlyLoading = $loadingKey;
+ static::$loadingStack[] = $loadingKey;
- $this->loaded = true;
+ try {
+ $this->loaded = true;
- if (Statamic::isWorker() && ! $currentlyLoadingThis) {
- $this->loaded = false;
- }
-
- debugbar()->addMessage("Loading index: {$loadingKey}", 'stache');
+ if (Statamic::isWorker() && ! $currentlyLoadingThis) {
+ $this->loaded = false;
+ }
- $this->items = Stache::cacheStore()->get($this->cacheKey());
+ debugbar()->addMessage("Loading index: {$loadingKey}", 'stache');
- if ($this->items === null) {
- $this->update();
- }
+ $this->items = Stache::cacheStore()->get($this->cacheKey());
- $this->store->cacheIndexUsage($this);
+ if ($this->items === null) {
+ $this->update();
+ }
- static::$currentlyLoading = null;
+ $this->store->cacheIndexUsage($this);
+ } finally {
+ array_pop(static::$loadingStack);
+ }
return $this;
}
@@ -161,8 +163,14 @@ public function clear()
Stache::cacheStore()->forget($this->cacheKey());
}
+ /** @deprecated */
public static function currentlyLoading()
{
- return static::$currentlyLoading;
+ return end(static::$loadingStack) ?: null;
+ }
+
+ public static function isLoading(): bool
+ {
+ return ! empty(static::$loadingStack);
}
}
diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php
index fd8de075c90..2ae7e3c15f9 100644
--- a/src/Stache/Stores/CollectionEntriesStore.php
+++ b/src/Stache/Stores/CollectionEntriesStore.php
@@ -234,9 +234,7 @@ protected function getCachedItem($key)
return null;
}
- $isLoadingIds = Index::currentlyLoading() === $this->key().'/id';
-
- if (! $isLoadingIds && $this->shouldBlinkEntryUris && ($uri = $this->resolveIndex('uri')->load()->get($entry->id()))) {
+ if (! Index::isLoading() && $this->shouldBlinkEntryUris && ($uri = $this->resolveIndex('uri')->load()->get($entry->id()))) {
Blink::store('entry-uris')->put($entry->id(), $uri);
}
diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php
index a6a144fcaa5..8aee797e5b6 100644
--- a/src/Stache/Stores/Store.php
+++ b/src/Stache/Stores/Store.php
@@ -319,6 +319,8 @@ public function paths()
return $isDuplicate ?? false;
});
+ $items->each(fn ($item) => $this->cacheItem($item['item']));
+
$paths = $items->pluck('path', 'key');
$this->cachePaths($paths);
diff --git a/src/Stache/Stores/TaxonomiesStore.php b/src/Stache/Stores/TaxonomiesStore.php
index 4978d9993b5..365f7438be9 100644
--- a/src/Stache/Stores/TaxonomiesStore.php
+++ b/src/Stache/Stores/TaxonomiesStore.php
@@ -43,7 +43,6 @@ public function makeItemFromFile($path, $contents)
return Taxonomy::make($handle)
->title(Arr::get($data, 'title'))
->cascade(Arr::get($data, 'inject', []))
- ->revisionsEnabled(Arr::get($data, 'revisions', false))
->searchIndex(Arr::get($data, 'search_index'))
->defaultPublishState($this->getDefaultPublishState($data))
->sites($sites)
diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php
index 50170b5983f..d8791897b76 100644
--- a/src/StaticCaching/Cachers/FileCacher.php
+++ b/src/StaticCaching/Cachers/FileCacher.php
@@ -278,7 +278,7 @@ function replaceElement(el, html) {
})
.then((response) => response.json())
.then((data) => {
- map = createMap(); // Recreate map in case the DOM changed.
+ map = createMap();
const regions = data.regions;
for (var key in regions) {
diff --git a/src/Structures/Nav.php b/src/Structures/Nav.php
index e6699a8ec21..208c674c18b 100644
--- a/src/Structures/Nav.php
+++ b/src/Structures/Nav.php
@@ -2,6 +2,7 @@
namespace Statamic\Structures;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Structures\Nav as Contract;
use Statamic\Contracts\Structures\NavTree;
use Statamic\Contracts\Structures\NavTreeRepository;
@@ -21,7 +22,7 @@
use Statamic\Facades\Stache;
use Statamic\Support\Str;
-class Nav extends Structure implements Contract
+class Nav extends Structure implements ContainsQueryableValues, Contract
{
use ExistsAsFile;
@@ -187,4 +188,21 @@ public function collectionsQueryScopes($scopes = null)
})
->args(func_get_args());
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'blueprint', 'collections', 'editUrl', 'expectsRoot', 'handle', 'id',
+ 'maxDepth', 'path', 'showUrl', 'sites', 'title', 'trees',
+ ];
+ }
}
diff --git a/src/Structures/Page.php b/src/Structures/Page.php
index 20653ce0a61..d65643370a9 100644
--- a/src/Structures/Page.php
+++ b/src/Structures/Page.php
@@ -13,6 +13,7 @@
use Statamic\Contracts\Data\BulkAugmentable;
use Statamic\Contracts\Entries\Entry;
use Statamic\Contracts\GraphQL\ResolvesValues as ResolvesValuesContract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Routing\UrlBuilder;
use Statamic\Contracts\Structures\Nav;
use Statamic\Data\ContainsSupplementalData;
@@ -25,7 +26,7 @@
use Statamic\GraphQL\ResolvesValues;
use Statamic\Support\Str;
-class Page implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, Entry, JsonSerializable, Protectable, ResolvesValuesContract, Responsable
+class Page implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Entry, JsonSerializable, Protectable, ResolvesValuesContract, Responsable
{
use ContainsSupplementalData, ForwardsCalls, HasAugmentedInstance, ResolvesValues, TracksQueriedColumns;
@@ -493,6 +494,24 @@ public function __call($method, $args)
return $this->forwardCallTo($this->entry(), $method, $args);
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return $this->value($field);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'absoluteUrl', 'blueprint', 'collection', 'depth', 'editUrl', 'id', 'isRedirect', 'isRoot',
+ 'private', 'published', 'reference', 'route', 'site', 'slug', 'status', 'structure',
+ 'title', 'uri', 'url', 'urlWithoutRedirect',
+ ];
+ }
+
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
diff --git a/src/Structures/Tree.php b/src/Structures/Tree.php
index 1cbd7e1270e..4de7ccbef8b 100644
--- a/src/Structures/Tree.php
+++ b/src/Structures/Tree.php
@@ -3,6 +3,7 @@
namespace Statamic\Structures;
use Statamic\Contracts\Data\Localization;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Structures\Tree as Contract;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasDirtyState;
@@ -10,9 +11,10 @@
use Statamic\Facades\Entry;
use Statamic\Facades\Site;
use Statamic\Support\Arr;
+use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-abstract class Tree implements Contract, Localization
+abstract class Tree implements ContainsQueryableValues, Contract, Localization
{
use ExistsAsFile, FluentlyGetsAndSets, HasDirtyState;
@@ -388,6 +390,22 @@ public function withEntries()
return $this;
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return null;
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'editUrl', 'handle', 'locale', 'path', 'route', 'showUrl', 'site',
+ ];
+ }
+
public function getCurrentDirtyStateAttributes(): array
{
return [
diff --git a/src/Tags/Concerns/QueriesOrderBys.php b/src/Tags/Concerns/QueriesOrderBys.php
index 62cf8dd9f6c..96ca552d6be 100644
--- a/src/Tags/Concerns/QueriesOrderBys.php
+++ b/src/Tags/Concerns/QueriesOrderBys.php
@@ -32,7 +32,7 @@ protected function parseOrderBys()
return collect(explode('|', $piped ?? ''))->filter()->map(function ($orderBy) {
return OrderBy::parse($orderBy);
- });
+ })->filter(fn ($orderBy) => OrderBy::column($orderBy->sort));
}
protected function preParsedOrderBys()
diff --git a/src/Taxonomies/LocalizedTerm.php b/src/Taxonomies/LocalizedTerm.php
index c42aa34bc4f..6c2cd6af72b 100644
--- a/src/Taxonomies/LocalizedTerm.php
+++ b/src/Taxonomies/LocalizedTerm.php
@@ -31,7 +31,6 @@
use Statamic\Facades\Site;
use Statamic\GraphQL\ResolvesValues;
use Statamic\Http\Responses\DataResponse;
-use Statamic\Revisions\Revisable;
use Statamic\Routing\Routable;
use Statamic\Search\Searchable;
use Statamic\Statamic;
@@ -39,7 +38,7 @@
class LocalizedTerm implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, ContainsQueryableValues, Localization, Protectable, ResolvesValuesContract, Responsable, SearchableContract, Term
{
- use ContainsSupplementalData, HasAugmentedInstance, Publishable, ResolvesValues, Revisable, Routable, Searchable, TracksLastModified, TracksQueriedColumns, TracksQueriedRelations;
+ use ContainsSupplementalData, HasAugmentedInstance, Publishable, ResolvesValues, Routable, Searchable, TracksLastModified, TracksQueriedColumns, TracksQueriedRelations;
protected $locale;
protected $term;
@@ -235,41 +234,6 @@ public function entriesCount()
});
}
- protected function revisionKey()
- {
- return vsprintf('taxonomies/%s/%s/%s', [
- $this->taxonomyHandle(),
- $this->locale(),
- $this->slug(),
- ]);
- }
-
- protected function revisionAttributes()
- {
- return [
- 'id' => $this->id(),
- 'slug' => $this->slug(),
- 'published' => $this->published(),
- 'data' => $this->data()->except(['updated_by', 'updated_at'])->all(),
- ];
- }
-
- public function makeFromRevision($revision)
- {
- $entry = clone $this;
-
- if (! $revision) {
- return $entry;
- }
-
- $attrs = $revision->attributes();
-
- return $entry
- ->published($attrs['published'])
- ->data($attrs['data'])
- ->slug($attrs['slug']);
- }
-
public function origin()
{
return $this->inDefaultLocale();
@@ -289,15 +253,10 @@ public function locale($locale = null)
return $this->locale;
}
+ /** @deprecated */
public function revisionsEnabled($enabled = null)
{
- if (func_num_args() === 0) {
- return $this->term->revisionsEnabled();
- }
-
- $this->term->revisionsEnabled($enabled);
-
- return $this;
+ return func_num_args() === 0 ? false : $this;
}
public function editUrl()
@@ -320,21 +279,6 @@ public function unpublishUrl()
return $this->cpUrl('taxonomies.terms.published.destroy');
}
- public function revisionsUrl()
- {
- return $this->cpUrl('taxonomies.terms.revisions.index');
- }
-
- public function createRevisionUrl()
- {
- return $this->cpUrl('taxonomies.terms.revisions.store');
- }
-
- public function restoreRevisionUrl()
- {
- return $this->cpUrl('taxonomies.terms.restore-revision');
- }
-
public function livePreviewUrl()
{
return $this->cpUrl('taxonomies.terms.preview.edit');
@@ -541,7 +485,7 @@ public function repository()
public function getQueryableValue(string $field)
{
- if (method_exists($this, $method = Str::camel($field))) {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
return $this->{$method}();
}
@@ -554,6 +498,16 @@ public function getQueryableValue(string $field)
return $field->fieldtype()->toQueryableValue($value);
}
+ private function queryableMethods(): array
+ {
+ return [
+ 'absoluteUrl', 'apiUrl', 'blueprint', 'editUrl', 'entriesCount', 'hasOrigin', 'id', 'isRedirect',
+ 'isRoot', 'lastModified', 'lastModifiedBy', 'layout', 'locale', 'path', 'private', 'published',
+ 'redirectUrl', 'site', 'slug', 'status', 'taxonomy', 'taxonomyHandle', 'template', 'title',
+ 'uri', 'url', 'urlWithoutRedirect', 'values',
+ ];
+ }
+
public function getCpSearchResultBadge()
{
return $this->taxonomy()->title();
diff --git a/src/Taxonomies/Taxonomy.php b/src/Taxonomies/Taxonomy.php
index ae1a078698a..0acb6ee7096 100644
--- a/src/Taxonomies/Taxonomy.php
+++ b/src/Taxonomies/Taxonomy.php
@@ -6,6 +6,7 @@
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Responsable;
use Statamic\Contracts\Data\Augmentable as AugmentableContract;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Taxonomies\Taxonomy as Contract;
use Statamic\Data\ContainsCascadingData;
use Statamic\Data\ContainsSupplementalData;
@@ -27,13 +28,12 @@
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Facades\URL;
-use Statamic\Statamic;
use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
use function Statamic\trans as __;
-class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, Contract, Responsable
+class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, ContainsQueryableValues, Contract, Responsable
{
use ContainsCascadingData, ContainsSupplementalData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedData;
@@ -43,7 +43,6 @@ class Taxonomy implements Arrayable, ArrayAccess, AugmentableContract, Contract,
protected $sites = [];
protected $collection;
protected $defaultPublishState = true;
- protected $revisions = false;
protected $searchIndex;
protected $previewTargets = [];
protected $template;
@@ -322,20 +321,10 @@ public function sites($sites = null)
->args(func_get_args());
}
+ /** @deprecated */
public function revisionsEnabled($enabled = null)
{
- return $this
- ->fluentlyGetOrSet('revisions')
- ->getter(function ($enabled) {
- if (! config('statamic.revisions.enabled') || ! Statamic::pro()) {
- return false;
- }
-
- return false; // TODO
-
- return $enabled;
- })
- ->args(func_get_args());
+ return func_num_args() === 0 ? false : $this;
}
public function url()
@@ -580,4 +569,22 @@ public function hasCustomTermTemplate()
{
return $this->termTemplate !== null;
}
+
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return $this->get($field);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'absoluteUrl', 'collection', 'collections', 'defaultPublishState', 'editUrl', 'handle',
+ 'hasSearchIndex', 'id', 'layout', 'path', 'revisionsEnabled', 'searchIndex', 'sites',
+ 'sortDirection', 'sortField', 'template', 'termTemplate', 'title', 'uri', 'url',
+ ];
+ }
}
diff --git a/src/Taxonomies/Term.php b/src/Taxonomies/Term.php
index 45438958ac1..61a265f151e 100644
--- a/src/Taxonomies/Term.php
+++ b/src/Taxonomies/Term.php
@@ -2,6 +2,7 @@
namespace Statamic\Taxonomies;
+use Statamic\Contracts\Query\ContainsQueryableValues;
use Statamic\Contracts\Taxonomies\Term as TermContract;
use Statamic\Data\ExistsAsFile;
use Statamic\Data\HasDirtyState;
@@ -20,7 +21,7 @@
use Statamic\Support\Str;
use Statamic\Support\Traits\FluentlyGetsAndSets;
-class Term implements TermContract
+class Term implements ContainsQueryableValues, TermContract
{
use ExistsAsFile, FluentlyGetsAndSets, HasDirtyState;
@@ -278,9 +279,10 @@ public function reference()
return "term::{$this->id()}";
}
+ /** @deprecated */
public function revisionsEnabled()
{
- return $this->taxonomy()->revisionsEnabled();
+ return false;
}
public function dataForLocale($locale, $data = null)
@@ -301,6 +303,23 @@ public function set($key, $value)
return $this;
}
+ public function getQueryableValue(string $field)
+ {
+ if (in_array($method = Str::camel($field), $this->queryableMethods())) {
+ return $this->{$method}();
+ }
+
+ return $this->inDefaultLocale()->getQueryableValue($field);
+ }
+
+ private function queryableMethods(): array
+ {
+ return [
+ 'blueprint', 'collection', 'entriesCount', 'id', 'path', 'reference',
+ 'slug', 'taxonomy', 'taxonomyHandle', 'title',
+ ];
+ }
+
public function getCurrentDirtyStateAttributes(): array
{
return array_merge([
diff --git a/src/Testing/AddonTestCase.php b/src/Testing/AddonTestCase.php
index 7d7d3faf185..7f463cff34e 100644
--- a/src/Testing/AddonTestCase.php
+++ b/src/Testing/AddonTestCase.php
@@ -32,12 +32,10 @@ protected function setUp(): void
$this->preventSavingStacheItemsToDisk();
}
- Version::shouldReceive('get')->zeroOrMoreTimes()->andReturn(Composer::create(__DIR__.'/../')->installedVersion(Statamic::PACKAGE));
- $this->addToAssertionCount(-1);
+ Version::shouldReceive('get')->andReturn(Composer::create(__DIR__.'/../')->installedVersion(Statamic::PACKAGE));
- \Statamic\Facades\CP\Nav::shouldReceive('build')->zeroOrMoreTimes()->andReturn(collect());
- \Statamic\Facades\CP\Nav::shouldReceive('clearCachedUrls')->zeroOrMoreTimes();
- $this->addToAssertionCount(-2); // Dont want to assert this
+ \Statamic\Facades\CP\Nav::shouldReceive('build')->andReturn(collect());
+ \Statamic\Facades\CP\Nav::shouldReceive('clearCachedUrls');
}
protected function tearDown(): void
diff --git a/src/Tokens/FileTokenRepository.php b/src/Tokens/FileTokenRepository.php
index 94a9caee9d9..044112fba8f 100644
--- a/src/Tokens/FileTokenRepository.php
+++ b/src/Tokens/FileTokenRepository.php
@@ -22,7 +22,15 @@ public function find(string $token): ?TokenContract
return null;
}
- return $this->makeFromPath($path);
+ $token = $this->makeFromPath($path);
+
+ if ($token->hasExpired()) {
+ $this->delete($token);
+
+ return null;
+ }
+
+ return $token;
}
public function save(TokenContract $token): bool
diff --git a/src/View/Antlers/Language/Utilities/StringUtilities.php b/src/View/Antlers/Language/Utilities/StringUtilities.php
index 997b17182ac..f114e64344e 100644
--- a/src/View/Antlers/Language/Utilities/StringUtilities.php
+++ b/src/View/Antlers/Language/Utilities/StringUtilities.php
@@ -79,15 +79,10 @@ public static function containsSymbolicCharacters($text)
*/
public static function sanitizePhp($text)
{
- $text = str_replace('save();
+ tap(Facades\Entry::make()->collection('pages')->id('dance')->published(false)->set('title', 'Dance')->slug('dance'))->save();
+ $otherEntry = tap(Facades\Entry::make()->collection('pages')->id('sing')->published(true)->set('title', 'Sing')->slug('sing'))->save();
+
+ LivePreview::tokenize('test-token', $otherEntry);
+
+ $this->get('/api/collections/pages/entries/dance?token=test-token')->assertJson([
+ 'message' => 'Not found.',
+ ]);
+ }
+
#[Test]
public function it_replaces_terms_using_live_preview_token()
{
diff --git a/tests/Antlers/ContentAllowlistConfigTest.php b/tests/Antlers/ContentAllowlistConfigTest.php
new file mode 100644
index 00000000000..20292776189
--- /dev/null
+++ b/tests/Antlers/ContentAllowlistConfigTest.php
@@ -0,0 +1,109 @@
+assertContains('trans:*', GlobalRuntimeState::$allowedContentTagPaths);
+ $this->assertContains('lower', GlobalRuntimeState::$allowedContentModifierPaths);
+ }
+
+ public function test_null_allowed_content_config_uses_statamic_defaults()
+ {
+ config([
+ 'statamic.antlers.allowedContentTags' => null,
+ 'statamic.antlers.allowedContentModifiers' => null,
+ ]);
+
+ app(ParserContract::class);
+
+ $tags = GlobalRuntimeState::$allowedContentTagPaths;
+ $modifiers = GlobalRuntimeState::$allowedContentModifierPaths;
+
+ $this->assertContains('trans:*', $tags);
+ $this->assertGreaterThanOrEqual(4, count($tags));
+ $this->assertContains('lower', $modifiers);
+ $this->assertGreaterThanOrEqual(150, count($modifiers));
+ }
+
+ public function test_empty_array_allows_no_content_tags_or_modifiers()
+ {
+ config([
+ 'statamic.antlers.allowedContentTags' => [],
+ 'statamic.antlers.allowedContentModifiers' => [],
+ ]);
+
+ app(ParserContract::class);
+
+ $this->assertSame([], GlobalRuntimeState::$allowedContentTagPaths);
+ $this->assertSame([], GlobalRuntimeState::$allowedContentModifierPaths);
+ }
+
+ public function test_at_default_expands_core_tags_and_keeps_custom_patterns()
+ {
+ config(['statamic.antlers.allowedContentTags' => ['@default', 'custom_allowlist_tag:*']]);
+
+ app(ParserContract::class);
+
+ $allowed = GlobalRuntimeState::$allowedContentTagPaths;
+
+ $this->assertContains('trans:*', $allowed);
+ $this->assertContains('custom_allowlist_tag:*', $allowed);
+ }
+
+ public function test_at_default_expands_core_modifiers_and_keeps_custom_handles()
+ {
+ config(['statamic.antlers.allowedContentModifiers' => ['@default', 'custom_allowlist_modifier']]);
+
+ app(ParserContract::class);
+
+ $allowed = GlobalRuntimeState::$allowedContentModifierPaths;
+
+ $this->assertContains('lower', $allowed);
+ $this->assertContains('custom_allowlist_modifier', $allowed);
+ }
+
+ public function test_config_without_at_default_replaces_tag_allowlist()
+ {
+ config(['statamic.antlers.allowedContentTags' => ['only_custom:*']]);
+
+ app(ParserContract::class);
+
+ $allowed = GlobalRuntimeState::$allowedContentTagPaths;
+
+ $this->assertSame(['only_custom:*'], $allowed);
+ $this->assertNotContains('trans:*', $allowed);
+ }
+
+ public function test_config_without_at_default_replaces_modifier_allowlist()
+ {
+ config(['statamic.antlers.allowedContentModifiers' => ['only_custom_modifier']]);
+
+ app(ParserContract::class);
+
+ $allowed = GlobalRuntimeState::$allowedContentModifierPaths;
+
+ $this->assertSame(['only_custom_modifier'], $allowed);
+ $this->assertNotContains('lower', $allowed);
+ }
+
+ public function test_at_default_deduplicates_expanded_lists()
+ {
+ config(['statamic.antlers.allowedContentTags' => ['@default', '@default', 'dedupe_me:*']]);
+
+ app(ParserContract::class);
+
+ $allowed = GlobalRuntimeState::$allowedContentTagPaths;
+ $counts = array_count_values($allowed);
+
+ $this->assertSame(1, $counts['dedupe_me:*'] ?? 0);
+ $this->assertSame(1, $counts['trans:*'] ?? 0);
+ }
+}
diff --git a/tests/Antlers/Runtime/PhpEnabledTest.php b/tests/Antlers/Runtime/PhpEnabledTest.php
index 63f02d46296..5f31dda431c 100644
--- a/tests/Antlers/Runtime/PhpEnabledTest.php
+++ b/tests/Antlers/Runtime/PhpEnabledTest.php
@@ -608,4 +608,19 @@ public function test_disabled_php_node_inside_user_values()
GlobalRuntimeState::$allowPhpInContent = false;
}
+
+ public function test_sanitize_php_is_case_insensitive()
+ {
+ $this->assertSame('<?php echo "test"; ?>', StringUtilities::sanitizePhp(''));
+ $this->assertSame('<?PHP echo "test"; ?>', StringUtilities::sanitizePhp(''));
+ $this->assertSame('<?Php echo "test"; ?>', StringUtilities::sanitizePhp(''));
+ $this->assertSame('<?pHp echo "test"; ?>', StringUtilities::sanitizePhp(''));
+ }
+
+ public function test_sanitize_php_handles_short_tags()
+ {
+ $this->assertSame('<?= $var ?>', StringUtilities::sanitizePhp('= $var ?>'));
+ $this->assertSame('<?="test"?>', StringUtilities::sanitizePhp('="test"?>'));
+ $this->assertSame("<? echo 'test' ?>", StringUtilities::sanitizePhp(" echo 'test' ?>"));
+ }
}
diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php
index 69646d24022..a2fc3d7ea9b 100644
--- a/tests/Assets/AssetTest.php
+++ b/tests/Assets/AssetTest.php
@@ -2180,6 +2180,76 @@ public function cannot_reupload_a_file_with_a_different_extension()
Event::assertNotDispatched(AssetSaved::class);
}
+ #[Test]
+ public function it_sanitizes_svgs_on_reupload()
+ {
+ Event::fake();
+
+ // Create and upload an initial clean SVG
+ $asset = (new Asset)->container($this->container)->path('path/to/asset.svg')->syncOriginal();
+ Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container);
+ $asset->upload(UploadedFile::fake()->createWithContent('asset.svg', ''));
+ Storage::disk('test')->assertExists('path/to/asset.svg');
+
+ // Place a malicious SVG in the local disk for reupload
+ $uploadDisk = Storage::fake('local');
+ $maliciousSvg = '';
+ $uploadDisk->put('path/to/malicious.svg', $maliciousSvg);
+ $uploadDisk->assertExists('path/to/malicious.svg');
+
+ $file = new ReplacementFile('path/to/malicious.svg');
+
+ $return = $asset->reupload($file);
+
+ $this->assertEquals($asset, $return);
+ Storage::disk('test')->assertExists('path/to/asset.svg');
+
+ // Ensure the inline scripts were stripped out
+ $this->assertStringNotContainsString('', $asset->contents());
+
+ Event::assertDispatched(AssetReuploaded::class, function ($event) use ($asset) {
+ return $event->asset->id() === $asset->id();
+ });
+ }
+
+ #[Test]
+ public function it_does_not_sanitize_svgs_on_reupload_when_behaviour_is_disabled()
+ {
+ Event::fake();
+
+ config()->set('statamic.assets.svg_sanitization_on_upload', false);
+
+ // Create and upload an initial clean SVG
+ $asset = (new Asset)->container($this->container)->path('path/to/asset.svg')->syncOriginal();
+ Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container);
+ $asset->upload(UploadedFile::fake()->createWithContent('asset.svg', ''));
+ Storage::disk('test')->assertExists('path/to/asset.svg');
+
+ // Place a malicious SVG in the local disk for reupload
+ $uploadDisk = Storage::fake('local');
+ $maliciousSvg = '';
+ $uploadDisk->put('path/to/malicious.svg', $maliciousSvg);
+ $uploadDisk->assertExists('path/to/malicious.svg');
+
+ $file = new ReplacementFile('path/to/malicious.svg');
+
+ $return = $asset->reupload($file);
+
+ $this->assertEquals($asset, $return);
+ Storage::disk('test')->assertExists('path/to/asset.svg');
+
+ // Ensure the inline scripts were NOT stripped out when disabled
+ $this->assertStringContainsString('', $asset->contents());
+
+ Event::assertDispatched(AssetReuploaded::class, function ($event) use ($asset) {
+ return $event->asset->id() === $asset->id();
+ });
+ }
+
#[Test]
public function it_doesnt_lowercase_uploaded_filenames_when_configured()
{
diff --git a/tests/Auth/CpForgotPasswordTest.php b/tests/Auth/CpForgotPasswordTest.php
new file mode 100644
index 00000000000..0e48544b5e9
--- /dev/null
+++ b/tests/Auth/CpForgotPasswordTest.php
@@ -0,0 +1,83 @@
+post(cp_route('password.email'), [
+ 'email' => 'nobody@example.com',
+ ])
+ ->assertSessionHasNoErrors()
+ ->assertSessionHas('user.forgot_password.success', __(Password::RESET_LINK_SENT))
+ ->assertSessionHas('status', __(Password::RESET_LINK_SENT));
+
+ Notification::assertNothingSent();
+ }
+
+ #[Test]
+ public function it_returns_generic_success_for_throttled_user_to_prevent_enumeration()
+ {
+ Notification::fake();
+
+ $throttled = new class
+ {
+ public function sendResetLink()
+ {
+ return Password::RESET_THROTTLED;
+ }
+ };
+
+ Password::shouldReceive('broker')->andReturn($throttled);
+
+ User::make()
+ ->email('san@holo.com')
+ ->password('chewy')
+ ->save();
+
+ $this
+ ->post(cp_route('password.email'), [
+ 'email' => 'san@holo.com',
+ ])
+ ->assertSessionHasNoErrors()
+ ->assertSessionHas('user.forgot_password.success', __(Password::RESET_LINK_SENT))
+ ->assertSessionHas('status', __(Password::RESET_LINK_SENT));
+
+ Notification::assertNothingSent();
+ }
+
+ #[Test]
+ public function it_still_errors_on_invalid_email_format()
+ {
+ $this
+ ->post(cp_route('password.email'), [
+ 'email' => 'not-an-email',
+ ])
+ ->assertSessionHasErrors('email', null, 'user.forgot_password');
+ }
+}
diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php
index 38d35f835d3..94c50b79ce1 100644
--- a/tests/Auth/ForgotPasswordTest.php
+++ b/tests/Auth/ForgotPasswordTest.php
@@ -5,6 +5,7 @@
use Illuminate\Support\Facades\Password;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
+use Statamic\Auth\Passwords\PasswordReset;
use Statamic\Facades\User;
use Tests\Facades\Concerns\ProvidesExternalUrls;
use Tests\PreventSavingStacheItemsToDisk;
@@ -22,35 +23,146 @@ protected function resolveApplicationConfiguration($app)
$app['config']->set('app.url', 'http://absolute-url-resolved-from-request.com');
}
- #[Test]
- #[DataProvider('externalUrlProvider')]
- public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal)
+ public function setUp(): void
{
+ parent::setUp();
+
+ PasswordReset::resetFormUrl(null);
+
$this->setSites([
'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'],
'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'],
'c' => ['name' => 'C', 'locale' => 'fr_FR', 'url' => '/fr/'],
]);
+ }
+ #[Test]
+ public function it_accepts_encrypted_reset_url_when_sending_reset_link_email()
+ {
$this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
- User::make()
- ->email('san@holo.com')
- ->password('chewy')
- ->save();
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => encrypt('http://this-site.com/some-path'),
+ ])->assertSessionHasNoErrors();
+ $this->assertEquals('http://this-site.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets'));
+ }
- $response = $this->post('/!/auth/password/email', [
+ #[Test]
+ public function it_accepts_unencrypted_relative_reset_url_when_sending_reset_link_email()
+ {
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => '/some-path',
+ ])->assertSessionHasNoErrors();
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets'));
+ }
+
+ #[Test]
+ #[DataProvider('externalResetUrlProvider')]
+ public function it_rejects_unencrypted_external_reset_url_when_sending_reset_link_email($url)
+ {
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_reset_url' => $url,
- ]);
+ ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
+
+ #[Test]
+ public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_reset_link_email()
+ {
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => 'http://this-site.com/some-path',
+ ])->assertSessionHasNoErrors();
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
+
+ #[Test]
+ public function it_rejects_unencrypted_relative_reset_url_with_control_characters_when_sending_reset_link_email()
+ {
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => "/some-path\r\nLocation: https://evil.com",
+ ])->assertSessionHasNoErrors();
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
+
+ #[Test]
+ public function it_rejects_reset_url_longer_than_2048_characters_when_sending_reset_link_email()
+ {
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => '/'.str_repeat('a', 2048),
+ ])->assertSessionHasNoErrors();
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
- if ($isExternal) {
- $response->assertSessionHasErrors(['_reset_url']);
+ #[Test]
+ public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email()
+ {
+ // Unencrypted string that doesn't look like a URL is probably a tampered encrypted string.
+ // It might be a relative url without a leading slash, but we won't treat it as that.
- return;
- }
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
- $response->assertSessionHasNoErrors();
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => 'not-an-encrypted-string',
+ ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
+
+ #[Test]
+ #[DataProvider('externalResetUrlProvider')]
+ public function it_rejects_encrypted_external_reset_url_when_sending_reset_link_email($url)
+ {
+ // It's weird to point to an external URL, even if you encrypt it yourself.
+ // This is an additional safeguard.
+
+ $this->simulateSuccessfulPasswordResetEmail();
+ $this->createUser();
+
+ $this->post('/!/auth/password/email', [
+ 'email' => 'san@holo.com',
+ '_reset_url' => encrypt($url),
+ ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url.
+ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets'));
+ }
+
+ public static function externalResetUrlProvider()
+ {
+ $keyFn = function ($key) {
+ return is_null($key) ? 'null' : $key;
+ };
+
+ return collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url]])->all();
+ }
+
+ private function createUser(): void
+ {
+ User::make()
+ ->email('san@holo.com')
+ ->password('chewy')
+ ->save();
}
#[Test]
diff --git a/tests/Auth/Protect/PasswordProtectionTest.php b/tests/Auth/Protect/PasswordProtectionTest.php
index 9d998e05cdd..1efda28c8cf 100644
--- a/tests/Auth/Protect/PasswordProtectionTest.php
+++ b/tests/Auth/Protect/PasswordProtectionTest.php
@@ -3,8 +3,11 @@
namespace Tests\Auth\Protect;
use Facades\Statamic\Auth\Protect\Protectors\Password\Token;
+use Facades\Statamic\CP\LivePreview;
+use Facades\Tests\Factories\EntryFactory;
use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\Test;
+use Statamic\Facades\Entry;
class PasswordProtectionTest extends PageProtectionTestCase
{
@@ -124,4 +127,44 @@ public function custom_password_form_url_is_unprotected()
->assertOk()
->assertSee('Password form template');
}
+
+ #[Test]
+ public function live_preview_token_bypasses_password_protection()
+ {
+ config(['statamic.protect.schemes.password-scheme' => [
+ 'driver' => 'password',
+ 'allowed' => ['test'],
+ ]]);
+
+ $this->createPage('test', ['data' => ['protect' => 'password-scheme']]);
+
+ $entry = Entry::find('test');
+
+ LivePreview::tokenize('test-token', $entry);
+
+ $this
+ ->get('/test?token=test-token')
+ ->assertOk();
+ }
+
+ #[Test]
+ public function live_preview_token_for_different_entry_doesnt_bypass_password_protection()
+ {
+ config(['statamic.protect.schemes.password-scheme' => [
+ 'driver' => 'password',
+ 'allowed' => ['test'],
+ ]]);
+
+ $this->createPage('test', ['data' => ['protect' => 'password-scheme']]);
+
+ $other = EntryFactory::slug('other')->id('other')->collection('pages')->create();
+
+ LivePreview::tokenize('test-token', $other);
+
+ Token::shouldReceive('generate')->andReturn('pw-token');
+
+ $this
+ ->get('/test?token=test-token')
+ ->assertRedirect();
+ }
}
diff --git a/tests/Data/Assets/AssetQueryBuilderTest.php b/tests/Data/Assets/AssetQueryBuilderTest.php
index 7513bc9489b..15e8ee4495b 100644
--- a/tests/Data/Assets/AssetQueryBuilderTest.php
+++ b/tests/Data/Assets/AssetQueryBuilderTest.php
@@ -715,6 +715,17 @@ public function values_can_be_plucked()
'f.jpg',
], $this->container->queryAssets()->where('extension', 'jpg')->pluck('path')->all());
}
+
+ #[Test]
+ public function sorting_by_unsafe_method_does_not_invoke_it()
+ {
+ $count = $this->container->assets()->count();
+ $this->assertGreaterThan(0, $count);
+
+ $this->container->queryAssets()->orderBy('delete', 'asc')->get();
+
+ $this->assertCount($count, $this->container->assets());
+ }
}
class CustomScope extends Scope
diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php
index cfaf8b4c2e9..bb86537a731 100644
--- a/tests/Data/Entries/EntryQueryBuilderTest.php
+++ b/tests/Data/Entries/EntryQueryBuilderTest.php
@@ -1270,6 +1270,19 @@ public function exists_returns_false_when_no_results_are_found()
{
$this->assertFalse(Entry::query()->exists());
}
+
+ #[Test]
+ public function sorting_by_unsafe_method_does_not_invoke_it()
+ {
+ $this->createDummyCollectionAndEntries();
+
+ $count = Entry::all()->count();
+ $this->assertGreaterThan(0, $count);
+
+ Entry::query()->orderBy('delete', 'asc')->get();
+
+ $this->assertCount($count, Entry::all());
+ }
}
class CustomScope extends Scope
diff --git a/tests/Data/Taxonomies/TermQueryBuilderTest.php b/tests/Data/Taxonomies/TermQueryBuilderTest.php
index 5d1afe80f8e..3537522fbe4 100644
--- a/tests/Data/Taxonomies/TermQueryBuilderTest.php
+++ b/tests/Data/Taxonomies/TermQueryBuilderTest.php
@@ -775,6 +775,21 @@ public function terms_are_found_using_where_relation()
$this->assertCount(1, $terms);
$this->assertEquals(['c'], $terms->map->slug->all());
}
+
+ #[Test]
+ public function sorting_by_unsafe_method_does_not_invoke_it()
+ {
+ Taxonomy::make('tags')->save();
+ Term::make('a')->taxonomy('tags')->data(['title' => 'Alpha'])->save();
+ Term::make('b')->taxonomy('tags')->data(['title' => 'Bravo'])->save();
+
+ $count = Term::all()->count();
+ $this->assertGreaterThan(0, $count);
+
+ Term::query()->orderBy('delete', 'asc')->get();
+
+ $this->assertCount($count, Term::all());
+ }
}
class CustomScope extends Scope
diff --git a/tests/Data/Users/UserQueryBuilderTest.php b/tests/Data/Users/UserQueryBuilderTest.php
index 2ce099cb07b..039807a3575 100644
--- a/tests/Data/Users/UserQueryBuilderTest.php
+++ b/tests/Data/Users/UserQueryBuilderTest.php
@@ -496,6 +496,20 @@ public function users_are_found_using_scopes()
$this->assertCount(1, User::query()->customScope(['email' => 'gandalf@precious.com'])->get());
$this->assertCount(1, User::query()->whereCustom(['email' => 'gandalf@precious.com'])->get());
}
+
+ #[Test]
+ public function sorting_by_unsafe_method_does_not_invoke_it()
+ {
+ User::make()->email('a@example.com')->data(['name' => 'Alpha'])->save();
+ User::make()->email('b@example.com')->data(['name' => 'Bravo'])->save();
+
+ $count = User::all()->count();
+ $this->assertGreaterThan(0, $count);
+
+ User::query()->orderBy('delete', 'asc')->get();
+
+ $this->assertCount($count, User::all());
+ }
}
class CustomScope extends Scope
diff --git a/tests/Dictionaries/FileTest.php b/tests/Dictionaries/FileTest.php
index cf5f45b8a4b..ad60a35f38b 100644
--- a/tests/Dictionaries/FileTest.php
+++ b/tests/Dictionaries/FileTest.php
@@ -2,6 +2,7 @@
namespace Tests\Dictionaries;
+use League\Flysystem\PathTraversalDetected;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Dictionaries\File;
@@ -180,4 +181,15 @@ public function it_gets_array_from_value()
'emoji' => '🍌',
], $item->data());
}
+
+ #[Test]
+ public function path_traversal_not_allowed()
+ {
+ $this->expectException(PathTraversalDetected::class);
+ $this->expectExceptionMessage('Path traversal detected: ../secret.json');
+
+ (new File)
+ ->setConfig(['filename' => '../secret.json'])
+ ->options();
+ }
}
diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php
index 867aaf15407..4c0745c24d0 100644
--- a/tests/Facades/Concerns/ProvidesExternalUrls.php
+++ b/tests/Facades/Concerns/ProvidesExternalUrls.php
@@ -4,69 +4,148 @@
trait ProvidesExternalUrls
{
- public static function externalUrlProvider()
+ private static function internalUrls()
+ {
+ return [
+ 'http://this-site.com',
+ 'http://this-site.com?foo',
+ 'http://this-site.com#anchor',
+ 'http://this-site.com/',
+ 'http://this-site.com/?foo',
+ 'http://this-site.com/#anchor',
+
+ 'http://subdomain.this-site.com',
+ 'http://subdomain.this-site.com/',
+ 'http://subdomain.this-site.com/?foo',
+ 'http://subdomain.this-site.com/#anchor',
+ 'http://subdomain.this-site.com/some-slug',
+ 'http://subdomain.this-site.com/some-slug?foo',
+ 'http://subdomain.this-site.com/some-slug#anchor',
+
+ 'http://absolute-url-resolved-from-request.com',
+ 'http://absolute-url-resolved-from-request.com/',
+ 'http://absolute-url-resolved-from-request.com/?foo',
+ 'http://absolute-url-resolved-from-request.com/?anchor',
+ 'http://absolute-url-resolved-from-request.com/some-slug',
+ 'http://absolute-url-resolved-from-request.com/some-slug?foo',
+ 'http://absolute-url-resolved-from-request.com/some-slug#anchor',
+
+ '/',
+ '/?foo',
+ '/#anchor',
+ '/some-slug',
+ '?foo',
+ '#anchor',
+ '',
+ null,
+ ];
+ }
+
+ private static function externalUrls()
{
return [
- ['http://this-site.com', false],
- ['http://this-site.com?foo', false],
- ['http://this-site.com#anchor', false],
- ['http://this-site.com/', false],
- ['http://this-site.com/?foo', false],
- ['http://this-site.com/#anchor', false],
-
- ['http://that-site.com', true],
- ['http://that-site.com/', true],
- ['http://that-site.com/?foo', true],
- ['http://that-site.com/#anchor', true],
- ['http://that-site.com/some-slug', true],
- ['http://that-site.com/some-slug?foo', true],
- ['http://that-site.com/some-slug#anchor', true],
-
- ['http://subdomain.this-site.com', false],
- ['http://subdomain.this-site.com/', false],
- ['http://subdomain.this-site.com/?foo', false],
- ['http://subdomain.this-site.com/#anchor', false],
- ['http://subdomain.this-site.com/some-slug', false],
- ['http://subdomain.this-site.com/some-slug?foo', false],
- ['http://subdomain.this-site.com/some-slug#anchor', false],
-
- ['http://absolute-url-resolved-from-request.com', false],
- ['http://absolute-url-resolved-from-request.com/', false],
- ['http://absolute-url-resolved-from-request.com/?foo', false],
- ['http://absolute-url-resolved-from-request.com/?anchor', false],
- ['http://absolute-url-resolved-from-request.com/some-slug', false],
- ['http://absolute-url-resolved-from-request.com/some-slug?foo', false],
- ['http://absolute-url-resolved-from-request.com/some-slug#anchor', false],
- ['/', false],
- ['/?foo', false],
- ['/#anchor', false],
- ['/some-slug', false],
- ['?foo', false],
- ['#anchor', false],
- ['', false],
- [null, false],
+ 'http://that-site.com',
+ 'http://that-site.com/',
+ 'http://that-site.com/?foo',
+ 'http://that-site.com/#anchor',
+ 'http://that-site.com/some-slug',
+ 'http://that-site.com/some-slug?foo',
+ 'http://that-site.com/some-slug#anchor',
// Protocol-relative URLs are external
- ['//evil.com', true],
- ['//evil.com/', true],
- ['//evil.com/path', true],
- ['//this-site.com', true],
+ '//evil.com',
+ '//evil.com/',
+ '//evil.com/path',
+ '//this-site.com',
// External domain that starts with a valid domain.
- ['http://this-site.com.au', true],
- ['http://this-site.com.au/', true],
- ['http://this-site.com.au/?foo', true],
- ['http://this-site.com.au/#anchor', true],
- ['http://this-site.com.au/some-slug', true],
- ['http://this-site.com.au/some-slug?foo', true],
- ['http://this-site.com.au/some-slug#anchor', true],
- ['http://subdomain.this-site.com.au', true],
- ['http://subdomain.this-site.com.au/', true],
- ['http://subdomain.this-site.com.au/?foo', true],
- ['http://subdomain.this-site.com.au/#anchor', true],
- ['http://subdomain.this-site.com.au/some-slug', true],
- ['http://subdomain.this-site.com.au/some-slug?foo', true],
- ['http://subdomain.this-site.com.au/some-slug#anchor', true],
+ 'http://this-site.com.au',
+ 'http://this-site.com.au/',
+ 'http://this-site.com.au/?foo',
+ 'http://this-site.com.au/#anchor',
+ 'http://this-site.com.au/some-slug',
+ 'http://this-site.com.au/some-slug?foo',
+ 'http://this-site.com.au/some-slug#anchor',
+ 'http://subdomain.this-site.com.au',
+ 'http://subdomain.this-site.com.au/',
+ 'http://subdomain.this-site.com.au/?foo',
+ 'http://subdomain.this-site.com.au/#anchor',
+ 'http://subdomain.this-site.com.au/some-slug',
+ 'http://subdomain.this-site.com.au/some-slug?foo',
+ 'http://subdomain.this-site.com.au/some-slug#anchor',
+
+ // Credential injection
+ 'http://this-site.com@evil.com',
+ 'http://this-site.com@evil.com/',
+ 'http://this-site.com@evil.com/path',
+ 'http://this-site.com@evil.com/path?query',
+ 'http://this-site.com:password@evil.com',
+ 'http://user:pass@evil.com',
+ 'http://absolute-url-resolved-from-request.com@evil.com',
+ 'http://absolute-url-resolved-from-request.com@evil.com/path',
+ 'http://subdomain.this-site.com@evil.com',
+ 'http://subdomain.this-site.com@evil.com/path',
+ 'http://this-site.com:8000@evil.com',
+ 'http://this-site.com:8000@evil.com/path',
+ 'http://this-site.com:8000@webhook.site/token',
+
+ // Backslash bypass
+ 'http://evil.com\@this-site.com',
+ 'http://evil.com\@this-site.com/',
+ 'http://evil.com\@this-site.com/path',
+ 'http://evil.com\@subdomain.this-site.com',
+ 'http://evil.com\@absolute-url-resolved-from-request.com',
+ 'https://evil.com\@this-site.com',
+ 'http://evil.com\\@this-site.com',
+ 'http://evil.com\\\@this-site.com',
+
+ // Percent-encoded backslash bypass
+ 'http://evil.com%5c@this-site.com',
+ 'http://evil.com%5c@this-site.com/',
+ 'http://evil.com%5c@this-site.com/path',
+ 'http://evil.com%5c@subdomain.this-site.com',
+ 'http://evil.com%5c@absolute-url-resolved-from-request.com',
+ 'https://evil.com%5C@this-site.com',
+
+ // Absolute-looking URL with no host (parse_url() returns false)
+ 'http:///path',
+
+ // Percent-encoded whitespace bypass
+ '%20http://evil.com',
+ '%09http://evil.com',
+ '%0ahttp://evil.com',
+
+ // Dangerous URL schemes
+ 'javascript:alert(1)',
+ 'javascript:alert(document.cookie)',
+ 'javascript://this-site.com/%0aalert(1)',
+ 'JAVASCRIPT:alert(1)',
+ 'data:text/html,',
+ 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
+ 'DATA:text/html,test',
+ 'vbscript:msgbox(1)',
+ 'file:///etc/passwd',
+
+ // Whitespace bypass
+ ' http://this-site.com',
+ ' http://evil.com',
+ ' http://evil.com',
+ "\thttp://evil.com",
+ "\nhttp://evil.com",
+ "\rhttp://evil.com",
+ "\r\nhttp://evil.com",
+ ];
+ }
+
+ public static function externalUrlProvider()
+ {
+ $keyFn = function ($key) {
+ return is_null($key) ? 'null' : $key;
+ };
+
+ return [
+ ...collect(static::internalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, false]])->all(),
+ ...collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, true]])->all(),
];
}
}
diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php
index 58201545c5f..93354c678a7 100644
--- a/tests/Facades/UrlTest.php
+++ b/tests/Facades/UrlTest.php
@@ -94,6 +94,18 @@ public function it_determines_if_external_url_to_application($url, $expected)
$this->assertEquals($expected, URL::isExternalToApplication($url));
}
+ #[Test]
+ public function it_does_not_trust_current_request_domain_when_no_sites_are_relative()
+ {
+ $this->setSites([
+ 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'],
+ 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'],
+ ]);
+
+ $this->assertTrue(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/'));
+ $this->assertFalse(URL::isExternalToApplication('http://this-site.com/'));
+ }
+
#[Test]
#[DataProvider('ancestorProvider')]
public function it_checks_whether_a_url_is_an_ancestor_of_another($child, $parent, $isAncestor)
diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php
index d9d032ead25..9d9c5d1e9b3 100644
--- a/tests/Feature/Entries/EntryRevisionsTest.php
+++ b/tests/Feature/Entries/EntryRevisionsTest.php
@@ -46,7 +46,7 @@ public function it_gets_revisions()
$now = Carbon::parse('2017-02-03');
Carbon::setTestNow($now);
$this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
- $this->setTestRoles(['test' => ['access cp', 'publish blog entries']]);
+ $this->setTestRoles(['test' => ['access cp', 'view blog entries', 'publish blog entries']]);
$user = User::make()->id('user-1')->assignRole('test')->save();
$entry = EntryFactory::id('1')
@@ -97,6 +97,109 @@ public function it_gets_revisions()
->assertJsonPath('1.revisions.1.attributes.item_url', 'http://localhost/cp/collections/blog/entries/1/revisions/'.Carbon::parse('2017-02-03')->timestamp);
}
+ #[Test]
+ public function it_denies_access_to_revisions_without_permission_to_view_entry()
+ {
+ $now = Carbon::parse('2017-02-03');
+ Carbon::setTestNow($now);
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('1')
+ ->slug('test')
+ ->collection('blog')
+ ->published(true)
+ ->date('2010-12-25')
+ ->data([
+ 'blueprint' => 'test',
+ 'title' => 'Original title',
+ 'foo' => 'bar',
+ ])->create();
+
+ tap($entry->makeRevision(), function ($copy) {
+ $copy->message('Revision one');
+ $copy->date(Carbon::parse('2017-02-01'));
+ })->save();
+
+ tap($entry->makeRevision(), function ($copy) {
+ $copy->message('Revision two');
+ $copy->date(Carbon::parse('2017-02-03'));
+ })->save();
+
+ tap($entry->makeWorkingCopy(), function ($copy) {
+ $attrs = $copy->attributes();
+ $attrs['data']['title'] = 'Title modified in working copy';
+ $attrs['data']['foo'] = 'baz';
+ $copy->attributes($attrs);
+ })->save();
+
+ $this
+ ->actingAs($user)
+ ->getJson($entry->revisionsUrl())
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function it_denies_access_to_a_specific_revision_without_permission_to_view_entry()
+ {
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('1')
+ ->slug('test')
+ ->collection('blog')
+ ->published(true)
+ ->date('2010-12-25')
+ ->data([
+ 'blueprint' => 'test',
+ 'title' => 'Original title',
+ 'foo' => 'bar',
+ ])->create();
+
+ $revision = tap($entry->makeRevision(), function ($copy) {
+ $copy->message('Revision one');
+ $copy->date(Carbon::parse('2017-02-01'));
+ });
+ $revision->save();
+
+ $this
+ ->actingAs($user)
+ ->getJson($entry->revisionsUrl().'/'.$revision->date()->timestamp)
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function it_views_a_specific_revision()
+ {
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp', 'view blog entries']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('1')
+ ->slug('test')
+ ->collection('blog')
+ ->published(true)
+ ->date('2010-12-25')
+ ->data([
+ 'blueprint' => 'test',
+ 'title' => 'Original title',
+ 'foo' => 'bar',
+ ])->create();
+
+ $revision = tap($entry->makeRevision(), function ($copy) {
+ $copy->message('Revision one');
+ $copy->date(Carbon::parse('2017-02-01'));
+ });
+ $revision->save();
+
+ $this
+ ->actingAs($user)
+ ->getJson($entry->revisionsUrl().'/'.$revision->date()->timestamp)
+ ->assertOk();
+ }
+
#[Test]
public function it_publishes_an_entry()
{
@@ -216,6 +319,36 @@ public function it_unpublishes_an_entry()
$this->assertEquals('unpublish', $revision->action());
}
+ #[Test]
+ public function it_denies_creating_a_revision_without_permission_to_edit_entry()
+ {
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp', 'view blog entries']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('1')
+ ->slug('test')
+ ->collection('blog')
+ ->published(false)
+ ->date('2010-12-25')
+ ->data([
+ 'blueprint' => 'test',
+ 'title' => 'Title',
+ 'foo' => 'bar',
+ ])->create();
+
+ tap($entry->makeWorkingCopy(), function ($copy) {
+ $attrs = $copy->attributes();
+ $attrs['data']['foo'] = 'foo modified in working copy';
+ $copy->attributes($attrs);
+ })->save();
+
+ $this
+ ->actingAs($user)
+ ->postJson($entry->createRevisionUrl(), ['message' => 'Test!'])
+ ->assertForbidden();
+ }
+
#[Test]
public function it_creates_a_revision()
{
@@ -274,6 +407,29 @@ public function it_creates_a_revision()
$this->assertTrue($entry->hasWorkingCopy());
}
+ #[Test]
+ public function it_denies_restoring_a_revision_without_permission_to_edit_entry()
+ {
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp', 'view blog entries']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('123')
+ ->slug('test')
+ ->collection('blog')
+ ->published(false)
+ ->data([
+ 'blueprint' => 'test',
+ 'title' => 'Title',
+ 'foo' => 'bar',
+ ])->create();
+
+ $this
+ ->actingAs($user)
+ ->postJson($entry->restoreRevisionUrl(), ['revision' => '1553546421'])
+ ->assertForbidden();
+ }
+
#[Test]
public function it_restores_a_published_entrys_working_copy_to_another_revision()
{
diff --git a/tests/Feature/Fieldtypes/PreviewMarkdownTest.php b/tests/Feature/Fieldtypes/PreviewMarkdownTest.php
new file mode 100644
index 00000000000..3c51bd4ac34
--- /dev/null
+++ b/tests/Feature/Fieldtypes/PreviewMarkdownTest.php
@@ -0,0 +1,57 @@
+postJson(cp_route('markdown.preview'), $payload);
+ }
+
+ #[Test]
+ public function it_parses_markdown()
+ {
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $this
+ ->actingAs($user)
+ ->request(['config' => ['type' => 'markdown'], 'value' => '**Hello**'])
+ ->assertContent("Hello
\n");
+ }
+
+ #[Test]
+ public function it_aborts_for_non_markdown()
+ {
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $this
+ ->actingAs($user)
+ ->request(['config' => ['type' => 'text'], 'value' => '**Hello**'])
+ ->assertBadRequest()
+ ->assertJson(['message' => 'Bad Request']);
+ }
+
+ #[Test]
+ public function it_denies_access_without_control_panel_permission()
+ {
+ $this->setTestRoles(['test' => []]);
+ $user = User::make()->assignRole('test')->save();
+
+ $this
+ ->actingAs($user)
+ ->request(['config' => ['type' => 'markdown'], 'value' => '**Hello**'])
+ ->assertForbidden();
+ }
+}
diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php
index 30cf1ed8795..3bb3c04d1a4 100644
--- a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php
+++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php
@@ -5,6 +5,8 @@
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
+use Statamic\Facades\Taxonomy;
+use Statamic\Facades\Term;
use Statamic\Facades\User;
use Statamic\Query\Scopes\Scope;
use Tests\FakesRoles;
@@ -35,7 +37,7 @@ public function it_filters_entries_by_query_scopes()
Entry::make()->collection('test')->slug('cherry')->data(['title' => 'Cherry'])->save();
Entry::make()->collection('test')->slug('banana')->data(['title' => 'Banana'])->save();
- $this->setTestRoles(['test' => ['access cp']]);
+ $this->setTestRoles(['test' => ['access cp', 'view test entries']]);
$user = User::make()->assignRole('test')->save();
$config = base64_encode(json_encode([
@@ -46,7 +48,7 @@ public function it_filters_entries_by_query_scopes()
$response = $this
->actingAs($user)
- ->get("/cp/fieldtypes/relationship?config={$config}&collections[0]=test")
+ ->get("/cp/fieldtypes/relationship?config={$config}")
->assertOk();
$titles = collect($response->json('data'))->pluck('title')->all();
@@ -57,6 +59,134 @@ public function it_filters_entries_by_query_scopes()
$this->assertNotContains('Apple', $titles);
$this->assertNotContains('Banana', $titles);
}
+
+ #[Test]
+ public function it_limits_access_to_entries_from_collections_the_user_can_view()
+ {
+ Collection::make('pages')->save();
+ Entry::make()->collection('pages')->slug('home')->data(['title' => 'Home'])->save();
+
+ Collection::make('secret')->save();
+ Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save();
+
+ $this->setTestRoles(['test' => ['access cp', 'view pages entries']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $config = base64_encode(json_encode([
+ 'type' => 'entries',
+ 'collections' => ['pages', 'secret'],
+ ]));
+
+ $this
+ ->actingAs($user)
+ ->getJson("/cp/fieldtypes/relationship?config={$config}")
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJson([
+ 'data' => [
+ ['slug' => 'home'],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function it_denies_access_to_entries_when_user_cannot_view_any_of_the_collections()
+ {
+ Collection::make('pages')->save();
+ Entry::make()->collection('pages')->slug('home')->data(['title' => 'Home'])->save();
+
+ Collection::make('secret')->save();
+ Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save();
+
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $config = base64_encode(json_encode([
+ 'type' => 'entries',
+ 'collections' => ['pages', 'secret'],
+ ]));
+
+ $this
+ ->actingAs($user)
+ ->getJson("/cp/fieldtypes/relationship?config={$config}")
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function it_forbids_access_to_entries_when_filters_target_collections_the_user_cannot_view()
+ {
+ Collection::make('secret')->save();
+ Entry::make()->collection('test')->slug('apple')->data(['title' => 'Apple'])->save();
+ Entry::make()->collection('secret')->slug('secret-one')->data(['title' => 'Secret One'])->save();
+
+ $this->setTestRoles([
+ 'test' => ['access cp', 'view test entries'],
+ ]);
+ $user = User::make()->assignRole('test')->save();
+
+ $config = base64_encode(json_encode([
+ 'type' => 'entries',
+ 'collections' => ['test'],
+ ]));
+ $filters = base64_encode(json_encode([
+ 'collection' => ['collections' => ['secret']],
+ ]));
+
+ $this
+ ->actingAs($user)
+ ->getJson("/cp/fieldtypes/relationship?config={$config}&filters={$filters}")
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function it_limits_access_to_terms_from_taxonomies_the_user_can_view()
+ {
+ Taxonomy::make('topics')->save();
+ Taxonomy::make('secret')->save();
+ Term::make('public')->taxonomy('topics')->data([])->save();
+ Term::make('internal')->taxonomy('secret')->data([])->save();
+
+ $this->setTestRoles(['test' => ['access cp', 'view topics terms']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $config = base64_encode(json_encode([
+ 'type' => 'terms',
+ 'taxonomies' => ['topics', 'secret'],
+ ]));
+
+ $this
+ ->actingAs($user)
+ ->getJson("/cp/fieldtypes/relationship?config={$config}")
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJson([
+ 'data' => [
+ ['slug' => 'public'],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function it_forbids_access_to_terms_when_the_user_cannot_view_any_of_the_taxonomies()
+ {
+ Taxonomy::make('topics')->save();
+ Taxonomy::make('secret')->save();
+ Term::make('public')->taxonomy('topics')->data([])->save();
+ Term::make('internal')->taxonomy('secret')->data([])->save();
+
+ $this->setTestRoles(['test' => ['access cp']]);
+ $user = User::make()->assignRole('test')->save();
+
+ $config = base64_encode(json_encode([
+ 'type' => 'terms',
+ 'taxonomies' => ['topics', 'secret'],
+ ]));
+
+ $this
+ ->actingAs($user)
+ ->getJson("/cp/fieldtypes/relationship?config={$config}")
+ ->assertForbidden();
+ }
}
class StartsWithC extends Scope
diff --git a/tests/Feature/GraphQL/EntryTest.php b/tests/Feature/GraphQL/EntryTest.php
index e54dacdb72d..98787b29cb5 100644
--- a/tests/Feature/GraphQL/EntryTest.php
+++ b/tests/Feature/GraphQL/EntryTest.php
@@ -796,4 +796,41 @@ public function it_only_shows_unpublished_entries_with_token()
'title' => 'That was so rad!',
]]]);
}
+
+ #[Test]
+ public function it_does_not_show_unpublished_entries_with_token_for_different_entry()
+ {
+ FilterAuthorizer::shouldReceive('allowedForSubResources')
+ ->andReturn(['published', 'status']);
+
+ EntryFactory::collection('blog')
+ ->id('6')
+ ->slug('that-was-so-rad')
+ ->data(['title' => 'That was so rad!'])
+ ->published(false)
+ ->create();
+
+ $other = EntryFactory::collection('blog')
+ ->id('7')
+ ->slug('other')
+ ->data(['title' => 'Other'])
+ ->create();
+
+ LivePreview::tokenize('test-token', $other);
+
+ $query = <<<'GQL'
+{
+ entry(id: "6") {
+ id
+ title
+ }
+}
+GQL;
+
+ $this
+ ->withoutExceptionHandling()
+ ->post('/graphql?token=test-token', ['query' => $query])
+ ->assertGqlOk()
+ ->assertExactJson(['data' => ['entry' => null]]);
+ }
}
diff --git a/tests/Fields/BlueprintTest.php b/tests/Fields/BlueprintTest.php
index 2be10d841da..5ef21d4f39c 100644
--- a/tests/Fields/BlueprintTest.php
+++ b/tests/Fields/BlueprintTest.php
@@ -898,6 +898,41 @@ public function it_ensures_a_field_has_config()
// todo: duplicate or tweak above test but make the target field not in the first section.
+ #[Test]
+ public function it_can_ensure_an_deferred_ensured_field_has_specific_config()
+ {
+ $blueprint = (new Blueprint)->setContents(['tabs' => [
+ 'tab_one' => [
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'title', 'field' => ['type' => 'text']],
+ ],
+ ],
+ ],
+ ],
+ ]]);
+
+ // Let's say somewhere else in the code ensures an `author` field
+ $blueprint->ensureField('author', ['type' => 'text', 'do_not_touch_other_config' => true, 'foo' => 'bar']);
+
+ // Then later, we try to ensure that `author` field has config, we should be able to successfully modify that deferred field
+ $fields = $blueprint
+ ->ensureFieldHasConfig('author', ['foo' => 'baz', 'visibility' => 'read_only'])
+ ->fields();
+
+ $this->assertEquals(['type' => 'text'], $fields->get('title')->config());
+
+ $expectedConfig = [
+ 'type' => 'text',
+ 'do_not_touch_other_config' => true,
+ 'foo' => 'baz',
+ 'visibility' => 'read_only',
+ ];
+
+ $this->assertEquals($expectedConfig, $fields->get('author')->config());
+ }
+
#[Test]
public function it_merges_previously_undefined_keys_into_the_config_when_ensuring_a_field_exists_and_it_already_exists()
{
diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php
index 15d2229e344..9e7d082a0cc 100644
--- a/tests/FrontendTest.php
+++ b/tests/FrontendTest.php
@@ -17,7 +17,6 @@
use Statamic\Facades\Blueprint;
use Statamic\Facades\Cascade;
use Statamic\Facades\Collection;
-use Statamic\Facades\Entry;
use Statamic\Facades\User;
use Statamic\Tags\Tags;
use Statamic\View\Antlers\Language\Utilities\StringUtilities;
@@ -38,8 +37,7 @@ public function setUp(): void
private function withStandardBlueprints()
{
- $this->addToAssertionCount(-1);
- Blueprint::shouldReceive('in')->withAnyArgs()->zeroOrMoreTimes()->andReturn(collect([new \Statamic\Fields\Blueprint]));
+ Blueprint::shouldReceive('in')->withAnyArgs()->andReturn(collect([new \Statamic\Fields\Blueprint]));
}
#[Test]
@@ -254,6 +252,21 @@ public function drafts_are_visible_if_using_live_preview()
$this->assertEquals('Testing 123', $response->content());
}
+ #[Test]
+ public function drafts_are_not_visible_if_using_live_preview_token_for_different_entry()
+ {
+ $this->withStandardFakeErrorViews();
+
+ $page = tap($this->createPage('about')->published(false)->set('content', 'Testing 123'))->save();
+ $other = $this->createPage('other');
+
+ LivePreview::tokenize('test-token', $other);
+
+ $this
+ ->get('/about?token=test-token')
+ ->assertStatus(404);
+ }
+
#[Test]
public function drafts_dont_get_statically_cached()
{
diff --git a/tests/Licensing/AddonLicenseTest.php b/tests/Licensing/AddonLicenseTest.php
index e93e5dfce88..687b3584267 100644
--- a/tests/Licensing/AddonLicenseTest.php
+++ b/tests/Licensing/AddonLicenseTest.php
@@ -14,9 +14,7 @@ class AddonLicenseTest extends TestCase
protected function license($response = [])
{
Addon::shouldReceive('get')->with('test/addon')
- ->zeroOrMoreTimes()
->andReturn(new FakeAddonLicenseAddon('Test Addon', '1.2.3', 'rad'));
- $this->addToAssertionCount(-1); // dont need to assert this.
return new AddonLicense('test/addon', $response);
}
diff --git a/tests/Licensing/LicenseManagerTest.php b/tests/Licensing/LicenseManagerTest.php
index 6138092b38a..44a37e27384 100644
--- a/tests/Licensing/LicenseManagerTest.php
+++ b/tests/Licensing/LicenseManagerTest.php
@@ -191,8 +191,7 @@ private function managerWithResponse(array $response)
{
$outpost = $this->mock(Outpost::class);
- $this->addToAssertionCount(-1); // Dont want to assert this
- $outpost->shouldReceive('response')->zeroOrMoreTimes()->andReturn($response);
+ $outpost->shouldReceive('response')->andReturn($response);
return new LicenseManager($outpost);
}
diff --git a/tests/Markdown/ManagerTest.php b/tests/Markdown/ManagerTest.php
index 2d74a75632d..ab9cd89c738 100644
--- a/tests/Markdown/ManagerTest.php
+++ b/tests/Markdown/ManagerTest.php
@@ -105,7 +105,6 @@ public function parser_instances_can_be_saved_and_retrieved()
#[Test]
public function it_throws_an_exception_if_extending_without_returning_a_parser()
{
- $this->expectNotToPerformAssertions();
$this->expectException(UnexpectedValueException::class);
$this->expectExceptionMessage('A ['.Markdown\Parser::class.'] instance is expected.');
diff --git a/tests/Query/OrderByTest.php b/tests/Query/OrderByTest.php
index 6f2944d294a..fca0ad418b3 100644
--- a/tests/Query/OrderByTest.php
+++ b/tests/Query/OrderByTest.php
@@ -5,10 +5,14 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Query\OrderBy;
+use Statamic\Tags\Concerns\QueriesOrderBys;
use Tests\TestCase;
class OrderByTest extends TestCase
{
+ protected $shouldFakeVersion = false;
+ protected $shouldPreventNavBeingBuilt = false;
+
#[Test]
#[DataProvider('parseProvider')]
public function it_parses_string($string, $sort, $dir)
@@ -35,4 +39,52 @@ public static function parseProvider()
['foo:bar:baz:desc', 'foo->bar->baz', 'desc'],
];
}
+
+ #[Test]
+ #[DataProvider('columnProvider')]
+ public function it_validates_columns($column, $expected)
+ {
+ $this->assertEquals($expected, OrderBy::column($column));
+ $this->assertEquals($expected ?? 'fallback', OrderBy::column($column, 'fallback'));
+ }
+
+ public static function columnProvider()
+ {
+ return [
+ 'simple' => ['title', 'title'],
+ 'with_underscores' => ['first_name', 'first_name'],
+ 'with_numbers' => ['field1', 'field1'],
+ 'json_arrow' => ['data->title', 'data->title'],
+ 'nested_json_arrow' => ['data->nested->field', 'data->nested->field'],
+ 'dotted' => ['table.column', 'table.column'],
+ 'single_quote' => ["title'", null],
+ 'double_quote' => ['title"', null],
+ 'semicolon' => ['title;', null],
+ 'space' => ['title foo', null],
+ 'parentheses' => ['title()', null],
+ 'sql_injection' => ["title'; DROP TABLE entries;--", null],
+ 'backtick' => ['title`', null],
+ 'comma' => ['title,foo', null],
+ 'empty_string' => ['', null],
+ 'null' => [null, null],
+ ];
+ }
+
+ #[Test]
+ public function it_filters_unsafe_columns_from_order_bys()
+ {
+ $tag = new class
+ {
+ use QueriesOrderBys;
+
+ public $params = [];
+ };
+
+ $tag->params = ['sort' => "title|foo'; DROP TABLE entries;--|date"];
+
+ $method = new \ReflectionMethod($tag, 'parseOrderBys');
+ $orderBys = $method->invoke($tag);
+
+ $this->assertEquals(['title', 'date'], $orderBys->map->sort->values()->all());
+ }
}
diff --git a/tests/Routing/RouteBindingTest.php b/tests/Routing/RouteBindingTest.php
index 0d1b8dbd958..c2442f54d1d 100644
--- a/tests/Routing/RouteBindingTest.php
+++ b/tests/Routing/RouteBindingTest.php
@@ -206,9 +206,7 @@ private function setupContent()
Facades\Revision::shouldReceive('whereKey')->with('collections/blog/en/123')->andReturn(collect(['1' => $entryRevision]));
Facades\Taxonomy::make('tags')->title('Product Tags')->save();
- $term = tap(Facades\Term::make()->taxonomy('tags')->inDefaultLocale()->slug('bravo')->data([]))->save();
- $termRevision = $term->inDefaultLocale()->makeRevision()->id('2');
- Facades\Revision::shouldReceive('whereKey')->with('taxonomies/tags/en/bravo')->andReturn(collect(['2' => $termRevision]));
+ Facades\Term::make()->taxonomy('tags')->inDefaultLocale()->slug('bravo')->data([])->save();
Facades\AssetContainer::make('files')->disk('files')->title('The Files')->save();
Storage::fake('files');
@@ -456,17 +454,6 @@ function (Collection $collection, Entry $entry, Revision $revision) {
'cp/custom/entries/blog/123/revisions/invalid',
],
- 'cp term revision' => [
- 'cp/custom/terms/tags/bravo/revisions/2',
- function (Taxonomy $taxonomy, Term $term, Revision $revision) {
- return $taxonomy->handle() === 'tags' && $term->id() === 'tags::bravo' && $revision->id() === '2';
- },
- ],
-
- 'cp term missing revision' => [
- 'cp/custom/terms/tags/bravo/revisions/invalid',
- ],
-
'cp invalid content revision' => [
'cp/custom/revisions/1',
],
@@ -851,24 +838,6 @@ function (string $collection, string $entry, string $revision) {
},
],
- 'term revision' => [
- 'custom/terms/tags/bravo/revisions/2',
- function (Taxonomy $taxonomy, Term $term, Revision $revision) {
- return $taxonomy->handle() === 'tags' && $term->id() === 'tags::bravo' && $revision->id() === '2';
- },
- function (string $taxonomy, string $term, string $revision) {
- return $taxonomy === 'tags' && $term === 'bravo' && $revision === '2';
- },
- ],
-
- 'term missing revision' => [
- 'custom/terms/tags/bravo/revisions/invalid',
- null,
- function (string $taxonomy, string $term, string $revision) {
- return $taxonomy === 'tags' && $term === 'bravo' && $revision === 'invalid';
- },
- ],
-
'invalid content revision' => [
'custom/revisions/1',
null,
diff --git a/tests/Stache/ColdStacheUriTest.php b/tests/Stache/ColdStacheUriTest.php
new file mode 100644
index 00000000000..690ba35cb2c
--- /dev/null
+++ b/tests/Stache/ColdStacheUriTest.php
@@ -0,0 +1,78 @@
+routes('{parent_uri}/{slug}')
+ ->structureContents(['root' => true]);
+ $collection->save();
+
+ EntryFactory::id('alfa-id')->collection('pages')->slug('alfa')->data(['title' => 'Alfa'])->create();
+ EntryFactory::id('bravo-id')->collection('pages')->slug('bravo')->data(['title' => 'Bravo'])->create();
+
+ $this->simulateColdStache();
+
+ // Loading a non-URI index first triggers re-entrant URI index loading via getCachedItem().
+ Stache::store('entries')->store('pages')->index('site')->load();
+
+ $entries = Entry::query()
+ ->where('collection', 'pages')
+ ->whereNotNull('uri')
+ ->whereStatus('published')
+ ->get();
+
+ $this->assertCount(2, $entries);
+ }
+
+ #[Test]
+ public function entries_have_uris_on_cold_stache_with_multisite_structured_collection()
+ {
+ $this->setSites([
+ 'en' => ['url' => 'http://localhost/', 'locale' => 'en_US'],
+ 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr_FR'],
+ ]);
+
+ $collection = Collection::make('pages')
+ ->routes('{parent_uri}/{slug}')
+ ->structureContents(['root' => true])
+ ->sites(['en', 'fr']);
+ $collection->save();
+
+ EntryFactory::id('alfa-id')->locale('en')->collection('pages')->slug('alfa')->data(['title' => 'Alfa'])->create();
+ EntryFactory::id('bravo-id')->locale('fr')->collection('pages')->slug('bravo')->origin('alfa-id')->data(['title' => 'Bravo'])->create();
+
+ $this->simulateColdStache();
+
+ Stache::store('entries')->store('pages')->index('site')->load();
+
+ $entries = Entry::query()
+ ->where('collection', 'pages')
+ ->whereNotNull('uri')
+ ->whereStatus('published')
+ ->get();
+
+ $this->assertCount(2, $entries);
+ }
+}
diff --git a/tests/Tags/User/ForgotPasswordFormTest.php b/tests/Tags/User/ForgotPasswordFormTest.php
index 04cc9eaf92f..cda33cf5f97 100644
--- a/tests/Tags/User/ForgotPasswordFormTest.php
+++ b/tests/Tags/User/ForgotPasswordFormTest.php
@@ -3,6 +3,7 @@
namespace Tests\Tags\User;
use Illuminate\Support\Facades\Password;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Parse;
use Statamic\Facades\User;
@@ -32,12 +33,39 @@ public function it_renders_form()
#[Test]
public function it_renders_form_with_params()
{
- $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" reset_url="/resetting" class="form" id="form" }}{{ /user:forgot_password_form }}');
+ $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" class="form" id="form" }}{{ /user:forgot_password_form }}');
$this->assertStringStartsWith('