Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Allow file variants with different extensions #585

Merged
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
63 changes: 63 additions & 0 deletions src/FilenameParsing/AbstractFileIDHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,29 @@

namespace SilverStripe\Assets\FilenameParsing;

use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Path;

abstract class AbstractFileIDHelper implements FileIDHelper
{
use Injectable;

/**
* A variant type for encoding a variant filename with a different extension than the original.
*/
public const EXTENSION_REWRITE_VARIANT = 'ExtRewrite';

/**
* Use the original file's extension
*/
protected const EXTENSION_ORIGINAL = 0;

/**
* Use the variant file's extension
*/
protected const EXTENSION_VARIANT = 1;

public function buildFileID($filename, $hash = null, $variant = null, $cleanfilename = true)
{
if ($filename instanceof ParsedFileID) {
Expand All @@ -23,6 +40,10 @@ public function buildFileID($filename, $hash = null, $variant = null, $cleanfile
$filename = $this->cleanFilename($filename);
}

if ($variant) {
$filename = $this->swapExtension($filename, $variant, self::EXTENSION_VARIANT);
}

$name = basename($filename ?? '');

// Split extension
Expand Down Expand Up @@ -53,6 +74,48 @@ public function buildFileID($filename, $hash = null, $variant = null, $cleanfile
return $fileID;
}

/**
* Get the original file's filename with the extension rewritten to be the same as either the original
* or the variant extension.
*
* @param string $filename Original filename without variant
* @param int $extIndex One of self::EXTENSION_ORIGINAL or self::EXTENSION_VARIANT
*/
protected function swapExtension(string $filename, string $variant, int $extIndex): string
{
// If there's no variant at all, we can rewrite the filenmane
if (empty($variant)) {
return $filename;
}

// Split variant string in variant list
$subVariants = explode('_', $variant);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the original filename has underscores in it? You'll need to add a unit test for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't match the variant regex and so it will be ignored.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added underscores to file names in the test scenarios.


// Split our filename into a filename and extension part
$fileParts = pathinfo($filename);
if (!isset($fileParts['filename']) || !isset($fileParts['extension'])) {
return $filename;
}
$dirname = $fileParts['dirname'] !== '.' ? $fileParts['dirname'] : '';
$filenameWithoutExtension = Path::join($dirname, $fileParts['filename']);
$extension = $fileParts['extension'];

// Loop our variant list until we find our special file extension swap variant
// Reverse the list first so the variant extension we find is the last extension rewrite variant in a chain
$extSwapVariant = preg_quote(self::EXTENSION_REWRITE_VARIANT, '/');
foreach (array_reverse($subVariants) as $subVariant) {
if (preg_match("/^$extSwapVariant(?<base64>.+)$/", $subVariant, $matches)) {
// This array always contain 2 values: The original extension at index 0 and the variant extension at index 1
/** @var array $extensionData */
$extensionData = Convert::base64url_decode($matches['base64']);
$extension = $extensionData[$extIndex];
break;
}
}

return $filenameWithoutExtension . '.' . $extension;
}

public function cleanFilename($filename)
{
// Swap backslash for forward slash
Expand Down
1 change: 0 additions & 1 deletion src/FilenameParsing/FileIDHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/
interface FileIDHelper
{

/**
* Map file tuple (hash, name, variant) to a filename to be used by flysystem
*
Expand Down
8 changes: 7 additions & 1 deletion src/FilenameParsing/HashFileIDHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ public function parseFileID($fileID)
}

$filename = $matches['folder'] . $matches['basename'] . $matches['extension'];
$variant = $matches['variant'] ?: '';

if ($variant) {
$filename = $this->swapExtension($filename, $variant, self::EXTENSION_ORIGINAL);
}

return new ParsedFileID(
$filename,
$matches['hash'],
isset($matches['variant']) ? $matches['variant'] : '',
$variant,
$fileID
);
}
Expand Down
10 changes: 7 additions & 3 deletions src/FilenameParsing/NaturalFileIDHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

namespace SilverStripe\Assets\FilenameParsing;

use SilverStripe\Core\Injector\Injectable;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this in the refactor PR - it's not used anymore


/**
* Parsed Natural path URLs. Natural path is the same hashless path that appears in the CMS.
*
Expand All @@ -23,10 +21,16 @@ public function parseFileID($fileID)
}

$filename = $matches['folder'] . $matches['basename'] . $matches['extension'];
$variant = $matches['variant'] ?: '';

if ($variant) {
$filename = $this->swapExtension($filename, $variant, self::EXTENSION_ORIGINAL);
}

return new ParsedFileID(
$filename,
'',
isset($matches['variant']) ? $matches['variant'] : '',
$variant,
$fileID
);
}
Expand Down
10 changes: 8 additions & 2 deletions src/ImageBackendFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ public function __construct(Factory $creator)
*/
public function create($service, array $params = [])
{
/** @var AssetContainer $assetContainer */
/** @var AssetContainer|null $assetContainer */
$assetContainer = reset($params);
if (!$assetContainer instanceof AssetContainer) {

// If no asset container was passed in, create a new uncached image backend
if (!$assetContainer) {
return $this->creator->create($service, $params);
}
Comment on lines +43 to +46
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed so that developers can get an instance of the injected Image_Backend when they don't have a valid AssetContainer instance that contains an image file. The example in the "manual testing steps" section of the PR description simply won't work without this.


if (!($assetContainer instanceof AssetContainer)) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be if (!$assetContainer instanceof AssetContainer) - which actually checks if ((!$assetContainer) instanceof AssetContainer). In other words it was checking if a boolean was an instance of a class, which will always evaluate to false.

throw new BadMethodCallException("Can only create Image_Backend for " . AssetContainer::class);
}

Expand Down
25 changes: 20 additions & 5 deletions src/ImageManipulation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use InvalidArgumentException;
use LogicException;
use SilverStripe\Assets\FilenameParsing\AbstractFileIDHelper;
use SilverStripe\Assets\Storage\AssetContainer;
use SilverStripe\Assets\Storage\AssetStore;
use SilverStripe\Assets\Storage\DBFile;
Expand Down Expand Up @@ -840,13 +841,25 @@ public function isHeight($height)
return $this->getHeight() === $height;
}

/**
* Wrapper for manipulate() that creates a variant file with a different extension than the original file.
*
* @return DBFile|null The manipulated file
*/
public function manipulateExtension(string $newExtension, callable $callback)
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
{
$pathParts = pathinfo($this->getFilename());
$variant = $this->variantName(AbstractFileIDHelper::EXTENSION_REWRITE_VARIANT, $pathParts['extension'], $newExtension);
return $this->manipulate($variant, $callback);
}

/**
* Wrapper for manipulate that passes in and stores Image_Backend objects instead of tuples
*
* @param string $variant
* @param callable $callback Callback which takes an Image_Backend object, and returns an Image_Backend result.
* If this callback returns `true` then the current image will be duplicated without modification.
* @return DBFile The manipulated file
* @return DBFile|null The manipulated file
*/
public function manipulateImage($variant, $callback)
{
Expand Down Expand Up @@ -914,7 +927,7 @@ function (AssetStore $store, $filename, $hash, $variant) use ($callback) {
* This callback will be passed the backend, filename, hash, and variant
* This will not be called if the file does not
* need to be created.
* @return DBFile The manipulated file
* @return DBFile|null The manipulated file
*/
public function manipulate($variant, $callback)
{
Expand Down Expand Up @@ -999,6 +1012,7 @@ public function variantName($format, $arg = null)
* For legacy reasons, there's no delimiter between this part, and the encoded arguments.
* This means we have to use a whitelist of "known formats", based on methods
* available on the {@link Image} class as the "main" user of this trait.
* The one exception to this is the variant for swapping file extensions, which is explicitly allowed.
* This class is commonly decorated with additional manipulation methods through {@link DataExtension}.
*
* @param $variantName
Expand All @@ -1007,10 +1021,11 @@ public function variantName($format, $arg = null)
*/
public function variantParts($variantName)
{
$methods = array_map('preg_quote', singleton(Image::class)->allMethodNames() ?? []);
$allowedVariantTypes = array_map('preg_quote', singleton(Image::class)->allMethodNames() ?? []);
$allowedVariantTypes[] = preg_quote(AbstractFileIDHelper::EXTENSION_REWRITE_VARIANT);

// Regex needs to be case insensitive since allMethodNames() is all lowercased
$regex = '#^(?<format>(' . implode('|', $methods) . '))(?<encodedargs>(.*))#i';
$regex = '#^(?<format>(' . implode('|', $allowedVariantTypes) . '))(?<encodedargs>(.*))#i';
preg_match($regex ?? '', $variantName ?? '', $matches);

if (!$matches) {
Expand All @@ -1022,7 +1037,7 @@ public function variantParts($variantName)
throw new InvalidArgumentException('Invalid variant name arguments: ' . $variantName);
}

return array_merge([$matches['format']], $args[0]);
return array_merge([$matches['format']], $args);
Comment on lines -1025 to +1040
Copy link
Member Author

@GuySartorelli GuySartorelli Jan 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the description of c8f0c29

}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/InterventionBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,10 @@ public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $v
throw new BadMethodCallException("Cannot write corrupt file to store");
}

// Make sure we're using the extension of the variant file, which can differ from the original file
$url = $assetStore->getAsURL($filename, $hash, $variant, false);
$extension = pathinfo($url, PATHINFO_EXTENSION);
// Save file
$extension = pathinfo($filename ?? '', PATHINFO_EXTENSION);
Comment on lines +369 to -370
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is vital if using InterventionImage::writeToStore() to store (and encode) the variant. Without this, it will think it's meant to store it using the original file extension which will be wrong at best and outright fail if the original file wasn't an image.

$result = $assetStore->setFromString(
$resource->encode($extension, $this->getQuality())->getEncoded(),
$filename,
Expand Down
47 changes: 47 additions & 0 deletions tests/php/FilenameParsing/HashFileIDHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
namespace SilverStripe\Assets\Tests\FilenameParsing;

use InvalidArgumentException;
use ReflectionClassConstant;
use ReflectionMethod;
use SilverStripe\Assets\FilenameParsing\HashFileIDHelper;
use SilverStripe\Assets\FilenameParsing\ParsedFileID;
use SilverStripe\Core\Convert;

class HashFileIDHelperTest extends FileIDHelperTester
{
Expand Down Expand Up @@ -173,4 +176,48 @@ public function testHashlessBuildFileID()
$this->expectException(\InvalidArgumentException::class);
$this->getHelper()->buildFileID('Filename.txt', '');
}

public function provideRewriteExtension()
{
$jpgToPng = 'ExtRewrite' . Convert::base64url_encode(['jpg', 'png']);

return [
'no variant' => ['', 'hel_lo.txt', 'hel_lo.txt'],
'invalid extension' => ['xyz', 'h_ello.abc+', 'h_ello.abc+'],
'no extension' => ['', 'hell_o.', 'hell_o.'],
'no filename' => ['', '.hta_ccess', '.hta_ccess'],
'no rewrite' => ['xyz', 'hell_o.jpg', 'hell_o.jpg'],
'no rewrite multi variant' => ['xyz_abc', 'he_llo.jpg', 'he_llo.jpg'],
'rewitten extension' => [$jpgToPng, 'hel_lo.jpg', 'hel_lo.png'],
'rewitten extension with other variants' => ["{$jpgToPng}_xyz", 'hel_lo.jpg', 'hel_lo.png'],
];
}

/**
* @dataProvider provideRewriteExtension
*/
public function testRewriteVariantExtension($variant, $inFilename, $outFilename)
{
$helper = new HashFileIDHelper();
$reflectionMethod = new ReflectionMethod($helper, 'swapExtension');
$reflectionMethod->setAccessible(true);
$reflectionConst = new ReflectionClassConstant($helper, 'EXTENSION_VARIANT');
$actualFilename = $reflectionMethod->invoke($helper, $inFilename, $variant, $reflectionConst->getValue());

$this->assertEquals($outFilename, $actualFilename);
}

/**
* @dataProvider provideRewriteExtension
*/
public function testRestoreOriginalExtension($variant, $outFilename, $inFilename)
{
$helper = new HashFileIDHelper();
$reflectionMethod = new ReflectionMethod($helper, 'swapExtension');
$reflectionMethod->setAccessible(true);
$reflectionConst = new ReflectionClassConstant($helper, 'EXTENSION_ORIGINAL');
$actualFilename = $reflectionMethod->invoke($helper, $inFilename, $variant, $reflectionConst->getValue());

$this->assertEquals($outFilename, $actualFilename);
}
}
Loading
Loading