diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml new file mode 100644 index 00000000000..79ef6efce80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT-V5.yml @@ -0,0 +1,60 @@ +name: Bug Report – Craft 5 +description: Report an issue or unexpected behavior pertaining to Craft 5 +title: '[5.x]: ' +labels: + - bug + - craft5 +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to submit a bug report! Please fill out the fields to the best of your knowledge, so we can get to the bottom of the issue as quickly as possible. + - type: textarea + id: body + attributes: + label: What happened? + value: | + ### Description + + + + ### Steps to reproduce + + 1. + + ### Expected behavior + + + + ### Actual behavior + + validations: + required: true + - type: input + id: cmsVersion + attributes: + label: Craft CMS version + validations: + required: true + - type: input + id: phpVersion + attributes: + label: PHP version + - type: input + id: os + attributes: + label: Operating system and version + - type: input + id: db + attributes: + label: Database type and version + - type: input + id: imageDriver + attributes: + label: Image driver and version + - type: textarea + id: plugins + attributes: + label: Installed plugins and versions + value: | + - diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d016ff55fb..23238b50399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Release Notes for Craft CMS 4 +## 4.5.7 - 2023-10-17 + +- Field containers are no longer focusable unless a corresponding validation message is clicked on. ([#13782](https://github.com/craftcms/cms/issues/13782)) +- Improved element save performance. +- Added `pgpassword` and `pwd` to the list of keywords that Craft will look for when determining whether a value is sensitive and should be redacted from logs, etc. +- Added `craft\events\DefineCompatibleFieldTypesEvent`. +- Added `craft\services\Fields::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES`. ([#13793](https://github.com/craftcms/cms/discussions/13793)) +- Added `craft\web\assets\inputmask\InputmaskAsset`. +- `craft\web\Request::accepts()` now supports wildcard (e.g. `application/*`). ([#13759](https://github.com/craftcms/cms/issues/13759)) +- `Craft.ElementEditor` instances are now configured with an `elementId` setting, which is kept up-to-date when a provisional draft is created. ([#13795](https://github.com/craftcms/cms/discussions/13795)) +- Added `Garnish.isPrimaryClick()`. +- Fixed a bug where relational fields’ element selector modals weren’t always getting set to the correct site per the field’s “Relate entries from a specific site?” setting. ([#13750](https://github.com/craftcms/cms/issues/13750)) +- Fixed a bug where Dropdown fields weren’t visible when viewing revisions and other static forms. ([#13753](https://github.com/craftcms/cms/issues/13753), [craftcms/commerce#3270](https://github.com/craftcms/commerce/issues/3270)) +- Fixed a bug where the `defaultDirMode` config setting wasn’t being respected when the `storage/runtime/` and `storage/logs/` folders were created. ([#13756](https://github.com/craftcms/cms/issues/13756)) +- Fixed a bug where the “Save and continue editing” action wasn’t working on Edit User pages if they contained a Money field. ([#13760](https://github.com/craftcms/cms/issues/13760)) +- Fixed a bug where relational fields’ validation messages weren’t using the actual field name. ([#13807](https://github.com/craftcms/cms/issues/13807)) +- Fixed a bug where element editor slideouts were appearing behind element selector modals within Live Preview. ([#13798](https://github.com/craftcms/cms/issues/13798)) +- Fixed a bug where element URIs weren’t getting updated for propagated sites automatically. ([#13812](https://github.com/craftcms/cms/issues/13812)) +- Fixed a bug where dropdown input labels could overflow out of their containers. ([#13817](https://github.com/craftcms/cms/issues/13817)) +- Fixed a bug where the `transformGifs` and `transformSvgs` config settings weren’t always being respected when using `@transform` GraphQL directives. ([#13808](https://github.com/craftcms/cms/issues/13808)) +- Fixed a bug where Composer operations were sorting `require` packages differently than how Composer does it natively, when `config.sort-packages` was set to `true`. ([#13806](https://github.com/craftcms/cms/issues/13806)) +- Fixed a MySQL error that could occur when creating a Plain Text field with a high charcter limit. ([#13781](https://github.com/craftcms/cms/pull/13781)) +- Fixed a bug where entries weren’t always being treated as live for View and Preview buttons, when editing a non-primary site. ([#13746](https://github.com/craftcms/cms/issues/13746)) +- Fixed a bug where Ctrl-clicks were being treated as primary clicks in some browsers. ([#13823](https://github.com/craftcms/cms/issues/13823)) +- Fixed a bug where some language options were showing “false” hints. ([#13837](https://github.com/craftcms/cms/issues/13837)) +- Fixed a bug where Craft was tracking changes to elements when they were being resaved. ([#13761](https://github.com/craftcms/cms/issues/13761)) +- Fixed a bug where sensitive keywords weren’t getting redacted from log contexts. +- Fixed RCE vulnerabilities. + ## 4.5.6.1 - 2023-09-27 - Crossdomain JavaScript resources are now loaded via a proxy action. diff --git a/bootstrap/bootstrap.php b/bootstrap/bootstrap.php index e3af3bcdd93..bf07a97da94 100644 --- a/bootstrap/bootstrap.php +++ b/bootstrap/bootstrap.php @@ -9,6 +9,7 @@ use craft\helpers\App; use craft\helpers\ArrayHelper; +use craft\helpers\FileHelper; use craft\services\Config; use yii\base\ErrorException; @@ -16,40 +17,60 @@ // see https://stackoverflow.com/a/21601349/1688568 $lastError = error_get_last(); -// Setup +// Validate the app type // ----------------------------------------------------------------------------- -// Validate the app type if (!isset($appType) || ($appType !== 'web' && $appType !== 'console')) { throw new Exception('$appType must be set to "web" or "console".'); } -$createFolder = function($path) { - // Code borrowed from Io... - if (!is_dir($path)) { - $oldumask = umask(0); - - if (!mkdir($path, 0755, true)) { - // Set a 503 response header so things like Varnish won't cache a bad page. - http_response_code(503); - exit('Tried to create a folder at ' . $path . ', but could not.' . PHP_EOL); - } +// Determine the paths +// ----------------------------------------------------------------------------- - // Because setting permission with mkdir is a crapshoot. - chmod($path, 0755); - umask($oldumask); - } +$findConfig = function(string $cliName, string $envName) { + return App::cliOption($cliName, true) ?? App::env($envName); }; -$findConfigPath = function(string $cliName, string $envName, bool $isFile = false) use ($createFolder) { - $path = App::cliOption($cliName, true) ?? App::env($envName); - if (!$path) { - return null; - } - if (!$isFile) { - $createFolder($path); +// Set the vendor path. By default assume that it's 4 levels up from here +$vendorPath = FileHelper::normalizePath($findConfig('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3)); + +// Set the "project root" path that contains config/, storage/, etc. By default assume that it's up a level from vendor/. +$rootPath = FileHelper::normalizePath($findConfig('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath)); + +// By default the remaining files/directories will be in the base directory +$dotenvPath = FileHelper::normalizePath($findConfig('--dotenvPath', 'CRAFT_DOTENV_PATH') ?? "$rootPath/.env"); +$configPath = FileHelper::normalizePath($findConfig('--configPath', 'CRAFT_CONFIG_PATH') ?? "$rootPath/config"); +$contentMigrationsPath = FileHelper::normalizePath($findConfig('--contentMigrationsPath', 'CRAFT_CONTENT_MIGRATIONS_PATH') ?? "$rootPath/migrations"); +$storagePath = FileHelper::normalizePath($findConfig('--storagePath', 'CRAFT_STORAGE_PATH') ?? "$rootPath/storage"); +$templatesPath = FileHelper::normalizePath($findConfig('--templatesPath', 'CRAFT_TEMPLATES_PATH') ?? "$rootPath/templates"); +$translationsPath = FileHelper::normalizePath($findConfig('--translationsPath', 'CRAFT_TRANSLATIONS_PATH') ?? "$rootPath/translations"); +$testsPath = FileHelper::normalizePath($findConfig('--testsPath', 'CRAFT_TESTS_PATH') ?? "$rootPath/tests"); + +// Set the environment +// ----------------------------------------------------------------------------- + +$environment = App::cliOption('--env', true) + ?? App::env('CRAFT_ENVIRONMENT') + ?? App::env('ENVIRONMENT') + ?? $_SERVER['SERVER_NAME'] + ?? null; + +// Load the general config +// ----------------------------------------------------------------------------- + +$configService = new Config(); +$configService->env = $environment; +$configService->configDir = $configPath; +$configService->appDefaultsDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'defaults'; +$generalConfig = $configService->getConfigFromFile('general'); + +// Validation +// ----------------------------------------------------------------------------- + +$createFolder = function($path) use ($generalConfig) { + if (!is_dir($path)) { + FileHelper::createDirectory($path, $generalConfig['defaultDirMode'] ?? 0775); } - return realpath($path); }; $ensureFolderIsReadable = function($path, $writableToo = false) { @@ -69,31 +90,6 @@ } }; -// Determine the paths -// ----------------------------------------------------------------------------- - -// Set the vendor path. By default assume that it's 4 levels up from here -$vendorPath = $findConfigPath('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3); - -// Set the "project root" path that contains config/, storage/, etc. By default assume that it's up a level from vendor/. -$rootPath = $findConfigPath('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath); - -// By default the remaining files/directories will be in the base directory -$dotenvPath = $findConfigPath('--dotenvPath', 'CRAFT_DOTENV_PATH', true) ?? "$rootPath/.env"; -$configPath = $findConfigPath('--configPath', 'CRAFT_CONFIG_PATH') ?? "$rootPath/config"; -$contentMigrationsPath = $findConfigPath('--contentMigrationsPath', 'CRAFT_CONTENT_MIGRATIONS_PATH') ?? "$rootPath/migrations"; -$storagePath = $findConfigPath('--storagePath', 'CRAFT_STORAGE_PATH') ?? "$rootPath/storage"; -$templatesPath = $findConfigPath('--templatesPath', 'CRAFT_TEMPLATES_PATH') ?? "$rootPath/templates"; -$translationsPath = $findConfigPath('--translationsPath', 'CRAFT_TRANSLATIONS_PATH') ?? "$rootPath/translations"; -$testsPath = $findConfigPath('--testsPath', 'CRAFT_TESTS_PATH') ?? "$rootPath/tests"; - -// Set the environment -$environment = App::cliOption('--env', true) - ?? App::env('CRAFT_ENVIRONMENT') - ?? App::env('ENVIRONMENT') - ?? $_SERVER['SERVER_NAME'] - ?? null; - // Validate the paths // ----------------------------------------------------------------------------- @@ -160,15 +156,6 @@ $errorLevel = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED; error_reporting($errorLevel); -// Load the general config -// ----------------------------------------------------------------------------- - -$configService = new Config(); -$configService->env = $environment; -$configService->configDir = $configPath; -$configService->appDefaultsDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'defaults'; -$generalConfig = $configService->getConfigFromFile('general'); - // Determine if Craft is running in Dev Mode // ----------------------------------------------------------------------------- diff --git a/composer.lock b/composer.lock index fb7bdfcc582..70b00bc2ee6 100644 --- a/composer.lock +++ b/composer.lock @@ -285,16 +285,16 @@ }, { "name": "composer/composer", - "version": "2.5.8", + "version": "2.6.5", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "4c516146167d1392c8b9b269bb7c24115d262164" + "reference": "4b0fe89db9e65b1e64df633a992e70a7a215ab33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/4c516146167d1392c8b9b269bb7c24115d262164", - "reference": "4c516146167d1392c8b9b269bb7c24115d262164", + "url": "https://api.github.com/repos/composer/composer/zipball/4b0fe89db9e65b1e64df633a992e70a7a215ab33", + "reference": "4b0fe89db9e65b1e64df633a992e70a7a215ab33", "shasum": "" }, "require": { @@ -302,23 +302,23 @@ "composer/class-map-generator": "^1.0", "composer/metadata-minifier": "^1.0", "composer/pcre": "^2.1 || ^3.1", - "composer/semver": "^3.0", + "composer/semver": "^3.2.5", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", "justinrainbow/json-schema": "^5.2.11", "php": "^7.2.5 || ^8.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "react/promise": "^2.8", + "react/promise": "^2.8 || ^3", "seld/jsonlint": "^1.4", "seld/phar-utils": "^1.2", "seld/signal-handler": "^2.0", - "symfony/console": "^5.4.11 || ^6.0.11", - "symfony/filesystem": "^5.4 || ^6.0", - "symfony/finder": "^5.4 || ^6.0", + "symfony/console": "^5.4.11 || ^6.0.11 || ^7", + "symfony/filesystem": "^5.4 || ^6.0 || ^7", + "symfony/finder": "^5.4 || ^6.0 || ^7", "symfony/polyfill-php73": "^1.24", "symfony/polyfill-php80": "^1.24", "symfony/polyfill-php81": "^1.24", - "symfony/process": "^5.4 || ^6.0" + "symfony/process": "^5.4 || ^6.0 || ^7" }, "require-dev": { "phpstan/phpstan": "^1.9.3", @@ -326,7 +326,7 @@ "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1", "phpstan/phpstan-symfony": "^1.2.10", - "symfony/phpunit-bridge": "^6.0" + "symfony/phpunit-bridge": "^6.0 || ^7" }, "suggest": { "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", @@ -339,7 +339,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "2.6-dev" }, "phpstan": { "includes": [ @@ -349,7 +349,7 @@ }, "autoload": { "psr-4": { - "Composer\\": "src/Composer" + "Composer\\": "src/Composer/" } }, "notification-url": "https://packagist.org/downloads/", @@ -378,7 +378,8 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.5.8" + "security": "https://github.com/composer/composer/security/policy", + "source": "https://github.com/composer/composer/tree/2.6.5" }, "funding": [ { @@ -394,7 +395,7 @@ "type": "tidelift" } ], - "time": "2023-06-09T15:13:21+00:00" + "time": "2023-10-06T08:11:52+00:00" }, { "name": "composer/metadata-minifier", diff --git a/src/Craft.php b/src/Craft.php index ac34c126f57..c329f1db664 100644 --- a/src/Craft.php +++ b/src/Craft.php @@ -17,6 +17,7 @@ use GuzzleHttp\Client; use Symfony\Component\VarDumper\Cloner\VarCloner; use yii\base\ExitException; +use yii\base\InvalidConfigException; use yii\db\Expression; use yii\helpers\VarDumper; use yii\web\Request; @@ -51,6 +52,10 @@ class Craft extends Yii */ public static function createObject($type, array $params = []) { + if (is_array($type) && isset($type['__class']) && isset($type['class'])) { + throw new InvalidConfigException('`__class` and `class` cannot both be specified.'); + } + return parent::createObject($type, $params); } diff --git a/src/base/Element.php b/src/base/Element.php index d40e69ac247..d68d16b8944 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -73,7 +73,6 @@ use craft\validators\SlugValidator; use craft\validators\StringValidator; use craft\web\UploadedFile; -use DateTime; use Illuminate\Support\Collection; use Throwable; use Traversable; @@ -2261,41 +2260,45 @@ public function init(): void */ public function attributes(): array { - $names = parent::attributes(); + $names = array_flip(parent::attributes()); if ($this->structureId) { - $names[] = 'parentId'; + $names['parentId'] = true; } else { - ArrayHelper::removeValue($names, 'structureId'); - ArrayHelper::removeValue($names, 'root'); - ArrayHelper::removeValue($names, 'lft'); - ArrayHelper::removeValue($names, 'rgt'); - ArrayHelper::removeValue($names, 'level'); - } - - ArrayHelper::removeValue($names, 'searchScore'); - ArrayHelper::removeValue($names, 'awaitingFieldValues'); - ArrayHelper::removeValue($names, 'firstSave'); - ArrayHelper::removeValue($names, 'propagating'); - ArrayHelper::removeValue($names, 'propagateAll'); - ArrayHelper::removeValue($names, 'newSiteIds'); - ArrayHelper::removeValue($names, 'resaving'); - ArrayHelper::removeValue($names, 'duplicateOf'); - ArrayHelper::removeValue($names, 'mergingCanonicalChanges'); - ArrayHelper::removeValue($names, 'updatingFromDerivative'); - ArrayHelper::removeValue($names, 'previewing'); - ArrayHelper::removeValue($names, 'hardDelete'); - - $names[] = 'canonicalId'; - $names[] = 'isDraft'; - $names[] = 'isRevision'; - $names[] = 'isUnpublishedDraft'; - $names[] = 'ref'; - $names[] = 'status'; - $names[] = 'structureId'; - $names[] = 'url'; - - return $names; + unset( + $names['structureId'], + $names['root'], + $names['lft'], + $names['rgt'], + $names['level'], + ); + } + + unset( + $names['searchScore'], + $names['awaitingFieldValues'], + $names['firstSave'], + $names['propagating'], + $names['propagateAll'], + $names['newSiteIds'], + $names['resaving'], + $names['duplicateOf'], + $names['mergingCanonicalChanges'], + $names['updatingFromDerivative'], + $names['previewing'], + $names['hardDelete'], + ); + + $names['canonicalId'] = true; + $names['isDraft'] = true; + $names['isRevision'] = true; + $names['isUnpublishedDraft'] = true; + $names['ref'] = true; + $names['status'] = true; + $names['structureId'] = true; + $names['url'] = true; + + return array_keys($names); } /** diff --git a/src/config/app.php b/src/config/app.php index 41516e522eb..00b95ef0d80 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -3,7 +3,7 @@ return [ 'id' => 'CraftCMS', 'name' => 'Craft CMS', - 'version' => '4.5.6.1', + 'version' => '4.5.7', 'schemaVersion' => '4.5.3.0', 'minVersionRequired' => '3.7.11', 'basePath' => dirname(__DIR__), // Defines the @app alias @@ -119,7 +119,9 @@ 'key', 'pass', 'password', + 'pgpassword', 'pw', + 'pwd', 'secret', 'sk', 'tok', diff --git a/src/controllers/ElementIndexesController.php b/src/controllers/ElementIndexesController.php index 2ffe3a0c5ba..370fb750984 100644 --- a/src/controllers/ElementIndexesController.php +++ b/src/controllers/ElementIndexesController.php @@ -609,7 +609,7 @@ protected function elementQuery(): ElementQueryInterface $criteria['draftOf'] = filter_var($criteria['draftOf'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } } - Craft::configure($query, $criteria); + Craft::configure($query, Component::cleanseConfig($criteria)); } // Override with the custom filters diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index cda6729eb6b..283b697822b 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -416,6 +416,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): 'canCreateDrafts' => $canCreateDrafts, 'canEditMultipleSites' => $canEditMultipleSites, 'canSaveCanonical' => $canSaveCanonical, + 'elementId' => $element->id, 'canonicalId' => $canonical->id, 'draftId' => $element->draftId, 'draftName' => $isDraft ? $element->draftName : null, @@ -1308,6 +1309,7 @@ public function actionSaveDraft(): ?Response $data = [ 'canonicalId' => $element->getCanonicalId(), + 'elementId' => $element->id, 'draftId' => $element->draftId, 'timestamp' => Craft::$app->getFormatter()->asTimestamp($element->dateUpdated, 'short', true), 'creator' => $creator?->getName(), diff --git a/src/controllers/SystemSettingsController.php b/src/controllers/SystemSettingsController.php index 6aa7446e883..d800c71e74e 100644 --- a/src/controllers/SystemSettingsController.php +++ b/src/controllers/SystemSettingsController.php @@ -12,6 +12,7 @@ use craft\errors\MissingComponentException; use craft\helpers\App; use craft\helpers\ArrayHelper; +use craft\helpers\Component; use craft\helpers\MailerHelper; use craft\helpers\UrlHelper; use craft\mail\Mailer; @@ -299,7 +300,7 @@ private function _createMailSettingsFromPost(): MailSettings $settings->fromName = $this->request->getBodyParam('fromName'); $settings->template = $this->request->getBodyParam('template'); $settings->transportType = $this->request->getBodyParam('transportType'); - $settings->transportSettings = $this->request->getBodyParam('transportTypes.' . $settings->transportType); + $settings->transportSettings = Component::cleanseConfig($this->request->getBodyParam('transportTypes.' . $settings->transportType) ?? []); return $settings; } diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index 6c562a34eed..83ad2f22c05 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -1123,7 +1123,7 @@ public function actionEditUser(mixed $userId = null, ?User $user = null, ?array 'value' => $locale->id, 'data' => [ 'data' => [ - 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : false, + 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : '', 'hintLang' => $locale->id, ], ], diff --git a/src/elements/Category.php b/src/elements/Category.php index 73715f3291e..2a6e67f2ab9 100644 --- a/src/elements/Category.php +++ b/src/elements/Category.php @@ -435,16 +435,15 @@ protected function previewTargets(): array protected function route(): array|string|null { // Make sure the category group is set to have URLs for this site - $siteId = Craft::$app->getSites()->getCurrentSite()->id; - $categoryGroupSiteSettings = $this->getGroup()->getSiteSettings(); + $categoryGroupSiteSettings = $this->getGroup()->getSiteSettings()[$this->siteId] ?? null; - if (!isset($categoryGroupSiteSettings[$siteId]) || !$categoryGroupSiteSettings[$siteId]->hasUrls) { + if (!$categoryGroupSiteSettings?->hasUrls) { return null; } return [ 'templates/render', [ - 'template' => (string)$categoryGroupSiteSettings[$siteId]->template, + 'template' => (string)$categoryGroupSiteSettings->template, 'variables' => [ 'category' => $this, ], diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 05e23f061c4..df687114fef 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -967,16 +967,15 @@ protected function route(): array|string|null } // Make sure the section is set to have URLs for this site - $siteId = Craft::$app->getSites()->getCurrentSite()->id; - $sectionSiteSettings = $this->getSection()->getSiteSettings(); + $sectionSiteSettings = $this->getSection()->getSiteSettings()[$this->siteId] ?? null; - if (!isset($sectionSiteSettings[$siteId]) || !$sectionSiteSettings[$siteId]->hasUrls) { + if (!$sectionSiteSettings?->hasUrls) { return null; } return [ 'templates/render', [ - 'template' => (string)$sectionSiteSettings[$siteId]->template, + 'template' => (string)$sectionSiteSettings->template, 'variables' => [ 'entry' => $this, ], diff --git a/src/events/DefineCompatibleFieldTypesEvent.php b/src/events/DefineCompatibleFieldTypesEvent.php new file mode 100644 index 00000000000..c8954294374 --- /dev/null +++ b/src/events/DefineCompatibleFieldTypesEvent.php @@ -0,0 +1,22 @@ + + * @since 4.5.7 + */ +class DefineCompatibleFieldTypesEvent extends FieldEvent +{ + /** + * @var string[] The field type classes that are considered compatible with [[$field]]. + */ + public array $compatibleTypes; +} diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index 7d5c31a3c06..93533aa591c 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -506,7 +506,7 @@ public function validateRelatedElements(ElementInterface $element): void $elementType = static::elementType(); $element->addError($this->handle, Craft::t('app', 'Validation errors found in {attribute} {type}; please fix them.', [ 'type' => $errorCount === 1 ? $elementType::lowerDisplayName() : $elementType::pluralLowerDisplayName(), - 'attribute' => $this->getAttributeLabel($this->handle), + 'attribute' => Craft::t('site', $this->name), ])); } } diff --git a/src/fields/Dropdown.php b/src/fields/Dropdown.php index 2e67c60c739..dbffa36d571 100644 --- a/src/fields/Dropdown.php +++ b/src/fields/Dropdown.php @@ -64,6 +64,19 @@ public function getStatus(ElementInterface $element): ?array * @inheritdoc */ protected function inputHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->inputHtmlInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ?ElementInterface $element = null): string + { + return $this->inputHtmlInternal($value, $element, true); + } + + private function inputHtmlInternal(mixed $value, ?ElementInterface $element, bool $static): string { /** @var SingleOptionFieldData $value */ $options = $this->translatedOptions(true, $value, $element); @@ -77,7 +90,9 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s } if (!$value->valid) { - Craft::$app->getView()->setInitialDeltaValue($this->handle, $this->encodeValue($value->value)); + if (!$static) { + Craft::$app->getView()->setInitialDeltaValue($this->handle, $this->encodeValue($value->value)); + } $default = $this->defaultValue(); if ($default !== null) { @@ -103,6 +118,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s 'name' => $this->handle, 'value' => $encValue, 'options' => $options, + 'disabled' => $static, ]); } diff --git a/src/fields/MultiSelect.php b/src/fields/MultiSelect.php index 9673cc44364..ff38c585bd6 100644 --- a/src/fields/MultiSelect.php +++ b/src/fields/MultiSelect.php @@ -68,6 +68,23 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s ]); } + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ?ElementInterface $element = null): string + { + return Cp::selectizeHtml([ + 'id' => $this->getInputId(), + 'describedBy' => $this->describedBy, + 'class' => 'selectize', + 'name' => $this->handle, + 'values' => $this->encodeValue($value), + 'options' => $this->translatedOptions(true, $value, $element), + 'multi' => true, + 'disabled' => true, + ]); + } + /** * @inheritdoc */ diff --git a/src/fields/PlainText.php b/src/fields/PlainText.php index 6e76b968cf0..b43ffd56ae6 100644 --- a/src/fields/PlainText.php +++ b/src/fields/PlainText.php @@ -186,11 +186,25 @@ public function getContentColumnType(): string $bytes = $this->byteLimit; } elseif ($this->charLimit) { $bytes = $this->charLimit * 4; - } else { - return Schema::TYPE_TEXT; } - return Schema::TYPE_STRING . "($bytes)"; + if (Craft::$app->getDb()->getIsPgsql()) { + if (isset($bytes)) { + return Schema::TYPE_STRING . "($bytes)"; + } else { + return Schema::TYPE_TEXT; + } + } else { + if (!isset($bytes)) { + return Schema::TYPE_TEXT; + } + + if ($bytes <= 1020) { + return sprintf('%s(%s)', Schema::TYPE_STRING, $bytes); + } + + return Db::getTextualColumnTypeByContentLength($bytes); + } } /** diff --git a/src/gql/directives/Transform.php b/src/gql/directives/Transform.php index 3ce2aa2eed3..1084e1a8c40 100644 --- a/src/gql/directives/Transform.php +++ b/src/gql/directives/Transform.php @@ -84,6 +84,16 @@ public static function apply(mixed $source, mixed $value, array $arguments, Reso } } } elseif ($source instanceof Asset) { + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $allowTransform = match ($source->getMimeType()) { + 'image/gif' => $generalConfig->transformGifs, + 'image/svg+xml' => $generalConfig->transformSvgs, + default => true, + }; + if (!$allowTransform) { + $transform = null; + } + switch ($resolveInfo->fieldName) { case 'format': return $source->getFormat($transform); @@ -92,7 +102,7 @@ public static function apply(mixed $source, mixed $value, array $arguments, Reso case 'mimeType': return $source->getMimeType($transform); case 'url': - $generateNow = $arguments['immediately'] ?? Craft::$app->getConfig()->getGeneral()->generateTransformsBeforePageLoad; + $generateNow = $arguments['immediately'] ?? $generalConfig->generateTransformsBeforePageLoad; return $source->getUrl($transform, $generateNow); case 'width': return $source->getWidth($transform); diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index 6782a3b773a..4492b5e0876 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -775,7 +775,6 @@ public static function fieldHtml(string $input, array $config = []): string 'data' => [ 'attribute' => $attribute, ], - 'tabindex' => -1, ], $config['fieldAttributes'] ?? [] )) . diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index 91943c9c90d..8dbb16915b3 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -374,7 +374,8 @@ public static function shouldTrackChanges(ElementInterface $element): bool $element->siteSettingsId && $element->duplicateOf === null && $element::trackChanges() && - !$element->mergingCanonicalChanges + !$element->mergingCanonicalChanges && + !$element->resaving ); } diff --git a/src/helpers/StringHelper.php b/src/helpers/StringHelper.php index feaa9cade69..6ed96eddde4 100644 --- a/src/helpers/StringHelper.php +++ b/src/helpers/StringHelper.php @@ -1252,10 +1252,6 @@ public static function replaceLast(string $str, string $search, string $replacem */ public static function replaceMb4(string $str, callable|string $replace): string { - if (!static::containsMb4($str)) { - return $str; - } - return preg_replace_callback('/./u', function(array $match) use ($replace): string { if (strlen($match[0]) >= 4) { return is_callable($replace) ? $replace($match[0]) : $replace; diff --git a/src/log/ContextProcessor.php b/src/log/ContextProcessor.php index 941b6605f50..5a8a74ff221 100644 --- a/src/log/ContextProcessor.php +++ b/src/log/ContextProcessor.php @@ -9,8 +9,10 @@ use Craft; use craft\helpers\ArrayHelper; +use craft\helpers\Json; use Illuminate\Support\Collection; use Monolog\Processor\ProcessorInterface; +use yii\base\InvalidArgumentException; use yii\helpers\VarDumper; use yii\web\Request; use yii\web\Session; @@ -68,6 +70,18 @@ public function __invoke(array $record): array // Log the raw request body instead $this->vars = array_merge($this->vars); array_splice($this->vars, $postPos, 1); + + // Redact sensitive bits + try { + $decoded = Json::decode($body); + if (is_array($decoded)) { + $decoded = Craft::$app->getSecurity()->redactIfSensitive('', $decoded); + } + $body = Json::encode($decoded); + } catch (InvalidArgumentException) { + // NBD + } + $record[$this->key]['body'] = $body; } @@ -93,16 +107,15 @@ protected function dumpVars(array $vars): string protected function filterVars(array $vars = []): array { - $filtered = Collection::make(ArrayHelper::filter($GLOBALS, $vars)); + $filtered = ArrayHelper::filter($GLOBALS, $vars); // Workaround for codeception testing until these gets addressed: // https://github.com/yiisoft/yii-core/issues/49 // https://github.com/yiisoft/yii2/issues/15847 if (Craft::$app) { - $security = Craft::$app->getSecurity(); - $filtered = $filtered->map(fn($value, $key) => $security->redactIfSensitive($key, $value)); + $filtered = Craft::$app->getSecurity()->redactIfSensitive('', $filtered); } - return $filtered->all(); + return $filtered; } } diff --git a/src/services/Composer.php b/src/services/Composer.php index 878c3ad15bb..e5a77622134 100644 --- a/src/services/Composer.php +++ b/src/services/Composer.php @@ -322,12 +322,38 @@ protected function updateRequirements(IOInterface $io, string $jsonPath, array $ } if ($config['config']['sort-packages'] ?? false) { - ksort($config['require']); + $this->sortPackages($config['require']); } $this->writeJson($jsonPath, $config); } + public function sortPackages(&$packages): void + { + // Adapted from JsonManipulator::sortPackages() + uksort($packages, fn($a, $b) => strnatcmp($this->prefixPackage($a), $this->prefixPackage($b))); + } + + private function prefixPackage(string $package): string + { + if (preg_match('/^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$/iD', $package)) { + $lower = strtolower($package); + if (str_starts_with($lower, 'php')) { + $group = '0'; + } elseif (str_starts_with($lower, 'hhvm')) { + $group = '1'; + } elseif (str_starts_with($lower, 'ext')) { + $group = '2'; + } elseif (str_starts_with($lower, 'lib')) { + $group = '3'; + } elseif (preg_match('/^\D/', $lower)) { + $group = '4'; + } + } + + return sprintf('%s-%s', $group ?? '5', $package); + } + /** * @param array $config * @return int|string|false The key in `$config['repositories']` referencing composer.craftcms.com diff --git a/src/services/Elements.php b/src/services/Elements.php index 40efe907706..ae39a66852b 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -3587,6 +3587,7 @@ private function _propagateElement( $siteElement->siteId = $oldSiteElement->siteId; $siteElement->contentId = $oldSiteElement->contentId; $siteElement->setEnabledForSite($oldSiteElement->getEnabledForSite()); + $siteElement->uri = $oldSiteElement->uri; } else { $siteElement->enabled = $element->enabled; $siteElement->resaving = $element->resaving; @@ -3618,7 +3619,25 @@ private function _propagateElement( $siteElement->slug = $element->slug; } - // Copy the dirty attributes (except title and slug, which may be translatable) + // Ensure the uri is properly localized + // see https://github.com/craftcms/cms/issues/13812 for more details + if ( + $element::hasUris() && + ( + $isNewSiteForElement || + in_array('uri', $element->getDirtyAttributes()) || + $element->resaving + ) + ) { + // Set a unique URI on the site clone + try { + ElementHelper::setUniqueUri($siteElement); + } catch (OperationAbortedException) { + // carry on + } + } + + // Copy the dirty attributes (except title, slug and uri, which may be translatable) $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), function(string $attribute): bool { return $attribute !== 'title' && $attribute !== 'slug'; })); diff --git a/src/services/Fields.php b/src/services/Fields.php index 107aa262d3e..b9ae373791e 100644 --- a/src/services/Fields.php +++ b/src/services/Fields.php @@ -19,6 +19,7 @@ use craft\db\Table; use craft\errors\MissingComponentException; use craft\events\ConfigEvent; +use craft\events\DefineCompatibleFieldTypesEvent; use craft\events\FieldEvent; use craft\events\FieldGroupEvent; use craft\events\FieldLayoutEvent; @@ -104,6 +105,13 @@ class Fields extends Component */ public const EVENT_REGISTER_FIELD_TYPES = 'registerFieldTypes'; + /** + * @event DefineCompatibleFieldTypesEvent The event that is triggered when defining the compatible field types for a field. + * @see getCompatibleFieldTypes() + * @since 4.5.7 + */ + public const EVENT_DEFINE_COMPATIBLE_FIELD_TYPES = 'defineCompatibleFieldTypes'; + /** * @event FieldGroupEvent The event that is triggered before a field group is saved. */ @@ -518,50 +526,48 @@ public function getFieldTypesWithContent(): array */ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurrent = true): array { - if (!$field::hasContentColumn()) { - return $includeCurrent ? [get_class($field)] : []; - } + $types = []; - // If the field has any validation errors and has an ID, swap it with the saved field - if (!$field->getIsNew() && $field->hasErrors()) { - $field = $this->getFieldById($field->id); - } + if ($field::hasContentColumn()) { + // If the field has any validation errors and has an ID, swap it with the saved field + if (!$field->getIsNew() && $field->hasErrors()) { + $field = $this->getFieldById($field->id); + } - $fieldColumnType = $field->getContentColumnType(); + $fieldColumnType = $field->getContentColumnType(); - if (is_array($fieldColumnType)) { - return $includeCurrent ? [get_class($field)] : []; - } + if (is_array($fieldColumnType)) { + return $includeCurrent ? [get_class($field)] : []; + } - $types = []; + foreach ($this->getAllFieldTypes() as $class) { + /** @var string|FieldInterface $class */ + /** @phpstan-var class-string|FieldInterface $class */ + if ($class === get_class($field)) { + if ($includeCurrent) { + $types[] = $class; + } + continue; + } - foreach ($this->getAllFieldTypes() as $class) { - /** @var string|FieldInterface $class */ - /** @phpstan-var class-string|FieldInterface $class */ - if ($class === get_class($field)) { - if ($includeCurrent) { - $types[] = $class; + if (!$class::hasContentColumn()) { + continue; } - continue; - } - if (!$class::hasContentColumn()) { - continue; - } + /** @var FieldInterface $tempField */ + $tempField = new $class(); + $tempFieldColumnType = $tempField->getContentColumnType(); - /** @var FieldInterface $tempField */ - $tempField = new $class(); - $tempFieldColumnType = $tempField->getContentColumnType(); + if (is_array($tempFieldColumnType)) { + continue; + } - if (is_array($tempFieldColumnType)) { - continue; - } + if (!Db::areColumnTypesCompatible($fieldColumnType, $tempFieldColumnType)) { + continue; + } - if (!Db::areColumnTypesCompatible($fieldColumnType, $tempFieldColumnType)) { - continue; + $types[] = $class; } - - $types[] = $class; } // Make sure the current field class is in there if it's supposed to be @@ -569,6 +575,15 @@ public function getCompatibleFieldTypes(FieldInterface $field, bool $includeCurr $types[] = get_class($field); } + if ($this->hasEventHandlers(self::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES)) { + $event = new DefineCompatibleFieldTypesEvent([ + 'field' => $field, + 'compatibleTypes' => $types, + ]); + $this->trigger(self::EVENT_DEFINE_COMPATIBLE_FIELD_TYPES, $event); + return $event->compatibleTypes; + } + return $types; } diff --git a/src/templates/_components/widgets/CraftSupport/body.twig b/src/templates/_components/widgets/CraftSupport/body.twig index 1b7bd0d51b2..82397301b82 100644 --- a/src/templates/_components/widgets/CraftSupport/body.twig +++ b/src/templates/_components/widgets/CraftSupport/body.twig @@ -16,6 +16,7 @@
{{ tag('h2', { text: submitText, + class: 'cs-heading' }) }} {{ forms.textareaField({ first: true, diff --git a/src/templates/_includes/forms/autosuggest.twig b/src/templates/_includes/forms/autosuggest.twig index 8c28152ed0b..e8fbb1beec7 100644 --- a/src/templates/_includes/forms/autosuggest.twig +++ b/src/templates/_includes/forms/autosuggest.twig @@ -30,8 +30,6 @@ @focus="updateFilteredOptions" @blur="onBlur" @input="onInputChange" - @opened="onOpened" - @closed="onClosed" v-model="inputProps.initialValue" >