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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public function bootstrap($app): void
Asset::class,
Asset::EVENT_BEFORE_GENERATE_TRANSFORM,
function(GenerateTransformEvent $event) {
if (!$event->transform || !$event->asset?->fs instanceof AssetsFs) {
if (!$this->shouldUseAssetCdnTransform($event)) {
return;
}

Expand All @@ -119,6 +119,21 @@ function(GenerateTransformEvent $event) {
}
}

protected function shouldUseAssetCdnTransform(GenerateTransformEvent $event): bool
{
if (!$event->transform || !$event->asset?->fs instanceof AssetsFs) {
return false;
}

if (!(Craft::$app instanceof WebApplication)) {
return true;
}

// The image editor reads raw source pixels for save/crop math, so keep
// its preview in the same coordinate space as the original asset.
return Craft::$app->getRequest()->getActionSegments() !== ['assets', 'edit-image'];
}

public function getConfig(): Config
{
if (isset($this->_config)) {
Expand Down
62 changes: 50 additions & 12 deletions src/controllers/AssetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use craft\models\Volume;
use craft\web\Controller;
use DateTime;
use Throwable;
use yii\base\Event;
use yii\base\Exception;
use yii\base\Model;
Expand Down Expand Up @@ -94,9 +95,6 @@ public function actionCreateAsset(): Response
$filename = $this->request->getRequiredBodyParam('filename');
$originalFilename = $this->request->getRequiredBodyParam('originalFilename');
$targetFilename = $this->request->getRequiredBodyParam('targetFilename');
$size = $this->request->getBodyParam('size');
$width = $this->request->getBodyParam('width');
$height = $this->request->getBodyParam('height');
$elementsService = Craft::$app->getElements();
$lastModifiedMs = (int) $this->request->getBodyParam('lastModified');
$dateModified = $lastModifiedMs
Expand Down Expand Up @@ -159,9 +157,6 @@ public function actionCreateAsset(): Response
$asset->uploaderId = Craft::$app->getUser()->getId();
$asset->avoidFilenameConflicts = true;
$asset->dateModified = $dateModified;
$asset->size = $size;
$asset->width = $width;
$asset->height = $height;

// Setting newFolderId, so that extension validation on newLocation occurs
$asset->newFolderId = $folder->id;
Expand All @@ -172,6 +167,8 @@ public function actionCreateAsset(): Response
// Handle special characters that have been encoded from the presigned URL
$asset->folderPath = is_string($folder->path) ? Fs::urlEncodePathSegments($folder->path) : $asset->folderPath;

$this->setUploadedAssetMetadata($asset, $filename, $originalFilename);

if (!$selectionCondition) {
$asset->newFilename = $targetFilename;
}
Expand Down Expand Up @@ -239,9 +236,6 @@ public function actionReplaceFile(): Response
$sourceAssetId = $this->request->getBodyParam('sourceAssetId');
$filename = $this->request->getBodyParam('filename');
$targetFilename = $this->request->getBodyParam('targetFilename');
$size = $this->request->getBodyParam('size');
$width = $this->request->getBodyParam('width');
$height = $this->request->getBodyParam('height');
$lastModifiedMs = (int) $this->request->getBodyParam('lastModified');
$dateModified = $lastModifiedMs
? DateTime::createFromFormat('U', (string) floor($lastModifiedMs / 1000))
Expand Down Expand Up @@ -272,9 +266,7 @@ public function actionReplaceFile(): Response

// Handle the Element Action
if ($assetToReplace !== null && $filename) {
$assetToReplace->width = $width;
$assetToReplace->height = $height;
$assetToReplace->size = $size;
$this->setUploadedAssetMetadata($assetToReplace, $filename, $targetFilename);
$assetToReplace->dateModified = $dateModified;
if (!$this->replaceAssetFile($assetToReplace, $filename, $targetFilename)) {
throw new Exception('Unable to replace asset.');
Expand Down Expand Up @@ -396,4 +388,50 @@ private function volumeSubpath(Volume $volume): string
{
return method_exists($volume, 'getSubpath') ? $volume->getSubpath() : '';
}

protected function setUploadedAssetMetadata(Asset $asset, string $filename, ?string $displayFilename = null): void
{
try {
$asset->size = $this->uploadedAssetSize($asset, $filename, $displayFilename);
[$width, $height] = $this->uploadedImageDimensions($asset, $filename);
$asset->width = $width;
$asset->height = $height;
} catch (Throwable $e) {
$this->deleteUploadedAsset($asset, $filename);
throw $e;
}
}

protected function uploadedAssetSize(Asset $asset, string $filename, ?string $displayFilename = null): int
{
$size = $asset->getVolume()->getFileSize($asset->getPath($filename));
$maxUploadSize = Craft::$app->getConfig()->getGeneral()->maxUploadFileSize;

if ($maxUploadSize && $size > $maxUploadSize) {
throw new BadRequestHttpException(Craft::t('app', '“{filename}” is too large.', [
'filename' => $displayFilename ?: $filename,
]));
}

return $size;
}

protected function uploadedImageDimensions(Asset $asset, string $filename): array
{
$fs = $asset->getVolume()->getFs();

// Null dimensions are safer than browser-oriented dimensions for EXIF
// images, but they can still prevent image-editor use until reindexed.
return $fs instanceof Fs
? $fs->getImageDimensions($asset->getPath($filename)) ?? [null, null]
: [null, null];
}

private function deleteUploadedAsset(Asset $asset, string $filename): void
{
try {
$asset->getVolume()->deleteFile($asset->getPath($filename));
} catch (Throwable) {
}
}
}
117 changes: 116 additions & 1 deletion src/fs/Fs.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
use craft\cloud\Module;
use craft\cloud\StaticCache;
use craft\cloud\StaticCacheTag;
use craft\elements\Asset;
use craft\errors\FsException;
use craft\flysystem\base\FlysystemFs;
use craft\fs\Local;
use craft\helpers\App;
use craft\helpers\Assets;
use craft\helpers\DateTimeHelper;
use craft\helpers\Image;
use DateTime;
use DateTimeInterface;
use Generator;
Expand All @@ -37,6 +39,11 @@
*/
abstract class Fs extends FlysystemFs
{
// Most image headers are much smaller, but large EXIF/XMP/ICC blocks can
// push JPEG dimensions farther in. Stay bounded to avoid full downloads.
private const IMAGE_DIMENSION_INITIAL_BYTES = 1024 * 1024;
private const IMAGE_DIMENSION_MAX_BYTES = 8 * 1024 * 1024;

protected static bool $showUrlSetting = false;
protected ?string $expires = null;
protected ?Local $localFs = null;
Expand Down Expand Up @@ -94,7 +101,7 @@ public function createUrl(string $path = ''): UriInterface
{
if ($this->useLocalFs) {
return Modifier::wrap($this->getLocalFs()->getRootUrl() ?? '/')
->appendSegment($this->createPath($path))
->appendPath($this->createPath($path))
->unwrap();
}

Expand Down Expand Up @@ -519,6 +526,114 @@ public function getFileStream(string $uriPath)
return parent::getFileStream($uriPath);
}

public function getImageDimensions(string $uriPath): ?array
{
if (Assets::getFileKindByExtension($uriPath) !== Asset::KIND_IMAGE) {
return null;
}

for ($bytes = self::IMAGE_DIMENSION_INITIAL_BYTES; $bytes <= self::IMAGE_DIMENSION_MAX_BYTES; $bytes *= 2) {
$dimensions = $this->getImageDimensionsFromRange($uriPath, $bytes);

if ($dimensions !== null) {
return $dimensions;
}
}

return null;
}

private function getImageDimensionsFromRange(string $uriPath, int $bytes): ?array
{
$stream = $this->getFileStreamRange($uriPath, 0, $bytes - 1);

if ($stream === null) {
return null;
}

try {
$imageSize = Image::imageSizeByStream($stream);

if ($imageSize === false || !isset($imageSize[0], $imageSize[1])) {
return null;
}

return [
(int)$imageSize[0] ?: null,
(int)$imageSize[1] ?: null,
];
} catch (Throwable) {
return null;
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}

/**
* @return resource|null
*/
public function getFileStreamRange(string $uriPath, int $start, int $end)
{
if ($start < 0 || $end < $start) {
return null;
}

$sourceStream = null;

try {
if (!$this->useLocalFs) {
$bucket = $this->getBucketName();

if ($bucket === null) {
return null;
}

$object = $this->getClient()->getObject([
'Bucket' => $bucket,
'Key' => $this->createBucketPath($uriPath)->toString(),
'Range' => "bytes=$start-$end",
]);

return $this->stringStream((string)$object->get('Body'));
}

$sourceStream = $this->getFileStream($uriPath);

if (fseek($sourceStream, $start) === -1) {
return null;
}

$data = stream_get_contents($sourceStream, $end - $start + 1);

return is_string($data) ? $this->stringStream($data) : null;
} catch (Throwable) {
return null;
} finally {
if (is_resource($sourceStream)) {
fclose($sourceStream);
}
}
}

/**
* @return resource|null
*/
private function stringStream(string $contents)
{
$stream = fopen('php://temp', 'r+');

if ($stream === false) {
return null;
}

fwrite($stream, $contents);
rewind($stream);

return $stream;
}

/**
* @inheritDoc
*/
Expand Down
Loading
Loading