Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG-5.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@
- Element editor slideouts can now be switched to other sites, by clicking on a site name within the “Sites” pane. ([#20061](https://github.com/craftcms/cms/pull/20061))
- Sites that are added to elements within the “Sites” pane can now be removed again. ([#20061](https://github.com/craftcms/cms/pull/20061))
- Element edit pages’ sidebar pane headings now line up with the labels within the panes. ([#20061](https://github.com/craftcms/cms/pull/20061))
- Assets are now initially saved to the site they were uploaded from. ([#20060](https://github.com/craftcms/cms/pull/20060))
- Assets in volumes set to let each asset choose which sites it should be saved to now have a “Sites” pane in their edit pages’ sidebars. ([#20060](https://github.com/craftcms/cms/pull/20060))
- Assets in volumes set to let each asset choose which sites it should be saved to can now be deleted for individual sites, as long as they exist in another site. ([#20060](https://github.com/craftcms/cms/pull/20060))
- Deleting an element for the only site it exists in now soft-deletes it, unless it’s a draft or nested element. ([#20060](https://github.com/craftcms/cms/pull/20060))

### Administration

- Custom fields now have “Default Tip” and “Default Warning” settings. ([#19976](https://github.com/craftcms/cms/pull/19976))
- Volumes now have a “Propagation Method” setting, which can be set to “Let each asset choose which sites it should be saved to”. New volumes default to this, as do existing volumes on single-site installs. Existing volumes on multi-site installs continue to save assets to all sites. ([#20060](https://github.com/craftcms/cms/pull/20060))
- A rate limit is now enforced for `users/login`, `auth/verify-totp`, and `auth/verify-recovery-code` requests. ([#20054](https://github.com/craftcms/cms/pull/20054))
- Added the `defaultRateLimit` and `defaultRateLimitDuration` config settings, which configure the rate limits for `users/login`, `users/send-password-reset-email`, `auth/verify-totp`, and `auth/verify-recovery-code` requests. ([#20054](https://github.com/craftcms/cms/pull/20054))

Expand Down Expand Up @@ -53,12 +58,14 @@
- Added `craft\gql\types\Volume`. ([#20055](https://github.com/craftcms/cms/pull/20055))
- Added `craft\helpers\Template::render()`. ([#19988](https://github.com/craftcms/cms/pull/19988))
- Added `craft\i18n\Locale::languageId()`.
- Added `craft\models\Volume::$propagationMethod`. ([#20060](https://github.com/craftcms/cms/pull/20060))
- Added `craft\services\Images::getSupportsBmp()`.
- Added `craft\services\Images::getSupportsJxl()`.
- Added `craft\test\ElementFixtureTrait`. ([#19626](https://github.com/craftcms/cms/pull/19626))
- `Illuminate\Support\Collection` now has a `render()` macro, which replaces `craft\elements\ElementCollection::render()`. ([#19988](https://github.com/craftcms/cms/pull/19988))
- `craft\helpers\ElementHelper::normalizeSlug()` now has a `$language` argument, which defaults to the current application language. ([#19558](https://github.com/craftcms/cms/pull/19558))
- `craft\helpers\StringHelper::toLowerCase()`, `::toTitleCase()`, and `::toUpperCase()` now have `$language` arguments, which default to the current application language. ([#19558](https://github.com/craftcms/cms/pull/19558))
- `craft\services\Assets::getAssetById()` now returns the asset in another site if it doesn’t exist in the current site, when no site ID is passed. ([#20060](https://github.com/craftcms/cms/pull/20060))
- `elements/save-draft` action responses now include a `canDeleteForSite` key. ([#20061](https://github.com/craftcms/cms/pull/20061))
- Deprecated `craft\helpers\ElementHelper::renderElements()`. `craft\helpers\Template::render()` should be used instead. ([#19988](https://github.com/craftcms/cms/pull/19988))

Expand All @@ -74,5 +81,6 @@
- Updated Twig to 3.30.
- Updated Fabric.js to v7. ([#19628](https://github.com/craftcms/cms/pull/19628))
- Fixed a bug where entry and address indexes weren’t showing any results if they had a “Field” condition rule set to “is empty”.
- Fixed a bug where `craft\services\Elements::deleteElementsForSite()` would remove elements’ site rows without deleting the elements, on single-site installs. ([#20060](https://github.com/craftcms/cms/pull/20060))
- Fixed a bug where saving a deeply-nested element on its own wouldn’t update its owners’ `dateUpdated` timestamps, which could cause new revisions to reuse stale nested content. ([#19594](https://github.com/craftcms/cms/issues/19594))
- Fixed a bug where deleting an element for a site while editing a provisional draft would delete the provisional draft for all sites. ([#20061](https://github.com/craftcms/cms/pull/20061))
2 changes: 1 addition & 1 deletion src/config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
'id' => 'CraftCMS',
'name' => 'Craft CMS',
'version' => '5.11.5.1',
'schemaVersion' => '5.12.0.0',
'schemaVersion' => '5.12.0.1',
'minVersionRequired' => '4.5.0',
'basePath' => dirname(__DIR__), // Defines the @app alias
'runtimePath' => '@storage/runtime', // Defines the @runtime alias
Expand Down
2 changes: 1 addition & 1 deletion src/console/controllers/IndexAssetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ private function _indexAssets(array $volumes, string $path = '', int $startAt =
$this->stdout('Deleting the' . ($totalMissingFiles > 1 ? ' ' . $totalMissingFiles : '') . ' missing asset record' . ($totalMissingFiles > 1 ? 's' : '') . ' ... ');

/** @var Asset[] $assets */
$assets = Asset::find()->id($assetIds)->all();
$assets = Asset::find()->id($assetIds)->site('*')->unique()->all();
foreach ($assets as $asset) {
Craft::$app->getImageTransforms()->deleteCreatedTransformsForAsset($asset);
$asset->keepFileOnDelete = true;
Expand Down
2 changes: 1 addition & 1 deletion src/console/controllers/utils/AsciiFilenamesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function actionIndex(): int
}

// Find assets with non-ASCII characters
$query = Asset::find();
$query = Asset::find()->site('*')->unique();
if (Craft::$app->getDb()->getIsMysql()) {
// h/t https://stackoverflow.com/a/11741314/1688568
$query->andWhere(new Expression('[[filename]] <> CONVERT([[filename]] USING ASCII)'));
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/AssetIndexesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ public function actionFinishIndexingSession(): Response
$assets = Asset::find()
->status(null)
->id($deleteFiles)
->site('*')
->unique()
->all();

foreach ($assets as $asset) {
Expand Down
47 changes: 36 additions & 11 deletions src/controllers/AssetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public function actionPreviewThumb(): Response
throw new BadRequestHttpException('Invalid asset ID.');
}

$asset = Asset::findOne((int)$assetId);
$asset = Craft::$app->getAssets()->getAssetById((int)$assetId);
if ($asset === null) {
throw new BadRequestHttpException("Invalid asset ID: $assetId");
}
Expand Down Expand Up @@ -265,6 +265,7 @@ public function actionUpload(): Response

$folderId = (int)$this->request->getBodyParam('folderId') ?: null;
$fieldId = (int)$this->request->getBodyParam('fieldId') ?: null;
$siteId = (int)$this->request->getBodyParam('siteId') ?: null;

if (!$folderId && !$fieldId) {
throw new BadRequestHttpException('No target destination provided for uploading');
Expand All @@ -283,7 +284,6 @@ public function actionUpload(): Response
}

if ($elementId = $this->request->getBodyParam('elementId')) {
$siteId = $this->request->getBodyParam('siteId') ?: null;
$element = $elementsService->getElementById($elementId, null, $siteId);
} else {
$element = null;
Expand Down Expand Up @@ -333,6 +333,11 @@ public function actionUpload(): Response
$asset->uploaderId = Craft::$app->getUser()->getId();
$asset->avoidFilenameConflicts = true;

// Save the asset in the site it was uploaded from
if ($siteId && in_array($siteId, Craft::$app->getSites()->getEditableSiteIds(), true)) {
$asset->siteId = $siteId;
}

if (isset($originalFilename)) {
$asset->title = Assets::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME));
}
Expand Down Expand Up @@ -375,7 +380,13 @@ public function actionUpload(): Response
}

if ($asset->conflictingFilename !== null) {
$conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]);
$conflictingAsset = Asset::find()
->folderId($folder->id)
->filename(Db::escapeParam($asset->conflictingFilename))
->site('*')
->preferSites([Craft::$app->getSites()->getCurrentSite()->id])
->unique()
->one();

return $this->asJson([
'conflict' => Craft::t('app', 'A file with the name “{filename}” already exists.', ['filename' => $asset->conflictingFilename]),
Expand Down Expand Up @@ -474,6 +485,8 @@ public function actionReplaceFile(): Response
->select(['elements.id'])
->folderId($sourceAsset->folderId)
->filename(Db::escapeParam($targetFilename))
->site('*')
->unique()
->one();

if ($assetToReplace) {
Expand Down Expand Up @@ -716,6 +729,8 @@ public function actionMoveAsset(): Response
->select(['elements.id'])
->folderId($folderId)
->filename(Db::escapeParam($asset->getFilename()))
->site('*')
->unique()
->one();

// If there's a conflicting asset, then merge and replace the file.
Expand Down Expand Up @@ -825,6 +840,8 @@ public function actionMoveFolder(): Response
/** @var Asset[] $foundAssets */
$foundAssets = Asset::find()
->folderId($allSourceFolderIds)
->site('*')
->unique()
->all();
$fileTransferList = Assets::fileTransferList($foundAssets, $folderIdChanges);
} else {
Expand Down Expand Up @@ -864,6 +881,8 @@ public function actionMoveFolder(): Response
/** @var Asset[] $foundAssets */
$foundAssets = Asset::find()
->folderId($allSourceFolderIds)
->site('*')
->unique()
->all();
$fileTransferList = Assets::fileTransferList($foundAssets, $folderIdChanges);
}
Expand Down Expand Up @@ -923,7 +942,7 @@ public function actionEditImage(): Response
$assetId = (int)$this->request->getRequiredQueryParam('assetId');
$size = (int)$this->request->getRequiredQueryParam('size');

$asset = Asset::findOne($assetId);
$asset = Craft::$app->getAssets()->getAssetById($assetId);
if (!$asset) {
throw new BadRequestHttpException("Invalid asset ID: $asset");
}
Expand Down Expand Up @@ -1140,6 +1159,9 @@ public function actionDownloadAsset(): Response
/** @var Asset[] $assets */
$assets = Asset::find()
->id($assetIds)
->site('*')
->preferSites([Craft::$app->getSites()->getCurrentSite()->id])
->unique()
->all();

if (empty($assets)) {
Expand Down Expand Up @@ -1241,7 +1263,7 @@ public function actionGenerateTransform(?int $transformId = null): Response
$transformer = $transform->getImageTransformer();
}

$asset = Asset::findOne(['id' => $assetId]);
$asset = Craft::$app->getAssets()->getAssetById((int)$assetId);

if (!$asset) {
throw new BadRequestHttpException("Invalid asset ID: $assetId");
Expand Down Expand Up @@ -1270,8 +1292,7 @@ public function actionPreviewFile(): Response
$assetId = $this->request->getRequiredParam('assetId');
$requestId = $this->request->getRequiredParam('requestId');

/** @var Asset|null $asset */
$asset = Asset::find()->id($assetId)->one();
$asset = Craft::$app->getAssets()->getAssetById((int)$assetId);

if (!$asset) {
return $this->asFailure(Craft::t('app', 'Asset not found with that id'));
Expand Down Expand Up @@ -1340,7 +1361,12 @@ public function actionUpdateFocalPosition(): Response
}

/** @var Asset|null $asset */
$asset = Asset::find()->uid($assetUid)->one();
$asset = Asset::find()
->uid($assetUid)
->site('*')
->preferSites([Craft::$app->getSites()->getCurrentSite()->id])
->unique()
->one();

if (!$asset) {
throw new BadRequestHttpException("Invalid asset UID: $assetUid");
Expand Down Expand Up @@ -1421,8 +1447,7 @@ public function actionGenerateFallbackTransform(string $transform): Response

[$assetId, $transformString] = explode(',', $transform, 2);

/** @var Asset|null $asset */
$asset = Asset::find()->id($assetId)->one();
$asset = Craft::$app->getAssets()->getAssetById((int)$assetId);
if (!$asset) {
throw new BadRequestHttpException("Invalid asset ID: $assetId");
}
Expand Down Expand Up @@ -1503,7 +1528,7 @@ public function actionShowInFolder(): Response
throw new BadRequestHttpException('Invalid asset ID.');
}

$asset = Asset::findOne((int)$assetId);
$asset = Craft::$app->getAssets()->getAssetById((int)$assetId);
if ($asset === null) {
throw new BadRequestHttpException("Invalid asset ID: $assetId");
}
Expand Down
3 changes: 3 additions & 0 deletions src/controllers/VolumesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use craft\base\Field;
use craft\base\FsInterface;
use craft\elements\Asset;
use craft\enums\PropagationMethod;
use craft\helpers\Assets;
use craft\helpers\Cp;
use craft\helpers\FileHelper;
Expand Down Expand Up @@ -197,6 +198,8 @@ public function actionSaveVolume(): ?Response
'titleTranslationKeyFormat' => $this->request->getBodyParam('titleTranslationKeyFormat'),
'altTranslationMethod' => $this->request->getBodyParam('altTranslationMethod', Field::TRANSLATION_METHOD_NONE),
'altTranslationKeyFormat' => $this->request->getBodyParam('altTranslationKeyFormat'),
'propagationMethod' => PropagationMethod::tryFrom($this->request->getBodyParam('propagationMethod') ?? '')
?? $oldVolume->propagationMethod ?? PropagationMethod::Custom,
]);

// Set the field layout
Expand Down
87 changes: 87 additions & 0 deletions src/elements/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use craft\elements\actions\CopyReferenceTag;
use craft\elements\actions\CopyUrl;
use craft\elements\actions\DeleteAssets;
use craft\elements\actions\DeleteForSite;
use craft\elements\actions\DownloadAssetFile;
use craft\elements\actions\EditImage;
use craft\elements\actions\MoveAssets;
Expand All @@ -38,6 +39,7 @@
use craft\elements\db\ElementQueryInterface;
use craft\enums\CmsEdition;
use craft\enums\MenuItemType;
use craft\enums\PropagationMethod;
use craft\errors\AssetException;
use craft\errors\FileException;
use craft\errors\FsException;
Expand Down Expand Up @@ -543,6 +545,15 @@ protected static function defineActions(string $source): array
if ($userSession->checkPermission("deletePeerAssets:$volume->uid")) {
$actions[] = DeleteAssets::class;
}

// Delete for site
if (
$volume->propagationMethod === PropagationMethod::Custom &&
Craft::$app->getIsMultiSite() &&
$userSession->checkPermission("deleteAssets:$volume->uid")
) {
$actions[] = DeleteForSite::class;
}
}

return $actions;
Expand Down Expand Up @@ -1522,6 +1533,68 @@ public function canDelete(User $user): bool
return $user->can("deleteAssets:$volume->uid");
}

/**
* @inheritdoc
*/
public function canDeleteForSite(User $user): bool
{
if ($this->isFolder || $this->getVolume()->propagationMethod !== PropagationMethod::Custom) {
return parent::canDeleteForSite($user);
}

// Assets that only exist in this site will be deleted normally, so require normal delete permissions
if (!ElementHelper::isMultiSite($this)) {
return $this->canDelete($user);
}

return parent::canDeleteForSite($user) || $this->canDelete($user);
}

/**
* @inheritdoc
*/
protected function hasCustomPropagation(): bool
{
return !$this->isFolder && $this->getVolume()->propagationMethod === PropagationMethod::Custom;
}

/**
* @inheritdoc
* @since 5.12.0
*/
public function getSupportedSites(): array
{
$siteIds = parent::getSupportedSites();

if ($this->getVolume()->propagationMethod !== PropagationMethod::Custom) {
return $siteIds;
}

// Figure out which sites the asset is currently saved in
$currentSites = [];
$sourceIds = array_filter([$this->id, $this->duplicateOf->id ?? null]);

if (!empty($sourceIds)) {
$currentSites = array_flip(self::find()
->status(null)
->id($sourceIds)
->site('*')
->select('elements_sites.siteId')
->column());
}

return array_map(fn(int $siteId) => [
'siteId' => $siteId,
// Only actually propagate to this site if it's the current site, or the asset has been assigned
// a status for this site, or the asset already exists for this site
'propagate' => (
$siteId === $this->siteId ||
$this->getEnabledForSite($siteId) !== null ||
isset($currentSites[$siteId])
),
], $siteIds);
}

/**
* @inheritdoc
* ---
Expand Down Expand Up @@ -3497,6 +3570,20 @@ public function afterDelete(): void
parent::afterDelete();
}

/**
* @inheritdoc
*/
public function afterDeleteForSite(): void
{
// Delete the site-specific asset data
Db::delete(Table::ASSETS_SITES, [
'assetId' => $this->id,
'siteId' => $this->siteId,
]);

parent::afterDeleteForSite();
}

/**
* @inheritdoc
*/
Expand Down
1 change: 1 addition & 0 deletions src/migrations/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,7 @@ public function createTables(): void
'titleTranslationKeyFormat' => $this->text(),
'altTranslationMethod' => $this->string()->notNull()->defaultValue(Field::TRANSLATION_METHOD_SITE),
'altTranslationKeyFormat' => $this->text(),
'propagationMethod' => $this->string()->defaultValue(PropagationMethod::Custom->value)->notNull(),
'sortOrder' => $this->smallInteger()->unsigned(),
'dateCreated' => $this->dateTime()->notNull(),
'dateUpdated' => $this->dateTime()->notNull(),
Expand Down
Loading
Loading