diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3708a14df45..e0d5495120d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - develop + - '4.8' pull_request: permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index f189c2a0846..f268fb7bb82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Release Notes for Craft CMS 4 +## 4.8.0 - 2024-02-26 + +> [!NOTE] +> Trialing Craft and plugin updates with expired licenses is allowed now, on non-public domains. + +> [!WARNING] +> When licensing issues occur on public domains, the control panel will now become temporarily inaccessible for logged-in users, alerting them to the problems and giving them an opportunity to resolve them. (The front end will not be impacted.) + +### Content Management +- Assets fields’ selection modals now open to the last-viewed location by default, if their Default Upload Location doesn’t specify a subpath. ([#14382](https://github.com/craftcms/cms/pull/14382)) +- Element sources no longer display `0` badges. + +### Administration +- Color fields now have a “Presets” settings. ([#14463](https://github.com/craftcms/cms/discussions/14463)) +- It’s now possible to update expired licenses from the Updates utility, on non-public domains. +- The `queue/run` command now supports a `--job-id` option. +- `update all` and `update ` commands now support a `--with-expired` option. + +### Development +- The GraphQL API is now available for Craft Solo installs. +- The `{% js %}` and `{% css %}` tags now support `.js.gz` and `.css.gz` URLs. ([#14243](https://github.com/craftcms/cms/issues/14243)) +- Relation fields’ element query params now factor in the element query’s target site(s). ([#14258](https://github.com/craftcms/cms/issues/14258), [#14348](https://github.com/craftcms/cms/issues/14348), [#14304](https://github.com/craftcms/cms/pull/14304)) +- Element queries’ `level` param now supports passing an array which includes `null`. ([#14419](https://github.com/craftcms/cms/issues/14419)) + +### Extensibility +- Added `craft\services\ProjectConfig::EVENT_AFTER_WRITE_YAML_FILES`. ([#14365](https://github.com/craftcms/cms/discussions/14365)) +- Added `craft\services\Relations::deleteLeftoverRelations()`. ([#13956](https://github.com/craftcms/cms/issues/13956)) +- Added `craft\services\Search::shouldCallSearchElements()`. ([#14293](https://github.com/craftcms/cms/issues/14293)) + +### System +- Relations for fields that are no longer included in an element’s field layout are now deleted after element save. ([#13956](https://github.com/craftcms/cms/issues/13956)) +- The Sendmail email transport type now uses the `sendmail_path` PHP ini setting by default. ([#14433](https://github.com/craftcms/cms/pull/14433)) +- Composer installation commands suggested by the Plugin Store now include a minimum version constraint. +- Fixed a bug where it wasn’t possible to eager-load Matrix block revisions, or load them via GraphQL. ([#14448](https://github.com/craftcms/cms/issues/14448)) +- Fixed a PHP warning that could occur when publishing asset bundles on Dev Mode. ([#14455](https://github.com/craftcms/cms/pull/14455)) +- Fixed a bug where the Updates utility and Updates widget weren’t handling update check failures. +- Updated Twig to 3.8. + ## 4.7.4 - 2024-02-22 - The Plugin Store now shows “Tested on Cloud” and “Supports GraphQL” labels for plugins when appropriate. diff --git a/composer.json b/composer.json index 2d04b02a28a..8115a6593b4 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "symfony/var-dumper": "^5.0|^6.0", "symfony/yaml": "^5.2.3", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.4.3", + "twig/twig": "~3.8.0", "voku/stringy": "^6.4.0", "webonyx/graphql-php": "~14.11.5", "yiisoft/yii2": "~2.0.48.1", diff --git a/composer.lock b/composer.lock index 167f9cb6f36..d9b8a1b7889 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a1cf007ed10360541eaa9eb77b7a122", + "content-hash": "c8414f1566ad594c1d9310383746c319", "packages": [ { "name": "cebe/markdown", @@ -5332,33 +5332,29 @@ }, { "name": "twig/twig", - "version": "v3.4.3", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "c38fd6b0b7f370c198db91ffd02e23b517426b58" + "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/c38fd6b0b7f370c198db91ffd02e23b517426b58", - "reference": "c38fd6b0b7f370c198db91ffd02e23b517426b58", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/9d15f0ac07f44dc4217883ec6ae02fd555c6f71d", + "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d", "shasum": "" }, "require": { "php": ">=7.2.5", "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-mbstring": "^1.3" + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php80": "^1.22" }, "require-dev": { - "psr/container": "^1.0", - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.3|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { "psr-4": { "Twig\\": "src/" @@ -5392,7 +5388,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.4.3" + "source": "https://github.com/twigphp/Twig/tree/v3.8.0" }, "funding": [ { @@ -5404,7 +5400,7 @@ "type": "tidelift" } ], - "time": "2022-09-28T08:42:51+00:00" + "time": "2023-11-21T18:54:41+00:00" }, { "name": "voku/anti-xss", diff --git a/src/base/ApplicationTrait.php b/src/base/ApplicationTrait.php index c9b700eba13..086faf1ec3a 100644 --- a/src/base/ApplicationTrait.php +++ b/src/base/ApplicationTrait.php @@ -707,9 +707,11 @@ public function getCanUpgradeEdition(): bool } /** - * Returns whether Craft is running on a domain that is eligible to test out the editions. + * Returns whether Craft is running on a domain that is eligible to test + * unlicensed Craft and plugin editions/updates. * * @return bool + * @internal */ public function getCanTestEditions(): bool { @@ -723,7 +725,12 @@ public function getCanTestEditions(): bool /** @var Cache $cache */ $cache = $this->getCache(); - return $cache->get(sprintf('editionTestableDomain@%s', $this->getRequest()->getHostName())); + $cacheKey = sprintf('editionTestableDomain@%s', $this->getRequest()->getHostName()); + if (!$cache->exists($cacheKey)) { + // err on the side of allowing it + return true; + } + return (bool)$cache->get($cacheKey); } /** diff --git a/src/base/Element.php b/src/base/Element.php index 30ed42beddc..542dfa9c50a 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -5182,6 +5182,11 @@ public function afterPropagate(bool $isNew): void $field->afterElementPropagate($this, $isNew); } + // Delete relations that don’t belong to a relational field on the element's field layout + if (!ElementHelper::isDraftOrRevision($this)) { + Craft::$app->getRelations()->deleteLeftoverRelations($this); + } + // Trigger an 'afterPropagate' event if ($this->hasEventHandlers(self::EVENT_AFTER_PROPAGATE)) { $this->trigger(self::EVENT_AFTER_PROPAGATE, new ModelEvent([ diff --git a/src/config/app.php b/src/config/app.php index 870cfa91267..342233c92d3 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -3,7 +3,7 @@ return [ 'id' => 'CraftCMS', 'name' => 'Craft CMS', - 'version' => '4.7.4', + 'version' => '4.8.0', 'schemaVersion' => '4.5.3.0', 'minVersionRequired' => '3.7.11', 'basePath' => dirname(__DIR__), // Defines the @app alias diff --git a/src/config/cproutes/common.php b/src/config/cproutes/common.php index 9d5bc0e1422..84d88f60a2b 100644 --- a/src/config/cproutes/common.php +++ b/src/config/cproutes/common.php @@ -19,6 +19,15 @@ 'entries///revisions' => 'elements/revisions', 'globals' => 'globals', 'globals/' => 'globals/edit-content', + 'graphiql' => 'graphql/graphiql', + 'graphql' => 'graphql/cp-index', + 'graphql/schemas' => 'graphql/view-schemas', + 'graphql/schemas/new' => 'graphql/edit-schema', + 'graphql/schemas/' => 'graphql/edit-schema', + 'graphql/schemas/public' => 'graphql/edit-public-schema', + 'graphql/tokens' => 'graphql/view-tokens', + 'graphql/tokens/new' => 'graphql/edit-token', + 'graphql/tokens/' => 'graphql/edit-token', 'myaccount' => [ 'route' => 'users/edit-user', 'defaults' => ['userId' => 'current'], diff --git a/src/config/cproutes/pro.php b/src/config/cproutes/pro.php index bd0ccc71c6a..c67f6543598 100644 --- a/src/config/cproutes/pro.php +++ b/src/config/cproutes/pro.php @@ -7,13 +7,4 @@ 'settings/users' => ['template' => 'settings/users/groups/_index'], 'settings/users/groups/new' => ['template' => 'settings/users/groups/_edit'], 'settings/users/groups/' => ['template' => 'settings/users/groups/_edit'], - 'graphiql' => 'graphql/graphiql', - 'graphql' => 'graphql/cp-index', - 'graphql/schemas' => 'graphql/view-schemas', - 'graphql/schemas/new' => 'graphql/edit-schema', - 'graphql/schemas/' => 'graphql/edit-schema', - 'graphql/schemas/public' => 'graphql/edit-public-schema', - 'graphql/tokens' => 'graphql/view-tokens', - 'graphql/tokens/new' => 'graphql/edit-token', - 'graphql/tokens/' => 'graphql/edit-token', ]; diff --git a/src/console/controllers/UpdateController.php b/src/console/controllers/UpdateController.php index 3eb5ee9d6f7..ac75b3f481e 100644 --- a/src/console/controllers/UpdateController.php +++ b/src/console/controllers/UpdateController.php @@ -42,6 +42,16 @@ class UpdateController extends Controller */ public $defaultAction = 'update'; + /** + * @var bool Whether to update expired licenses. + * + * NOTE: This will result in “License purchase required” messages in the control panel on public domains, + * until the licenses have been renewed. + * + * @since 4.8.0 + */ + public bool $withExpired = false; + /** * @var bool Force the update if allowUpdates is disabled */ @@ -65,6 +75,7 @@ public function options($actionID): array $options = parent::options($actionID); if ($actionID === 'update') { + $options[] = 'withExpired'; $options[] = 'force'; $options[] = 'backup'; $options[] = 'migrate'; @@ -336,8 +347,8 @@ private function _getRequirements(string ...$handles): array */ private function _updateRequirements(array &$requirements, array &$info, string $handle, string $from, ?string $to, string $oldPackageName, Update $update): void { - if ($update->status === Update::STATUS_EXPIRED) { - $this->stdout("Skipping $handle because its license has expired." . PHP_EOL, Console::FG_GREY); + if ($update->status === Update::STATUS_EXPIRED && !$this->withExpired) { + $this->stdout($this->markdownToAnsi("Skipping `$handle` because its license has expired. Run with `--with-expired` to update anyway.") . PHP_EOL); return; } diff --git a/src/controllers/AppController.php b/src/controllers/AppController.php index 1ae087de53e..1b2ddd90615 100644 --- a/src/controllers/AppController.php +++ b/src/controllers/AppController.php @@ -20,6 +20,7 @@ use craft\helpers\Cp; use craft\helpers\DateTimeHelper; use craft\helpers\Html; +use craft\helpers\Json; use craft\helpers\Update as UpdateHelper; use craft\helpers\UrlHelper; use craft\models\Update; @@ -30,6 +31,7 @@ use Throwable; use yii\base\InvalidConfigException; use yii\web\BadRequestHttpException; +use yii\web\Cookie; use yii\web\ForbiddenHttpException; use yii\web\Response; use yii\web\ServerErrorHttpException; @@ -392,6 +394,76 @@ public function actionShunCpAlert(): Response return $this->asFailure(Craft::t('app', 'A server error occurred.')); } + /** + * Displays a licensing issues takeover page. + * + * @param array $issues + * @param string $hash + * @return Response + * @internal + */ + public function actionLicensingIssues(array $issues, string $hash): Response + { + $this->requireCpRequest(); + + $consoleUrl = rtrim(Craft::$app->getPluginStore()->craftIdEndpoint, '/'); + $cartUrl = UrlHelper::urlWithParams("$consoleUrl/cart/new", [ + 'items' => array_map(fn($issue) => $issue[2], $issues), + ]); + + $cookie = $this->request->getCookies()->get(App::licenseShunCookieName()); + $data = $cookie ? Json::decode($cookie->value) : null; + if ($data['hash'] !== $hash) { + $data = null; + } + + $duration = match ($data['count'] ?? 0) { + 0 => 21, + 1 => 34, + 2 => 55, + 3 => 89, + 4 => 144, + 5 => 233, + 6 => 377, + 7 => 610, + 8 => 987, + default => 1597, + }; + + return $this->renderTemplate('_special/licensing-issues.twig', [ + 'issues' => $issues, + 'hash' => $hash, + 'cartUrl' => $cartUrl, + 'duration' => $duration, + ])->setStatusCode(402); + } + + /** + * Sets the license shun cookie. + * + * @return Response + * @internal + */ + public function actionSetLicenseShunCookie(): Response + { + $cookieName = App::licenseShunCookieName(); + $oldCookie = $this->request->getCookies()->get($cookieName); + $data = $oldCookie ? Json::decode($oldCookie->value) : []; + + $newCookie = new Cookie(Craft::cookieConfig([ + 'name' => $cookieName, + 'value' => Json::encode([ + 'hash' => $this->request->getRequiredBodyParam('hash'), + 'timestamp' => DateTimeHelper::toIso8601(DateTimeHelper::now()), + 'count' => ($data['count'] ?? 0) + 1, + ]), + 'expire' => DateTimeHelper::now()->modify('+1 year')->getTimestamp(), + ], $this->request)); + + $this->response->getCookies()->add($newCookie); + return $this->asSuccess(); + } + /** * Tries a Craft edition on for size. * @@ -524,6 +596,10 @@ private function _transformUpdate(bool $allowUpdates, Update $update, string $ha 'price' => Craft::$app->getFormatter()->asCurrency($update->renewalPrice, $update->renewalCurrency), ]); $arr['ctaUrl'] = UrlHelper::url($update->renewalUrl); + + if ($allowUpdates && Craft::$app->getCanTestEditions()) { + $arr['altCtaText'] = Craft::t('app', 'Update anyway'); + } } else { // Make sure that the platform & composer.json PHP version are compatible $phpConstraintError = null; diff --git a/src/controllers/GraphqlController.php b/src/controllers/GraphqlController.php index fa6913a3f59..10ccc962eb9 100644 --- a/src/controllers/GraphqlController.php +++ b/src/controllers/GraphqlController.php @@ -68,8 +68,6 @@ public function beforeAction($action): bool return false; } - Craft::$app->requireEdition(Craft::Pro); - return true; } diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index c12c7502ae4..74a940d7df3 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -2600,7 +2600,17 @@ private function _applyStructureParams(string $class): void } if ($this->level) { - $this->subQuery->andWhere(Db::parseNumericParam('structureelements.level', $this->level)); + $allowNull = is_array($this->level) && in_array(null, $this->level, true); + if ($allowNull) { + $levelCondition = [ + 'or', + Db::parseNumericParam('structureelements.level', array_filter($this->level, fn($v) => $v !== null)), + ['structureelements.level' => null], + ]; + } else { + $levelCondition = Db::parseNumericParam('structureelements.level', $this->level); + } + $this->subQuery->andWhere($levelCondition); } if ($this->leaves) { @@ -2776,9 +2786,11 @@ private function _applySearchParam(): void return; } - if (isset($this->orderBy['score'])) { + $searchService = Craft::$app->getSearch(); + + if (isset($this->orderBy['score']) || $searchService->shouldCallSearchElements($this)) { // Get the scored results up front - $searchResults = Craft::$app->getSearch()->searchElements($this); + $searchResults = $searchService->searchElements($this); if ($this->orderBy['score'] === SORT_ASC) { $searchResults = array_reverse($searchResults, true); @@ -2804,7 +2816,7 @@ private function _applySearchParam(): void $this->subQuery->andWhere(['elements.id' => array_keys($searchResults)]); } else { // Just filter the main query by the search query - $searchQuery = Craft::$app->getSearch()->createDbQuery($this->search, $this); + $searchQuery = $searchService->createDbQuery($this->search, $this); if ($searchQuery === false) { throw new QueryAbortedException(); diff --git a/src/elements/db/ElementQueryInterface.php b/src/elements/db/ElementQueryInterface.php index 3c0e727228e..31e459b65a5 100644 --- a/src/elements/db/ElementQueryInterface.php +++ b/src/elements/db/ElementQueryInterface.php @@ -1070,7 +1070,8 @@ public function structureId(?int $value = null): self; * | `1` | with a level of 1. * | `'not 1'` | not with a level of 1. * | `'>= 3'` | with a level greater than or equal to 3. - * | `[1, 2]` | with a level of 1 or 2 + * | `[1, 2]` | with a level of 1 or 2. + * | `[null, 1]` | without a level, or a level of 1. * | `['not', 1, 2]` | not with level of 1 or 2. * * --- diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index 221e6f17006..1b0d24cb982 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -695,7 +695,7 @@ public function modifyElementsQuery(ElementQueryInterface $query, mixed $value): $condition = $parser->parse([ 'targetElement' => $value, 'field' => $this->handle, - ]); + ], $query->siteId); if ($condition !== false) { $conditions[] = $condition; } diff --git a/src/fields/Color.php b/src/fields/Color.php index 0d67c626b6b..73b2d3abf20 100644 --- a/src/fields/Color.php +++ b/src/fields/Color.php @@ -46,6 +46,32 @@ public static function valueType(): string */ public ?string $defaultColor = null; + /** + * @var string[] Preset colors + * @since 4.8.0 + */ + public array $presets = []; + + /** + * @inheritdoc + */ + public function __construct($config = []) + { + if (isset($config['presets'])) { + $config['presets'] = array_values(array_filter(array_map( + fn($color) => is_array($color) ? $color['color'] : $color, + $config['presets'] + ))); + // Normalize afterward so empty strings have been filtered out + $config['presets'] = array_map( + fn(string $color) => ColorValidator::normalizeColor($color), + $config['presets'] + ); + } + + parent::__construct($config); + } + /** * @inheritdoc */ @@ -63,7 +89,29 @@ public function getSettingsHtml(): ?string 'name' => 'defaultColor', 'value' => $this->defaultColor, 'errors' => $this->getErrors('defaultColor'), - ]); + ]) . + Cp::editableTableFieldHtml([ + 'label' => Craft::t('app', 'Presets'), + 'name' => 'presets', + 'instructions' => Craft::t('app', 'Choose colors which should be recommended by the color picker.'), + 'cols' => [ + 'color' => [ + 'type' => 'color', + 'heading' => Craft::t('app', 'Color'), + ], + ], + 'rows' => array_map(fn(string $color) => compact('color'), $this->presets), + 'allowAdd' => true, + 'allowReorder' => true, + 'allowDelete' => true, + 'addRowLabel' => Craft::t('app', 'Add a color'), + 'inputContainerAttributes' => [ + 'style' => [ + 'max-width' => '15em', + ], + ], + 'errors' => $this->getErrors('presets'), + ]); } /** @@ -73,6 +121,18 @@ protected function defineRules(): array { $rules = parent::defineRules(); $rules[] = [['defaultColor'], ColorValidator::class]; + + $rules[] = [['presets'], function() { + $validator = new ColorValidator(); + foreach ($this->presets as $color) { + if (!$validator->validate($color, $error)) { + $this->addError('presets', Craft::t('yii', '{attribute} is invalid.', [ + 'attribute' => "#$color", + ])); + } + } + }]; + return $rules; } @@ -129,6 +189,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s 'describedBy' => $this->describedBy, 'name' => $this->handle, 'value' => $value?->getHex(), + 'presets' => $this->presets, ]); } diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index 4403b6bc66e..05d051f8955 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -945,6 +945,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false 'fieldId' => $this->id, 'allowOwnerDrafts' => true, 'allowOwnerRevisions' => true, + 'revisions' => null, ], ]; } diff --git a/src/helpers/Api.php b/src/helpers/Api.php index cca24ed5de0..8fdb0f31329 100644 --- a/src/helpers/Api.php +++ b/src/helpers/Api.php @@ -143,7 +143,8 @@ public static function processResponseHeaders(array $headers): void $cache = Craft::$app->getCache(); $duration = 31536000; if (isset($headers['x-craft-allow-trials'])) { - $cache->set('editionTestableDomain@' . Craft::$app->getRequest()->getHostName(), (bool)reset($headers['x-craft-allow-trials']), $duration); + $cacheKey = sprintf('editionTestableDomain@%s', Craft::$app->getRequest()->getHostName()); + $cache->set($cacheKey, (int)reset($headers['x-craft-allow-trials']), $duration); } // did we just get a new license key? diff --git a/src/helpers/App.php b/src/helpers/App.php index 0d17fab7104..8881b3aa28e 100644 --- a/src/helpers/App.php +++ b/src/helpers/App.php @@ -16,6 +16,8 @@ use craft\db\mysql\Schema as MysqlSchema; use craft\db\pgsql\Schema as PgsqlSchema; use craft\elements\User; +use craft\enums\LicenseKeyStatus; +use craft\errors\InvalidPluginException; use craft\errors\MissingComponentException; use craft\helpers\Session as SessionHelper; use craft\i18n\Locale; @@ -1204,4 +1206,220 @@ public static function createFormattingLocale(): Locale // Default to the application locale return Craft::$app->getLocale(); } + + /** + * Returns all known licensing issues. + * + * @param bool $withUnresolvables + * @param bool $fetch + * @return array{0:string,1:string,2:array|null}[] + * @internal + */ + public static function licensingIssues(bool $withUnresolvables = true, bool $fetch = false): array + { + $user = Craft::$app->getUser()->getIdentity(); + if (!$user) { + return []; + } + + $updatesService = Craft::$app->getUpdates(); + $cache = Craft::$app->getCache(); + $isInfoCached = $cache->exists('licenseInfo') && $updatesService->getIsUpdateInfoCached(); + + if (!$isInfoCached) { + if (!$fetch) { + return []; + } + + $updatesService->getUpdates(true); + } + + $issues = []; + + $allLicenseInfo = $cache->get('licenseInfo') ?: []; + $pluginsService = Craft::$app->getPlugins(); + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $consoleUrl = rtrim(Craft::$app->getPluginStore()->craftIdEndpoint, '/'); + + foreach ($allLicenseInfo as $handle => $licenseInfo) { + $isCraft = $handle === 'craft'; + if ($isCraft) { + $name = 'Craft'; + $editions = ['solo', 'pro']; + $currentEdition = Craft::$app->getEditionHandle(); + $currentEditionName = Craft::$app->getEditionName(); + $licenseEditionName = App::editionName(App::editionIdByHandle($licenseInfo['edition'] ?? 'solo')); + $version = Craft::$app->getVersion(); + } else { + if (!str_starts_with($handle, 'plugin-')) { + continue; + } + $handle = StringHelper::removeLeft($handle, 'plugin-'); + + try { + $pluginInfo = $pluginsService->getPluginInfo($handle); + } catch (InvalidPluginException) { + continue; + } + + $plugin = $pluginsService->getPlugin($handle); + if (!$plugin) { + continue; + } + + $name = $plugin->name; + $editions = $plugin::editions(); + $currentEdition = $pluginInfo['edition']; + $currentEditionName = ucfirst($currentEdition); + $licenseEditionName = ucfirst($licenseInfo['edition'] ?? 'standard'); + $version = $pluginInfo['version']; + } + + $isMultiEdition = count($editions) > 1; + + if ($licenseInfo['status'] === LicenseKeyStatus::Invalid) { + // invalid license + if ($withUnresolvables) { + $issues[] = [ + $name, + Craft::t('app', 'The {name} license is invalid.', ['name' => $name]), + null, + ]; + } + } elseif ($licenseInfo['status'] === LicenseKeyStatus::Trial) { + // trial license + $issues[] = [ + $isMultiEdition ? sprintf('%s %s', $name, $currentEditionName) : $name, + Craft::t('app', '{name} requires purchase.', ['name' => $name]), + array_filter([ + 'type' => $isCraft ? 'cms-edition' : 'plugin-edition', + 'plugin' => !$isCraft ? $handle : null, + 'licenseId' => $licenseInfo['id'], + 'edition' => $currentEdition, + ]), + ]; + } elseif ($licenseInfo['status'] === LicenseKeyStatus::Mismatched) { + if ($withUnresolvables) { + if ($isCraft) { + // wrong domain + $licensedDomain = $cache->get('licensedDomain'); + $domainLink = Html::a($licensedDomain, "http://$licensedDomain", [ + 'rel' => 'noopener', + 'target' => '_blank', + ]); + + if (defined('CRAFT_LICENSE_KEY')) { + $message = Craft::t('app', 'The Craft CMS license key in use belongs to {domain}', [ + 'domain' => $domainLink, + ]); + } else { + $keyPath = Craft::$app->getPath()->getLicenseKeyPath(); + + // If the license key path starts with the root project path, trim the project path off + $rootPath = Craft::getAlias('@root'); + if (strpos($keyPath, $rootPath . '/') === 0) { + $keyPath = substr($keyPath, strlen($rootPath) + 1); + } + + $message = Craft::t('app', 'The Craft CMS license located at {file} belongs to {domain}.', [ + 'file' => $keyPath, + 'domain' => $domainLink, + ]); + } + + $learnMoreLink = Html::a(Craft::t('app', 'Learn more'), 'https://craftcms.com/support/resolving-mismatched-licenses', [ + 'class' => 'go', + ]); + $issues[] = [$name, "$message $learnMoreLink", null]; + } else { + // wrong Craft install + $issues[] = [ + $name, + Craft::t('app', 'The {name} license is attached to a different Craft CMS license. You can detach it in Craft Console or buy a new license.', [ + 'name' => $name, + 'detachUrl' => "$consoleUrl/licenses/plugins/{$licenseInfo['id']}", + 'buyUrl' => $user->admin && $generalConfig->allowAdminChanges + ? UrlHelper::cpUrl("plugin-store/buy/$handle/$currentEdition") + : "https://plugins.craftcms.com/$handle", + ]), + null, + ]; + } + } + } elseif ($licenseInfo['edition'] !== $currentEdition) { + // wrong edition + $message = Craft::t('app', '{name} is licensed for the {licenseEdition} edition, but the {currentEdition} edition is installed.', [ + 'name' => $name, + 'licenseEdition' => $licenseEditionName, + 'currentEdition' => $currentEditionName, + ]); + $currentEditionIdx = array_search($currentEdition, $editions); + $licenseEditionIdx = array_search($licenseInfo['edition'], $editions); + if ($currentEditionIdx !== false && $licenseEditionIdx !== false && $currentEditionIdx > $licenseEditionIdx) { + $issues[] = [ + $isMultiEdition ? sprintf('%s %s', $name, $currentEditionName) : $name, + $message, + [ + 'type' => $isCraft ? 'cms-edition' : 'plugin-edition', + 'edition' => $currentEdition, + 'licenseId' => $licenseInfo['id'], + ], + ]; + } elseif ($withUnresolvables) { + if ($user->admin) { + if ($generalConfig->allowAdminChanges) { + $url = UrlHelper::cpUrl(sprintf('plugin-store/%s', $isCraft ? 'upgrade-craft' : $handle)); + $cta = Html::a(Craft::t('app', 'Resolve'), $url, ['class' => 'go']); + } else { + $cta = Craft::t('app', 'Please fix on an environment where administrative changes are allowed.'); + } + } else { + $cta = Craft::t('app', 'Please notify one of your site’s admins.'); + } + $issues[] = [$name, "$message $cta", null]; + } + } elseif ($licenseInfo['status'] === LicenseKeyStatus::Astray) { + // updated too far + $issues[] = [ + sprintf('%s %s', $name, $version), + Craft::t('app', '{name} isn’t licensed to run version {version}.', [ + 'name' => $name, + 'version' => $version, + ]), + array_filter([ + 'type' => $isCraft ? 'cms-renewal' : 'plugin-renewal', + 'plugin' => !$isCraft ? $handle : null, + 'licenseId' => $licenseInfo['id'], + ]), + ]; + } + } + + return $issues; + } + + /** + * Returns the license_shun cookie name. + * + * @return string + * @internal + */ + public static function licenseShunCookieName(): string + { + return sprintf('%s_license_shun', md5('Craft.' . WebUser::class . '.' . Craft::$app->id)); + } + + /** + * Returns a hash of the given licensing issues. + * + * @param array $issues + * @return string + * @internal + */ + public static function licensingIssuesHash(array $issues): string + { + $resolveItems = array_map(fn($issue) => Json::encode($issue[2]), $issues); + sort($resolveItems); + return md5(implode('', $resolveItems)); + } } diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index 4d2a0ab60c7..d06d228f2ab 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -13,9 +13,7 @@ use craft\base\FieldLayoutElement; use craft\behaviors\DraftBehavior; use craft\elements\Address; -use craft\enums\LicenseKeyStatus; use craft\errors\InvalidHtmlTagException; -use craft\errors\InvalidPluginException; use craft\events\DefineElementInnerHtmlEvent; use craft\events\RegisterCpAlertsEvent; use craft\fieldlayoutelements\BaseField; @@ -80,12 +78,11 @@ public static function renderTemplate(string $template, array $variables = []): * @param string|null $path * @param bool $fetch * @return array + * @internal */ public static function alerts(?string $path = null, bool $fetch = false): array { $alerts = []; - $resolvableLicenseAlerts = []; - $resolvableLicenseItems = []; $user = Craft::$app->getUser()->getIdentity(); $generalConfig = Craft::$app->getConfig()->getGeneral(); $consoleUrl = rtrim(Craft::$app->getPluginStore()->craftIdEndpoint, '/'); @@ -94,184 +91,49 @@ public static function alerts(?string $path = null, bool $fetch = false): array return $alerts; } - $updatesService = Craft::$app->getUpdates(); - $cache = Craft::$app->getCache(); - $isInfoCached = $cache->exists('licenseInfo') && $updatesService->getIsUpdateInfoCached(); - - if (!$isInfoCached && $fetch) { - $updatesService->getUpdates(true); - $isInfoCached = true; - } - - if ($isInfoCached) { - $allLicenseInfo = $cache->get('licenseInfo') ?: []; - $pluginsService = Craft::$app->getPlugins(); - $canTestEditions = Craft::$app->getCanTestEditions(); - - foreach ($allLicenseInfo as $handle => $licenseInfo) { - $isCraft = $handle === 'craft'; - if ($isCraft) { - $name = 'Craft CMS'; - $editions = ['solo', 'pro']; - $currentEdition = Craft::$app->getEditionHandle(); - $currentEditionName = Craft::$app->getEditionName(); - $licenseEditionName = App::editionName(App::editionIdByHandle($licenseInfo['edition'] ?? 'solo')); - $version = Craft::$app->getVersion(); - } else { - if (!str_starts_with($handle, 'plugin-')) { - continue; - } - $handle = StringHelper::removeLeft($handle, 'plugin-'); - - try { - $pluginInfo = $pluginsService->getPluginInfo($handle); - } catch (InvalidPluginException $e) { - continue; - } - - $plugin = $pluginsService->getPlugin($handle); - if (!$plugin) { - continue; - } - - $name = $plugin->name; - $editions = $plugin::editions(); - $currentEdition = $pluginInfo['edition']; - $currentEditionName = ucfirst($currentEdition); - $licenseEditionName = ucfirst($licenseInfo['edition'] ?? 'standard'); - $version = $pluginInfo['version']; - } + $canTestEditions = Craft::$app->getCanTestEditions(); + $resolvableLicenseAlerts = []; + $resolvableLicenseItems = []; - if ($licenseInfo['status'] === LicenseKeyStatus::Invalid) { - // invalid license - $alerts[] = Craft::t('app', 'The {name} license is invalid.', [ - 'name' => $name, - ]); - } elseif ($licenseInfo['status'] === LicenseKeyStatus::Trial && !$canTestEditions) { - // no trials allowed - $resolvableLicenseAlerts[] = Craft::t('app', '{name} requires purchase.', [ - 'name' => $name, - ]); - $resolvableLicenseItems[] = array_filter([ - 'type' => $isCraft ? 'cms-edition' : 'plugin-edition', - 'plugin' => !$isCraft ? $handle : null, - 'licenseId' => $licenseInfo['id'], - 'edition' => $currentEdition, - ]); - } elseif ($licenseInfo['status'] === LicenseKeyStatus::Mismatched) { - if ($isCraft) { - // wrong domain - $licensedDomain = $cache->get('licensedDomain'); - $domainLink = Html::a($licensedDomain, "http://$licensedDomain", [ - 'rel' => 'noopener', - 'target' => '_blank', - ]); - - if (defined('CRAFT_LICENSE_KEY')) { - $message = Craft::t('app', 'The Craft CMS license key in use belongs to {domain}', [ - 'domain' => $domainLink, - ]); - } else { - $keyPath = Craft::$app->getPath()->getLicenseKeyPath(); - - // If the license key path starts with the root project path, trim the project path off - $rootPath = Craft::getAlias('@root'); - if (strpos($keyPath, $rootPath . '/') === 0) { - $keyPath = substr($keyPath, strlen($rootPath) + 1); - } - - $message = Craft::t('app', 'The Craft CMS license located at {file} belongs to {domain}.', [ - 'file' => $keyPath, - 'domain' => $domainLink, - ]); - } - - $learnMoreLink = Html::a(Craft::t('app', 'Learn more'), 'https://craftcms.com/support/resolving-mismatched-licenses', [ - 'class' => 'go', - ]); - $alerts[] = "$message $learnMoreLink"; - } else { - // wrong Craft install - $alerts[] = Craft::t('app', 'The {name} license is attached to a different Craft CMS license. You can detach it in Craft Console or buy a new license.', [ - 'name' => $name, - 'detachUrl' => "$consoleUrl/licenses/plugins/{$licenseInfo['id']}", - 'buyUrl' => $user->admin && $generalConfig->allowAdminChanges - ? UrlHelper::cpUrl("plugin-store/buy/$handle/$currentEdition") - : "https://plugins.craftcms.com/$handle", - ]); - } - } elseif ($licenseInfo['edition'] !== $currentEdition && !$canTestEditions) { - // wrong edition - $message = Craft::t('app', '{name} is licensed for the {licenseEdition} edition, but the {currentEdition} edition is installed.', [ - 'name' => $name, - 'licenseEdition' => $licenseEditionName, - 'currentEdition' => $currentEditionName, - ]); - $currentEditionIdx = array_search($currentEdition, $editions); - $licenseEditionIdx = array_search($licenseInfo['edition'], $editions); - if ($currentEditionIdx !== false && $licenseEditionIdx !== false && $currentEditionIdx > $licenseEditionIdx) { - $resolvableLicenseAlerts[] = $message; - $resolvableLicenseItems[] = [ - 'type' => $isCraft ? 'cms-edition' : 'plugin-edition', - 'edition' => $currentEdition, - 'licenseId' => $licenseInfo['id'], - ]; - } else { - if ($user->admin) { - if ($generalConfig->allowAdminChanges) { - $url = UrlHelper::cpUrl(sprintf('plugin-store/%s', $isCraft ? 'upgrade-craft' : $handle)); - $cta = Html::a(Craft::t('app', 'Resolve'), $url, ['class' => 'go']); - } else { - $cta = Craft::t('app', 'Please fix on an environment where administrative changes are allowed.'); - } - } else { - $cta = Craft::t('app', 'Please notify one of your site’s admins.'); - } - $alerts[] = "$message $cta"; - } - } elseif ($licenseInfo['status'] === LicenseKeyStatus::Astray) { - // updated too far - $resolvableLicenseAlerts[] = Craft::t('app', '{name} isn’t licensed to run version {version}.', [ - 'name' => $name, - 'version' => $version, - ]); - $resolvableLicenseItems[] = array_filter([ - 'type' => $isCraft ? 'cms-renewal' : 'plugin-renewal', - 'plugin' => !$isCraft ? $handle : null, - 'licenseId' => $licenseInfo['id'], - ]); - } + foreach (App::licensingIssues(fetch: $fetch) as [$name, $message, $resolveItem]) { + if (!$resolveItem) { + $alerts[] = $message; + } elseif (!$canTestEditions) { + $resolvableLicenseAlerts[] = $message; + $resolvableLicenseItems[] = $resolveItem; } + } - if (!empty($resolvableLicenseAlerts)) { - $cartUrl = UrlHelper::urlWithParams("$consoleUrl/cart/new", [ - 'items' => $resolvableLicenseItems, - ]); - $alerts[] = [ - 'content' => Html::tag('h2', Craft::t('app', 'Licensing Issues')) . - Html::tag('p', Craft::t('app', 'The following licensing issues can be resolved with a single purchase on Craft Console.')) . - Html::ul($resolvableLicenseAlerts, [ - 'class' => 'errors', - ]) . - // can't use Html::a() because it's encoding &'s, which is causing issues - Html::beginTag('p', [ - 'class' => ['flex', 'flex-nowrap', 'resolvable-alert-buttons'], - ]) . - sprintf('%s', $cartUrl, Craft::t('app', 'Resolve now')) . - Html::endTag('p'), - 'showIcon' => false, - ]; - } + if (!empty($resolvableLicenseAlerts)) { + $cartUrl = UrlHelper::urlWithParams("$consoleUrl/cart/new", [ + 'items' => $resolvableLicenseItems, + ]); + array_unshift($alerts, [ + 'content' => Html::tag('h2', Craft::t('app', 'License purchase required.')) . + Html::tag('p', Craft::t('app', 'The following licensing {total, plural, =1{issue} other{issues}} can be resolved with a single purchase on Craft Console:', [ + 'total' => count($resolvableLicenseAlerts), + ])) . + Html::ul($resolvableLicenseAlerts, [ + 'class' => 'errors', + ]) . + // can't use Html::a() because it's encoding &'s, which is causing issues + Html::beginTag('p', [ + 'class' => ['flex', 'flex-nowrap', 'resolvable-alert-buttons'], + ]) . + sprintf('%s', $cartUrl, Craft::t('app', 'Resolve now')) . + Html::endTag('p'), + 'showIcon' => false, + ]); + } - // Critical update available? - if ( - $path !== 'utilities/updates' && - $user->can('utility:updates') && - $updatesService->getIsCriticalUpdateAvailable() - ) { - $alerts[] = Craft::t('app', 'A critical update is available.') . - ' ' . Craft::t('app', 'Go to Updates') . ''; - } + // Critical update available? + if ( + $path !== 'utilities/updates' && + $user->can('utility:updates') && + Craft::$app->getUpdates()->getIsCriticalUpdateAvailable() + ) { + $alerts[] = Craft::t('app', 'A critical update is available.') . + ' ' . Craft::t('app', 'Go to Updates') . ''; } // Display an alert if there are pending project config YAML changes diff --git a/src/helpers/Template.php b/src/helpers/Template.php index 2ff93e494f8..dcf4e5b77a5 100644 --- a/src/helpers/Template.php +++ b/src/helpers/Template.php @@ -270,7 +270,7 @@ private static function _profileToken(string $type, string $name, int $count): s public static function css(string $css, array $options = [], ?string $key = null): void { // Is this a CSS file? - if (preg_match('/^[^\r\n]+\.css$/i', $css) || UrlHelper::isAbsoluteUrl($css)) { + if (preg_match('/^[^\r\n]+\.css(\.gz)?$/i', $css) || UrlHelper::isAbsoluteUrl($css)) { Craft::$app->getView()->registerCssFile($css, $options, $key); } else { Craft::$app->getView()->registerCss($css, $options, $key); @@ -291,7 +291,7 @@ public static function css(string $css, array $options = [], ?string $key = null public static function js(string $js, array $options = [], ?string $key = null): void { // Is this a JS file? - if (preg_match('/^[^\r\n]+\.js$/i', $js) || UrlHelper::isAbsoluteUrl($js)) { + if (preg_match('/^[^\r\n]+\.js(\.gz)?$/i', $js) || UrlHelper::isAbsoluteUrl($js)) { Craft::$app->getView()->registerJsFile($js, $options, $key); } else { $position = $options['position'] ?? View::POS_READY; diff --git a/src/mail/transportadapters/Sendmail.php b/src/mail/transportadapters/Sendmail.php index 2b1e465acbf..25f51da7e02 100644 --- a/src/mail/transportadapters/Sendmail.php +++ b/src/mail/transportadapters/Sendmail.php @@ -100,9 +100,7 @@ public function getSettingsHtml(): ?string 'label' => $command, 'value' => $command, 'data' => [ - 'data' => [ - 'hint' => null, - ], + 'hint' => null, ], ]; }, $this->_allowedCommands()); @@ -119,7 +117,7 @@ public function getSettingsHtml(): ?string public function defineTransport(): array|AbstractTransport { // Replace any spaces with `%20` according to https://symfony.com/doc/current/mailer.html#other-options - $command = Html::encodeSpaces(App::parseEnv($this->command) ?: self::DEFAULT_COMMAND); + $command = Html::encodeSpaces(App::parseEnv($this->command) ?: ini_get('sendmail_path') ?: self::DEFAULT_COMMAND); return [ 'dsn' => 'sendmail://default?command=' . $command, @@ -139,8 +137,8 @@ private function _allowedCommands(): array return array_unique(array_filter([ !str_starts_with($command, '$') ? $command : null, - self::DEFAULT_COMMAND, ini_get('sendmail_path'), + self::DEFAULT_COMMAND, ])); } } diff --git a/src/queue/Command.php b/src/queue/Command.php index d350b42b2fb..32c255eb254 100644 --- a/src/queue/Command.php +++ b/src/queue/Command.php @@ -23,6 +23,12 @@ class Command extends \yii\queue\cli\Command { use ControllerTrait; + /** + * @var string The job ID to run + * @since 4.8.0 + */ + public ?string $jobId = null; + /** * @var Queue */ @@ -40,6 +46,18 @@ class Command extends \yii\queue\cli\Command 'class' => VerboseBehavior::class, ]; + /** + * @inheritdoc + */ + public function options($actionID) + { + $options = parent::options($actionID); + if ($actionID === 'run') { + $options[] = 'jobId'; + } + return $options; + } + /** * @inheritdoc */ @@ -65,6 +83,10 @@ public function actions(): array */ public function actionRun(): int { + if ($this->jobId) { + return $this->queue->executeJob($this->jobId) ? ExitCode::OK : ExitCode::UNSPECIFIED_ERROR; + } + return $this->queue->run() ?? ExitCode::OK; } diff --git a/src/services/Gql.php b/src/services/Gql.php index 11826de9e5f..99130a81f78 100644 --- a/src/services/Gql.php +++ b/src/services/Gql.php @@ -1636,6 +1636,10 @@ private function _getTagSchemaComponents(): array */ private function _getUserSchemaComponents(): array { + if (Craft::$app->edition !== Craft::Pro) { + return []; + } + $queryComponents = []; $userGroups = Craft::$app->getUserGroups()->getAllGroups(); diff --git a/src/services/ProjectConfig.php b/src/services/ProjectConfig.php index 8096d03e719..20f35d466db 100644 --- a/src/services/ProjectConfig.php +++ b/src/services/ProjectConfig.php @@ -200,6 +200,12 @@ class ProjectConfig extends Component */ public const EVENT_AFTER_APPLY_CHANGES = 'afterApplyChanges'; + /** + * @event Event The event that is triggered after the YAML files have been written out. + * @since 4.8.0 + */ + public const EVENT_AFTER_WRITE_YAML_FILES = 'afterWriteYamlFiles'; + /** * @event RebuildConfigEvent The event that is triggered when the project config is being rebuilt. * @@ -1627,6 +1633,9 @@ protected function updateYamlFiles(): void } Craft::$app->getCache()->delete(self::FILE_ISSUES_CACHE_KEY); + + // Let plugins know about it + $this->trigger(self::EVENT_AFTER_WRITE_YAML_FILES); } /** diff --git a/src/services/Relations.php b/src/services/Relations.php index eb89006903e..086733296b1 100644 --- a/src/services/Relations.php +++ b/src/services/Relations.php @@ -12,6 +12,7 @@ use craft\db\Command; use craft\db\Query; use craft\db\Table; +use craft\fieldlayoutelements\CustomField; use craft\fields\BaseRelationField; use craft\helpers\Db; use Throwable; @@ -125,4 +126,51 @@ public function saveRelations(BaseRelationField $field, ElementInterface $source } } } + + /** + * Deletes relations that don’t belong to a relational field on the given element’s field layout. + * + * @param ElementInterface $element + * @since 4.8.0 + */ + public function deleteLeftoverRelations(ElementInterface $element): void + { + if (!$element->id) { + return; + } + + $fieldLayout = $element->getFieldLayout(); + if (!$fieldLayout) { + return; + } + + $relationFieldIds = []; + foreach ($fieldLayout->getTabs() as $tab) { + foreach ($tab->getElements() as $layoutElement) { + if ($layoutElement instanceof CustomField) { + $field = $layoutElement->getField(); + if ($field instanceof BaseRelationField) { + $relationFieldIds[] = $field->id; + } + } + } + } + + // get those relations for the element that don't belong to any relational fields that are in the layout + $query = (new Query()) + ->select(['id']) + ->from(Table::RELATIONS) + ->where(['sourceId' => $element->id]); + + if (!empty($relationFieldIds)) { + $query->andWhere(['not', ['fieldId' => $relationFieldIds]]); + } + + $leftoverRelationIds = $query->column(); + + // if relations were returned - delete them + if (!empty($leftoverRelationIds)) { + Db::delete(Table::RELATIONS, ['id' => $leftoverRelationIds]); + } + } } diff --git a/src/services/Search.php b/src/services/Search.php index 6684ac90028..05237a58725 100644 --- a/src/services/Search.php +++ b/src/services/Search.php @@ -196,6 +196,22 @@ public function indexElementAttributes(ElementInterface $element, ?array $fieldH return true; } + /** + * Returns whether we should search for the resulting elements up front via [[searchElements()]], + * rather than supply a subquery which should be applied to the main element query via [[createDbQuery()]]. + * + * If the element query is being ordered by `score`, [[searchElements()]] will be called regardless of + * what this returns. + * + * @param ElementQuery $elementQuery + * @return bool + * @since 4.8.0 + */ + public function shouldCallSearchElements(ElementQuery $elementQuery): bool + { + return false; + } + /** * Searches for elements that match the given element query. * diff --git a/src/services/Webpack.php b/src/services/Webpack.php index d4c91151e11..f22afad55b4 100644 --- a/src/services/Webpack.php +++ b/src/services/Webpack.php @@ -128,7 +128,7 @@ private function _getEnvVars(string $class): ?array $envFile = $this->_getEnvFilePath($class); // TODO: Use DotEnv::parse() when we version is bumped. - $fileContents = file_exists($envFile) ? @file_get_contents($envFile) : null; + $fileContents = $envFile && file_exists($envFile) ? @file_get_contents($envFile) : null; if (!$fileContents) { return $this->_envFileVariables[$class] = []; diff --git a/src/templates/_elements/sources.twig b/src/templates/_elements/sources.twig index 7b5c964c994..5ccf428dc30 100644 --- a/src/templates/_elements/sources.twig +++ b/src/templates/_elements/sources.twig @@ -68,7 +68,7 @@ {{ '(blank)'|t('app') }} {% endif %} - {% if source.badgeCount is defined %} + {% if source.badgeCount ?? false %} {{ tag('span', { class: 'visually-hidden', diff --git a/src/templates/_includes/forms/button.twig b/src/templates/_includes/forms/button.twig index fe23285817c..90b2cdb8a8e 100644 --- a/src/templates/_includes/forms/button.twig +++ b/src/templates/_includes/forms/button.twig @@ -4,13 +4,14 @@ {% set retryMessage = retryMessage ?? false %} {% set successMessage = successMessage ?? false %} {% set label = label ?? null %} +{% set labelHtml = labelHtml ?? null %} {% set attributes = { type: type ?? 'button', id: id ?? false, class: (class ?? [])|explodeClass|merge([ 'btn', 'btngroup-btn-first', - not label ? 'btn-empty' : null, + not (label or labelHtml) ? 'btn-empty' : null, ]|filter), data: { 'busy-message': busyMessage, @@ -25,9 +26,10 @@
{% endif %} {% tag 'button' with attributes %} - {{ label ? tag('div', { - class: 'label', - text: label, + {{ (label or labelHtml) ? tag('div', { + class: ['label', 'inline-flex'], + text: label ?? null, + html: labelHtml ?? null }) }} {% if spinner %}
diff --git a/src/templates/_includes/forms/color.twig b/src/templates/_includes/forms/color.twig index c4a39863b06..1a078a9aa80 100644 --- a/src/templates/_includes/forms/color.twig +++ b/src/templates/_includes/forms/color.twig @@ -48,5 +48,7 @@ {% endapply -%} {% js %} - new Craft.ColorInput('#{{ containerId|namespaceInputId }}'); -{% endjs -%} \ No newline at end of file + new Craft.ColorInput('#{{ containerId|namespaceInputId }}', { + presets: {{ (presets ?? [])|json_encode|raw }}, + }); +{% endjs -%} diff --git a/src/templates/_layouts/cp.twig b/src/templates/_layouts/cp.twig index 7eabeb2868b..2df4ef1584e 100644 --- a/src/templates/_layouts/cp.twig +++ b/src/templates/_layouts/cp.twig @@ -300,22 +300,8 @@ {% if trialInfo %}
- {% if trialInfo.hasCraftTrial and trialInfo.trialPluginCount %} - {{ 'Craft Pro and {trialPluginCount, plural, =1{{name}} other{# plugins}} are installed as trials.'|t('app', { - trialPluginCount: trialInfo.trialPluginCount, - name: trialInfo.trialPluginNames|first, - }) }} - {% elseif trialInfo.hasCraftTrial %} - {{ 'Craft Pro is installed as a trial.'|t('app') }} - {% else %} - {{ '{trialPluginCount, plural, =1{{name} is installed as a trial} other{# plugins are installed as trials}}.'|t('app', { - trialPluginCount: trialInfo.trialPluginCount, - name: trialInfo.trialPluginNames|first, - }) }} - {% endif %} - {% set linkText = 'Purchase {total, plural, =1{license} other{licenses}}'|t('app', { - total: (trialInfo.hasCraftTrial ? 1 : 0) + trialInfo.trialPluginCount, - }) %} + {{ trialInfo.message }} + {% set linkText = 'Buy now'|t('app') %} {{ tag('a', { class: 'go', href: trialInfo.cartUrl, diff --git a/src/templates/_special/licensing-issues.twig b/src/templates/_special/licensing-issues.twig new file mode 100644 index 00000000000..60849d99d04 --- /dev/null +++ b/src/templates/_special/licensing-issues.twig @@ -0,0 +1,110 @@ +{% extends '_layouts/base' %} + +{% import '_includes/forms.twig' as forms %} + +{% set bodyClass = 'licensing-issues' %} +{% set title = 'License purchase required.'|t('app') %} + +{% do view.registerTranslations('app', [ + 'Continue to the control panel', + 'Continue to the control panel in {num, number} {num, plural, =1{second} other{seconds}}', +]) %} + +{% block body %} +
+
+

+ {{ 'License purchase required.'|t('app') }} +

+ +

+ {{ 'The following licensing {total, plural, =1{issue} other{issues}} can be resolved with a single purchase on Craft Console:'|t('app', { + total: issues|length, + }) }} +

+ +
    + {% for issue in issues %} +
  • {{ issue[1] }}
  • + {% endfor %} +
+ +
+ {% set linkText = 'Resolve now'|t('app') %} + {{ tag('a', { + class: ['go', 'btn', 'link-btn'], + href: cartUrl, + target: '_blank', + text: linkText, + aria: {label: linkText}, + }) }} + + {% set refreshLabel %} + {%- apply spaceless %} + + {{ 'Refresh'|t('app') }} + {% endapply -%} + {% endset %} + {{ forms.button({ + id: 'refresh-btn', + labelHtml: refreshLabel, + class: 'hairline', + spinner: true, + }) }} +
+
+
+

+{% endblock %} + +{% css %} + html { + height: 100%; + } +{% endcss %} + +{% js %} + const $refreshBtn = $('#refresh-btn'); + $refreshBtn.on('click', async () => { + $refreshBtn.addClass('loading'); + try { + await Craft.sendApiRequest('GET', 'ping'); + location.reload(); + } finally { + $refreshBtn.removeClass('loading'); + } + }); + + let duration = {{ duration|json_encode|raw }}; + const hash = {{ hash|json_encode|raw }}; + const $continue = $('#continue'); + const updateContinue = async () => { + if (duration) { + const message = Craft.t('app', 'Continue to the control panel in {duration, number} {duration, plural, =1{second} other{seconds}}…', { + duration, + }); + $continue.text(message); + setTimeout(() => { + duration--; + updateContinue(); + }, 1000); + } else { + await Craft.sendActionRequest('POST', 'app/set-license-shun-cookie', { + data: { + hash, + }, + }); + $continue + .empty() + .removeAttr('role') + .attr('aria-live', 'polite') + .append($('', { + text: Craft.t('app', 'Continue to the control panel'), + class: 'go', + href: document.location.href, + })); + } + }; + + updateContinue(); +{% endjs %} diff --git a/src/templates/graphql/schemas/_edit.twig b/src/templates/graphql/schemas/_edit.twig index 64c6bdd3570..b62194eb850 100644 --- a/src/templates/graphql/schemas/_edit.twig +++ b/src/templates/graphql/schemas/_edit.twig @@ -1,7 +1,5 @@ {% extends "_layouts/cp" %} -{% requireEdition CraftPro %} - {% set selectedSubnavItem = 'schemas' %} {% set fullPageForm = true %} diff --git a/src/templates/graphql/schemas/_index.twig b/src/templates/graphql/schemas/_index.twig index aed44c49037..a81092e8e85 100644 --- a/src/templates/graphql/schemas/_index.twig +++ b/src/templates/graphql/schemas/_index.twig @@ -1,8 +1,6 @@ {% extends "_layouts/cp" %} {% set title = "GraphQL Schemas"|t('app') %} -{% requireEdition CraftPro %} - {% do view.registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset') -%} {% set selectedSubnavItem = 'schemas' %} diff --git a/src/templates/graphql/tokens/_edit.twig b/src/templates/graphql/tokens/_edit.twig index 2021b4ee8a3..036ecf9f180 100644 --- a/src/templates/graphql/tokens/_edit.twig +++ b/src/templates/graphql/tokens/_edit.twig @@ -1,7 +1,5 @@ {% extends "_layouts/cp" %} -{% requireEdition CraftPro %} - {% set selectedSubnavItem = 'tokens' %} {% set fullPageForm = true %} diff --git a/src/templates/graphql/tokens/_index.twig b/src/templates/graphql/tokens/_index.twig index 3d3feaf9776..1e686f0320c 100644 --- a/src/templates/graphql/tokens/_index.twig +++ b/src/templates/graphql/tokens/_index.twig @@ -1,8 +1,6 @@ {% extends "_layouts/cp" %} {% set title = "GraphQL Tokens"|t('app') %} -{% requireEdition CraftPro %} - {% set selectedSubnavItem = 'tokens' %} {% set tokens = craft.app.gql.tokens %} diff --git a/src/translations/de-CH/app.php b/src/translations/de-CH/app.php index 7259350eb1d..97821f22ae3 100644 --- a/src/translations/de-CH/app.php +++ b/src/translations/de-CH/app.php @@ -259,7 +259,7 @@ 'Checkout' => 'Zur Kasse', 'Choose a currency…' => 'Währung auswählen…', 'Choose a new password' => 'Ein neues Passwort wählen', - 'Choose a page' => 'Choose a page', + 'Choose a page' => 'Eine Seite wählen', 'Choose a password' => 'Ein Passwort wählen', 'Choose a site' => 'Wähle eine Seite', 'Choose a user group that publicly-registered members will be added to by default.' => 'Wählen Sie eine Benutzergruppe, der öffentlich registrierte Mitglieder standardmässig hinzugefügt werden.', diff --git a/src/translations/en/app.php b/src/translations/en/app.php index 376822af7fb..39428f0298f 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -47,6 +47,7 @@ 'Add Row Label' => 'Add Row Label', 'Add a block' => 'Add a block', 'Add a category' => 'Add a category', + 'Add a color' => 'Add a color', 'Add a column' => 'Add a column', 'Add a filter' => 'Add a filter', 'Add a row' => 'Add a row', @@ -264,6 +265,7 @@ 'Choose a site' => 'Choose a site', 'Choose a user group that publicly-registered members will be added to by default.' => 'Choose a user group that publicly-registered members will be added to by default.', 'Choose a user' => 'Choose a user', + 'Choose colors which should be recommended by the color picker.' => 'Choose colors which should be recommended by the color picker.', 'Choose how the field should look for authors.' => 'Choose how the field should look for authors.', 'Choose the available content for querying with this schema:' => 'Choose the available content for querying with this schema:', 'Choose the available mutations for this schema:' => 'Choose the available mutations for this schema:', @@ -316,6 +318,8 @@ 'Context' => 'Context', 'Continue anyway' => 'Continue anyway', 'Continue shopping' => 'Continue shopping', + 'Continue to the control panel in {num, number} {num, plural, =1{second} other{seconds}}' => 'Continue to the control panel in {num, number} {num, plural, =1{second} other{seconds}}', + 'Continue to the control panel' => 'Continue to the control panel', 'Continue' => 'Continue', 'Control panel resources' => 'Control panel resources', 'Cookies must be enabled to access the Craft CMS control panel.' => 'Cookies must be enabled to access the Craft CMS control panel.', @@ -399,8 +403,6 @@ 'Craft CMS does not support backtracking to this version. Please update to Craft CMS {version} or later.' => 'Craft CMS does not support backtracking to this version. Please update to Craft CMS {version} or later.', 'Craft CMS edition changed.' => 'Craft CMS edition changed.', 'Craft CMS is running in Dev Mode.' => 'Craft CMS is running in Dev Mode.', - 'Craft Pro and {trialPluginCount, plural, =1{{name}} other{# plugins}} are installed as trials.' => 'Craft Pro and {trialPluginCount, plural, =1{{name}} other{# plugins}} are installed as trials.', - 'Craft Pro is installed as a trial.' => 'Craft Pro is installed as a trial.', 'Craft Support' => 'Craft Support', 'Craft isn’t installed yet.' => 'Craft isn’t installed yet.', 'Craft {version} Upgrade' => 'Craft {version} Upgrade', @@ -834,10 +836,10 @@ 'Letterbox' => 'Letterbox', 'Level {num}' => 'Level {num}', 'Level' => 'Level', + 'License purchase required.' => 'License purchase required.', 'License transferred.' => 'License transferred.', 'License' => 'License', 'Licensed' => 'Licensed', - 'Licensing Issues' => 'Licensing Issues', 'Lightswitch' => 'Lightswitch', 'Limit the number of selectable category branches.' => 'Limit the number of selectable category branches.', 'Limit the number of selectable {type} branches.' => 'Limit the number of selectable {type} branches.', @@ -1127,6 +1129,7 @@ 'Prefix Text' => 'Prefix Text', 'Prefix must be 5 or less characters long.' => 'Prefix must be 5 or less characters long.', 'Prefix' => 'Prefix', + 'Presets' => 'Presets', 'Prettify query' => 'Prettify query', 'Prettify' => 'Prettify', 'Prev' => 'Prev', @@ -1154,7 +1157,6 @@ 'Province' => 'Province', 'Pruning extra revisions' => 'Pruning extra revisions', 'Public Schema' => 'Public Schema', - 'Purchase {total, plural, =1{license} other{licenses}}' => 'Purchase {total, plural, =1{license} other{licenses}}', 'Pushing announcement to control panel users' => 'Pushing announcement to control panel users', 'Quality must be a number between 1 and 100 (included).' => 'Quality must be a number between 1 and 100 (included).', 'Quality' => 'Quality', @@ -1477,7 +1479,7 @@ 'The file “{name}” does not appear to be an image.' => 'The file “{name}” does not appear to be an image.', 'The filesystem doesn’t contain any files.' => 'The filesystem doesn’t contain any files.', 'The following aliases are defined:' => 'The following aliases are defined:', - 'The following licensing issues can be resolved with a single purchase on Craft Console.' => 'The following licensing issues can be resolved with a single purchase on Craft Console.', + 'The following licensing {total, plural, =1{issue} other{issues}} can be resolved with a single purchase on Craft Console:' => 'The following licensing {total, plural, =1{issue} other{issues}} can be resolved with a single purchase on Craft Console:', 'The following {items} could not be found or are empty. Should they be deleted from the index?' => 'The following {items} could not be found or are empty. Should they be deleted from the index?', 'The following {items} could not be found. Should they be deleted from the index?' => 'The following {items} could not be found. Should they be deleted from the index?', 'The image format that transformed images should use.' => 'The image format that transformed images should use.', @@ -1637,6 +1639,7 @@ 'URL Format' => 'URL Format', 'URL type' => 'URL type', 'URL' => 'URL', + 'Unable to fetch updates at this time.' => 'Unable to fetch updates at this time.', 'Unable to fetch upgrade info at this time.' => 'Unable to fetch upgrade info at this time.', 'Unable to find the template “{template}”.' => 'Unable to find the template “{template}”.', 'Unauthorized' => 'Unauthorized', @@ -1658,6 +1661,7 @@ 'Update YAML files' => 'Update YAML files', 'Update aborted.' => 'Update aborted.', 'Update all' => 'Update all', + 'Update anyway' => 'Update anyway', 'Update asset indexes' => 'Update asset indexes', 'Update status for individual sites' => 'Update status for individual sites', 'Update your project config YAML files to reflect the latest changes in the loaded project config.' => 'Update your project config YAML files to reflect the latest changes in the loaded project config.', @@ -1957,6 +1961,8 @@ '{attribute} should contain at most {max, number} {max, plural, one{selection} other{selections}}.' => '{attribute} should contain at most {max, number} {max, plural, one{selection} other{selections}}.', '{attribute} should contain {count, number} {count, plural, one{item} other{items}}.' => '{attribute} should contain {count, number} {count, plural, one{item} other{items}}.', '{attribute} “{value}” has already been taken.' => '{attribute} “{value}” has already been taken.', + '{count, spellout} plugin {count, plural, =1{update} other{updates}}' => '{count, spellout} plugin {count, plural, =1{update} other{updates}}', + '{count, spellout} {count, plural, =1{plugin} other{plugins}}' => '{count, spellout} {count, plural, =1{plugin} other{plugins}}', '{ctrl}C to copy.' => '{ctrl}C to copy.', '{description} (batch {index, number} of {total, number})' => '{description} (batch {index, number} of {total, number})', '{edition} edition' => '{edition} edition', @@ -1964,6 +1970,7 @@ '{filename} isn’t selectable for this field.' => '{filename} isn’t selectable for this field.', '{first, number}-{last, number} of {total, number} {total, plural, =1{{item}} other{{items}}}' => '{first, number}–{last, number} of {total, number} {total, plural, =1{{item}} other{{items}}}', '{first}-{last} of {total}' => '{first}–{last} of {total}', + '{names} {total, plural, =1{is installed as a trial} other{are installed as trials}}.' => '{names} {total, plural, =1{is installed as a trial} other{are installed as trials}}.', '{name} active, more info' => '{name} active, more info', '{name} folder' => '{name} folder', '{name} has been added, but an error occurred when installing it.' => '{name} has been added, but an error occurred when installing it.', @@ -1994,7 +2001,6 @@ '{totalItems, plural, =1{Item} other{Items}} moved.' => '{totalItems, plural, =1{Item} other{Items}} moved.', '{total} jobs' => '{total} jobs', '{total} updates available!' => '{total} updates available!', - '{trialPluginCount, plural, =1{{name} is installed as a trial} other{# plugins are installed as trials}}.' => '{trialPluginCount, plural, =1{{name} is installed as a trial} other{# plugins are installed as trials}}.', '{type} Condition' => '{type} Condition', '{type} Criteria' => '{type} Criteria', '{type} ID' => '{type} ID', diff --git a/src/web/Application.php b/src/web/Application.php index 8dae1f2ff4a..81f981d71a1 100644 --- a/src/web/Application.php +++ b/src/web/Application.php @@ -19,8 +19,10 @@ use craft\errors\ExitException; use craft\helpers\App; use craft\helpers\ArrayHelper; +use craft\helpers\DateTimeHelper; use craft\helpers\Db; use craft\helpers\FileHelper; +use craft\helpers\Json; use craft\helpers\Path; use craft\helpers\UrlHelper; use craft\queue\QueueLogBehavior; @@ -264,20 +266,35 @@ public function handleRequest($request, bool $skipSpecialHandling = false): Resp return $this->_processUpdateLogic($request) ?: $this->getResponse(); } - // If this is a plugin template request, make sure the user has access to the plugin - // If this is a non-login, non-validate, non-setPassword control panel request, make sure the user has access to the control panel - if ( - $request->getIsCpRequest() && - !$request->getIsActionRequest() && - ($firstSeg = $request->getSegment(1)) !== null && - ($plugin = $this->getPlugins()->getPlugin($firstSeg)) !== null - ) { - $user = $this->getUser(); - if ($user->getIsGuest()) { - return $user->loginRequired(); + if ($request->getIsCpRequest() && !$request->getIsActionRequest()) { + $userSession = $this->getUser(); + + // If this is a plugin template request, make sure the user has access to the plugin + // If this is a non-login, non-validate, non-setPassword control panel request, make sure the user has access to the control panel + if ( + ($firstSeg = $request->getSegment(1)) !== null && + ($plugin = $this->getPlugins()->getPlugin($firstSeg)) !== null + ) { + if ($userSession->getIsGuest()) { + return $userSession->loginRequired(); + } + if (!$userSession->checkPermission('accessPlugin-' . $plugin->id)) { + throw new ForbiddenHttpException(); + } } - if (!$user->checkPermission('accessPlugin-' . $plugin->id)) { - throw new ForbiddenHttpException(); + + if (!$userSession->getIsGuest() && !$this->getCanTestEditions()) { + // Are there are any licensing issues cached? + $licenseIssues = App::licensingIssues(false); + if (!empty($licenseIssues)) { + $hash = App::licensingIssuesHash($licenseIssues); + if ($this->_showLicensingIssuesScreen($hash)) { + return $this->runAction('app/licensing-issues', [ + 'issues' => $licenseIssues, + 'hash' => $hash, + ]); + } + } } } } @@ -296,6 +313,23 @@ public function handleRequest($request, bool $skipSpecialHandling = false): Resp } } + private function _showLicensingIssuesScreen(string $hash = null): bool + { + $cookie = $this->request->getCookies()->get(App::licenseShunCookieName()); + if (!$cookie) { + return true; + } + + // the cookie is only valid if it's for the same set of issues we're currently seeing + $data = Json::decode($cookie->value); + if ($data['hash'] !== $hash) { + return true; + } + + // if the cookie was created earlier today, let them pass + return !DateTimeHelper::isToday($data['timestamp']); + } + /** * @inheritdoc * @param string $route diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index 6560f0f68ea..1cd1261ea7d 100644 --- a/src/web/assets/cp/dist/cp.js +++ b/src/web/assets/cp/dist/cp.js @@ -1,3 +1,3 @@ /*! For license information please see cp.js.LICENSE.txt */ -(function(){var __webpack_modules__={463:function(){Craft.Accordion=Garnish.Base.extend({$trigger:null,targetSelector:null,_$target:null,init:function(t){var e=this;this.$trigger=$(t),this.$trigger.data("accordion")&&(console.warn("Double-instantiating an accordion trigger on an element"),this.$trigger.data("accordion").destroy()),this.$trigger.data("accordion",this),this.targetSelector=this.$trigger.attr("aria-controls")?"#".concat(this.$trigger.attr("aria-controls")):null,this.targetSelector&&(this._$target=$(this.targetSelector)),this.addListener(this.$trigger,"click","onTriggerClick"),this.addListener(this.$trigger,"keypress",(function(t){var i=t.keyCode;i!==Garnish.SPACE_KEY&&i!==Garnish.RETURN_KEY||(t.preventDefault(),e.onTriggerClick())}))},onTriggerClick:function(){"true"===this.$trigger.attr("aria-expanded")?this.hideTarget(this._$target):this.showTarget(this._$target)},showTarget:function(t){var e=this;if(t&&t.length){this.showTarget._currentHeight=t.height(),t.removeClass("hidden"),this.$trigger.removeClass("collapsed").addClass("expanded").attr("aria-expanded","true");for(var i=0;i .address-card");for(var s=0;s=this.settings.maxItems)){var e=$(t).appendTo(this.$tbody),i=e.find(".delete");this.settings.sortable&&this.sorter.addItems(e),this.$deleteBtns=this.$deleteBtns.add(i),this.addListener(i,"click","handleDeleteBtnClick"),this.totalItems++,this.updateUI()}},reorderItems:function(){var t=this;if(this.settings.sortable){for(var e=[],i=0;i=this.settings.maxItems?$(this.settings.newItemBtnSelector).addClass("hidden"):$(this.settings.newItemBtnSelector).removeClass("hidden"))}},{defaults:{tableSelector:null,noItemsSelector:null,newItemBtnSelector:null,idAttribute:"data-id",nameAttribute:"data-name",sortable:!1,allowDeleteAll:!0,minItems:0,maxItems:null,reorderAction:null,deleteAction:null,reorderSuccessMessage:Craft.t("app","New order saved."),reorderFailMessage:Craft.t("app","Couldn’t save new order."),confirmDeleteMessage:Craft.t("app","Are you sure you want to delete “{name}”?"),deleteSuccessMessage:Craft.t("app","“{name}” deleted."),deleteFailMessage:Craft.t("app","Couldn’t delete “{name}”."),onReorderItems:$.noop,onDeleteItem:$.noop}})},6872:function(){Craft.AssetImageEditor=Garnish.Modal.extend({$body:null,$footer:null,$imageTools:null,$buttons:null,$cancelBtn:null,$replaceBtn:null,$saveBtn:null,$focalPointBtn:null,$editorContainer:null,$straighten:null,$croppingCanvas:null,$spinner:null,$constraintContainer:null,$constraintRadioInputs:null,$customConstraints:null,canvas:null,image:null,viewport:null,focalPoint:null,grid:null,croppingCanvas:null,clipper:null,croppingRectangle:null,cropperHandles:null,cropperGrid:null,croppingShade:null,imageStraightenAngle:0,viewportRotation:0,originalWidth:0,originalHeight:0,imageVerticeCoords:null,zoomRatio:1,animationInProgress:!1,currentView:"",assetId:null,cacheBust:null,draggingCropper:!1,scalingCropper:!1,draggingFocal:!1,previousMouseX:0,previousMouseY:0,shiftKeyHeld:!1,editorHeight:0,editorWidth:0,cropperState:!1,scaleFactor:1,flipData:{},focalPointState:!1,maxImageSize:null,lastLoadedDimensions:null,imageIsLoading:!1,mouseMoveEvent:null,croppingConstraint:!1,constraintOrientation:"landscape",showingCustomConstraint:!1,saving:!1,renderImage:null,renderCropper:null,_queue:null,init:function(t,e){var i=this;this._queue=new Craft.Queue,this.cacheBust=Date.now(),this.setSettings(e,Craft.AssetImageEditor.defaults),null===this.settings.allowDegreeFractions&&(this.settings.allowDegreeFractions=Craft.isImagick),Garnish.prefersReducedMotion()&&(this.settings.animationDuration=1),this.assetId=t,this.flipData={x:0,y:0},this.$container=$('').appendTo(Garnish.$bod),this.$body=$('
').appendTo(this.$container),this.$footer=$('