mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
865 lines
24 KiB
PHP
865 lines
24 KiB
PHP
<?php
|
|
|
|
namespace SilverStripe\Assets\Flysystem;
|
|
|
|
use Generator;
|
|
use LogicException;
|
|
use InvalidArgumentException;
|
|
use League\Flysystem\Directory;
|
|
use League\Flysystem\Exception;
|
|
use League\Flysystem\Filesystem;
|
|
use League\Flysystem\FilesystemInterface;
|
|
use League\Flysystem\Util;
|
|
use SilverStripe\Assets\Storage\AssetNameGenerator;
|
|
use SilverStripe\Assets\Storage\AssetStore;
|
|
use SilverStripe\Assets\Storage\AssetStoreRouter;
|
|
use SilverStripe\Control\Director;
|
|
use SilverStripe\Control\Session;
|
|
use SilverStripe\Control\HTTPResponse;
|
|
use SilverStripe\Core\Config\Config;
|
|
use SilverStripe\Core\Flushable;
|
|
use SilverStripe\Core\Injector\Injector;
|
|
|
|
/**
|
|
* Asset store based on flysystem Filesystem as a backend
|
|
*/
|
|
class FlysystemAssetStore implements AssetStore, AssetStoreRouter, Flushable {
|
|
|
|
/**
|
|
* Session key to use for user grants
|
|
*/
|
|
const GRANTS_SESSION = 'AssetStore_Grants';
|
|
|
|
/**
|
|
* @var Filesystem
|
|
*/
|
|
private $publicFilesystem = null;
|
|
|
|
/**
|
|
* Filesystem to use for protected files
|
|
*
|
|
* @var Filesystem
|
|
*/
|
|
private $protectedFilesystem = null;
|
|
|
|
/**
|
|
* Enable to use legacy filename behaviour (omits hash)
|
|
*
|
|
* Note that if using legacy filenames then duplicate files will not work.
|
|
*
|
|
* @config
|
|
* @var bool
|
|
*/
|
|
private static $legacy_filenames = false;
|
|
|
|
/**
|
|
* Flag if empty folders are allowed.
|
|
* If false, empty folders are cleared up when their contents are deleted.
|
|
*
|
|
* @config
|
|
* @var bool
|
|
*/
|
|
private static $keep_empty_dirs = false;
|
|
|
|
/**
|
|
* Set HTTP error code for requests to secure denied assets.
|
|
* Note that this defaults to 404 to prevent information disclosure
|
|
* of secure files
|
|
*
|
|
* @config
|
|
* @var int
|
|
*/
|
|
private static $denied_response_code = 404;
|
|
|
|
/**
|
|
* Set HTTP error code to use for missing secure assets
|
|
*
|
|
* @config
|
|
* @var int
|
|
*/
|
|
private static $missing_response_code = 404;
|
|
|
|
/**
|
|
* Custom headers to add to all custom file responses
|
|
*
|
|
* @config
|
|
* @var array
|
|
*/
|
|
private static $file_response_headers = array(
|
|
'Cache-Control' => 'private'
|
|
);
|
|
|
|
/**
|
|
* Assign new flysystem backend
|
|
*
|
|
* @param Filesystem $filesystem
|
|
* @return $this
|
|
*/
|
|
public function setPublicFilesystem(Filesystem $filesystem) {
|
|
if(!$filesystem->getAdapter() instanceof PublicAdapter) {
|
|
throw new InvalidArgumentException("Configured adapter must implement PublicAdapter");
|
|
}
|
|
$this->publicFilesystem = $filesystem;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get the currently assigned flysystem backend
|
|
*
|
|
* @return Filesystem
|
|
* @throws LogicException
|
|
*/
|
|
public function getPublicFilesystem() {
|
|
if(!$this->publicFilesystem) {
|
|
throw new LogicException("Filesystem misconfiguration error");
|
|
}
|
|
return $this->publicFilesystem;
|
|
}
|
|
|
|
/**
|
|
* Assign filesystem to use for non-public files
|
|
*
|
|
* @param Filesystem $filesystem
|
|
* @return $this
|
|
*/
|
|
public function setProtectedFilesystem(Filesystem $filesystem) {
|
|
if(!$filesystem->getAdapter() instanceof ProtectedAdapter) {
|
|
throw new InvalidArgumentException("Configured adapter must implement ProtectedAdapter");
|
|
}
|
|
$this->protectedFilesystem = $filesystem;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get filesystem to use for non-public files
|
|
*
|
|
* @return Filesystem
|
|
* @throws Exception
|
|
*/
|
|
public function getProtectedFilesystem() {
|
|
if(!$this->protectedFilesystem) {
|
|
throw new Exception("Filesystem misconfiguration error");
|
|
}
|
|
return $this->protectedFilesystem;
|
|
}
|
|
|
|
/**
|
|
* Return the store that contains the given fileID
|
|
*
|
|
* @param string $fileID Internal file identifier
|
|
* @return Filesystem
|
|
*/
|
|
protected function getFilesystemFor($fileID) {
|
|
if($this->getPublicFilesystem()->has($fileID)) {
|
|
return $this->getPublicFilesystem();
|
|
}
|
|
|
|
if($this->getProtectedFilesystem()->has($fileID)) {
|
|
return $this->getProtectedFilesystem();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function getCapabilities() {
|
|
return array(
|
|
'visibility' => array(
|
|
self::VISIBILITY_PUBLIC,
|
|
self::VISIBILITY_PROTECTED
|
|
),
|
|
'conflict' => array(
|
|
self::CONFLICT_EXCEPTION,
|
|
self::CONFLICT_OVERWRITE,
|
|
self::CONFLICT_RENAME,
|
|
self::CONFLICT_USE_EXISTING
|
|
)
|
|
);
|
|
}
|
|
|
|
public function getVisibility($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
if($this->getPublicFilesystem()->has($fileID)) {
|
|
return self::VISIBILITY_PUBLIC;
|
|
}
|
|
|
|
if($this->getProtectedFilesystem()->has($fileID)) {
|
|
return self::VISIBILITY_PROTECTED;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
public function getAsStream($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this
|
|
->getFilesystemFor($fileID)
|
|
->readStream($fileID);
|
|
}
|
|
|
|
public function getAsString($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this
|
|
->getFilesystemFor($fileID)
|
|
->read($fileID);
|
|
}
|
|
|
|
public function getAsURL($filename, $hash, $variant = null, $grant = true) {
|
|
if($grant) {
|
|
$this->grant($filename, $hash);
|
|
}
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
|
|
// Check with filesystem this asset exists in
|
|
$public = $this->getPublicFilesystem();
|
|
$protected = $this->getProtectedFilesystem();
|
|
if($public->has($fileID) || !$protected->has($fileID)) {
|
|
/** @var PublicAdapter $publicAdapter */
|
|
$publicAdapter = $public->getAdapter();
|
|
return $publicAdapter->getPublicUrl($fileID);
|
|
} else {
|
|
/** @var ProtectedAdapter $protectedAdapter */
|
|
$protectedAdapter = $protected->getAdapter();
|
|
return $protectedAdapter->getProtectedUrl($fileID);
|
|
}
|
|
}
|
|
|
|
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) {
|
|
// Validate this file exists
|
|
if(!file_exists($path)) {
|
|
throw new InvalidArgumentException("$path does not exist");
|
|
}
|
|
|
|
// Get filename to save to
|
|
if(empty($filename)) {
|
|
$filename = basename($path);
|
|
}
|
|
|
|
// Callback for saving content
|
|
$callback = function(Filesystem $filesystem, $fileID) use ($path) {
|
|
// Read contents as string into flysystem
|
|
$handle = fopen($path, 'r');
|
|
if($handle === false) {
|
|
throw new InvalidArgumentException("$path could not be opened for reading");
|
|
}
|
|
$result = $filesystem->putStream($fileID, $handle);
|
|
fclose($handle);
|
|
return $result;
|
|
};
|
|
|
|
// When saving original filename, generate hash
|
|
if(!$variant) {
|
|
$hash = sha1_file($path);
|
|
}
|
|
|
|
// Submit to conflict check
|
|
return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
|
|
}
|
|
|
|
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) {
|
|
// Callback for saving content
|
|
$callback = function(Filesystem $filesystem, $fileID) use ($data) {
|
|
return $filesystem->put($fileID, $data);
|
|
};
|
|
|
|
// When saving original filename, generate hash
|
|
if(!$variant) {
|
|
$hash = sha1($data);
|
|
}
|
|
|
|
// Submit to conflict check
|
|
return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
|
|
}
|
|
|
|
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) {
|
|
// If the stream isn't rewindable, write to a temporary filename
|
|
if(!$this->isSeekableStream($stream)) {
|
|
$path = $this->getStreamAsFile($stream);
|
|
$result = $this->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
|
unlink($path);
|
|
return $result;
|
|
}
|
|
|
|
// Callback for saving content
|
|
$callback = function(Filesystem $filesystem, $fileID) use ($stream) {
|
|
return $filesystem->putStream($fileID, $stream);
|
|
};
|
|
|
|
// When saving original filename, generate hash
|
|
if(!$variant) {
|
|
$hash = $this->getStreamSHA1($stream);
|
|
}
|
|
|
|
// Submit to conflict check
|
|
return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
|
|
}
|
|
|
|
public function delete($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
$protected = $this->deleteFromFilesystem($fileID, $this->getProtectedFilesystem());
|
|
$public = $this->deleteFromFilesystem($fileID, $this->getPublicFilesystem());
|
|
return $protected || $public;
|
|
}
|
|
|
|
/**
|
|
* Delete the given file (and any variants) in the given {@see Filesystem}
|
|
*
|
|
* @param string $fileID
|
|
* @param Filesystem $filesystem
|
|
* @return bool True if a file was deleted
|
|
*/
|
|
protected function deleteFromFilesystem($fileID, Filesystem $filesystem) {
|
|
$deleted = false;
|
|
foreach($this->findVariants($fileID, $filesystem) as $nextID) {
|
|
$filesystem->delete($nextID);
|
|
$deleted = true;
|
|
}
|
|
|
|
// Truncate empty dirs
|
|
$this->truncateDirectory(dirname($fileID), $filesystem);
|
|
|
|
return $deleted;
|
|
}
|
|
|
|
/**
|
|
* Clear directory if it's empty
|
|
*
|
|
* @param string $dirname Name of directory
|
|
* @param Filesystem $filesystem
|
|
*/
|
|
protected function truncateDirectory($dirname, Filesystem $filesystem) {
|
|
if ($dirname
|
|
&& ltrim(dirname($dirname), '.')
|
|
&& ! Config::inst()->get(get_class($this), 'keep_empty_dirs')
|
|
&& ! $filesystem->listContents($dirname)
|
|
) {
|
|
$filesystem->deleteDir($dirname);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an iterable {@see Generator} of all files / variants for the given $fileID in the given $filesystem
|
|
* This includes the empty (no) variant.
|
|
*
|
|
* @param string $fileID ID of original file to compare with.
|
|
* @param Filesystem $filesystem
|
|
* @return Generator
|
|
*/
|
|
protected function findVariants($fileID, Filesystem $filesystem) {
|
|
$dirname = ltrim(dirname($fileID), '.');
|
|
foreach($filesystem->listContents($dirname) as $next) {
|
|
if($next['type'] !== 'file') {
|
|
continue;
|
|
}
|
|
$nextID = $next['path'];
|
|
// Compare given file to target, omitting variant
|
|
if($fileID === $this->removeVariant($nextID)) {
|
|
yield $nextID;
|
|
}
|
|
}
|
|
}
|
|
|
|
public function publish($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
$protected = $this->getProtectedFilesystem();
|
|
$public = $this->getPublicFilesystem();
|
|
$this->moveBetweenFilesystems($fileID, $protected, $public);
|
|
}
|
|
|
|
public function protect($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
$public = $this->getPublicFilesystem();
|
|
$protected = $this->getProtectedFilesystem();
|
|
$this->moveBetweenFilesystems($fileID, $public, $protected);
|
|
}
|
|
|
|
/**
|
|
* Move a file (and its associative variants) between filesystems
|
|
*
|
|
* @param string $fileID
|
|
* @param Filesystem $from
|
|
* @param Filesystem $to
|
|
*/
|
|
protected function moveBetweenFilesystems($fileID, Filesystem $from, Filesystem $to) {
|
|
foreach($this->findVariants($fileID, $from) as $nextID) {
|
|
// Copy via stream
|
|
$stream = $from->readStream($nextID);
|
|
$to->putStream($nextID, $stream);
|
|
fclose($stream);
|
|
$from->delete($nextID);
|
|
}
|
|
|
|
// Truncate empty dirs
|
|
$this->truncateDirectory(dirname($fileID), $from);
|
|
}
|
|
|
|
public function grant($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
$granted = Session::get(self::GRANTS_SESSION) ?: array();
|
|
$granted[$fileID] = true;
|
|
Session::set(self::GRANTS_SESSION, $granted);
|
|
}
|
|
|
|
public function revoke($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
$granted = Session::get(self::GRANTS_SESSION) ?: array();
|
|
unset($granted[$fileID]);
|
|
if($granted) {
|
|
Session::set(self::GRANTS_SESSION, $granted);
|
|
} else {
|
|
Session::clear(self::GRANTS_SESSION);
|
|
}
|
|
}
|
|
|
|
public function canView($filename, $hash) {
|
|
$fileID = $this->getFileID($filename, $hash);
|
|
if($this->getProtectedFilesystem()->has($fileID)) {
|
|
return $this->isGranted($fileID);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Determine if a grant exists for the given FileID
|
|
*
|
|
* @param string $fileID
|
|
* @return bool
|
|
*/
|
|
protected function isGranted($fileID) {
|
|
// Since permissions are applied to the non-variant only,
|
|
// map back to the original file before checking
|
|
$originalID = $this->removeVariant($fileID);
|
|
$granted = Session::get(self::GRANTS_SESSION) ?: array();
|
|
return !empty($granted[$originalID]);
|
|
}
|
|
|
|
/**
|
|
* get sha1 hash from stream
|
|
*
|
|
* @param resource $stream
|
|
* @return string str1 hash
|
|
*/
|
|
protected function getStreamSHA1($stream) {
|
|
Util::rewindStream($stream);
|
|
$context = hash_init('sha1');
|
|
hash_update_stream($context, $stream);
|
|
return hash_final($context);
|
|
}
|
|
|
|
/**
|
|
* Get stream as a file
|
|
*
|
|
* @param resource $stream
|
|
* @return string Filename of resulting stream content
|
|
* @throws Exception
|
|
*/
|
|
protected function getStreamAsFile($stream) {
|
|
// Get temporary file and name
|
|
$file = tempnam(sys_get_temp_dir(), 'ssflysystem');
|
|
$buffer = fopen($file, 'w');
|
|
if (!$buffer) {
|
|
throw new Exception("Could not create temporary file");
|
|
}
|
|
|
|
// Transfer from given stream
|
|
Util::rewindStream($stream);
|
|
stream_copy_to_stream($stream, $buffer);
|
|
if (! fclose($buffer)) {
|
|
throw new Exception("Could not write stream to temporary file");
|
|
}
|
|
|
|
return $file;
|
|
}
|
|
|
|
/**
|
|
* Determine if this stream is seekable
|
|
*
|
|
* @param resource $stream
|
|
* @return bool True if this stream is seekable
|
|
*/
|
|
protected function isSeekableStream($stream) {
|
|
return Util::isSeekableStream($stream);
|
|
}
|
|
|
|
/**
|
|
* Invokes the conflict resolution scheme on the given content, and invokes a callback if
|
|
* the storage request is approved.
|
|
*
|
|
* @param callable $callback Will be invoked and passed a fileID if the file should be stored
|
|
* @param string $filename Name for the resulting file
|
|
* @param string $hash SHA1 of the original file content
|
|
* @param string $variant Variant to write
|
|
* @param array $config Write options. {@see AssetStore}
|
|
* @return array Tuple associative array (Filename, Hash, Variant)
|
|
* @throws Exception
|
|
*/
|
|
protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = array()) {
|
|
// Set default conflict resolution
|
|
if(empty($config['conflict'])) {
|
|
$conflictResolution = $this->getDefaultConflictResolution($variant);
|
|
} else {
|
|
$conflictResolution = $config['conflict'];
|
|
}
|
|
|
|
// Validate parameters
|
|
if($variant && $conflictResolution === AssetStore::CONFLICT_RENAME) {
|
|
// As variants must follow predictable naming rules, they should not be dynamically renamed
|
|
throw new InvalidArgumentException("Rename cannot be used when writing variants");
|
|
}
|
|
if(!$filename) {
|
|
throw new InvalidArgumentException("Filename is missing");
|
|
}
|
|
if(!$hash) {
|
|
throw new InvalidArgumentException("File hash is missing");
|
|
}
|
|
|
|
$filename = $this->cleanFilename($filename);
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
|
|
// Check conflict resolution scheme
|
|
$resolvedID = $this->resolveConflicts($conflictResolution, $fileID);
|
|
if($resolvedID !== false) {
|
|
// Check if source file already exists on the filesystem
|
|
$mainID = $this->getFileID($filename, $hash);
|
|
$filesystem = $this->getFilesystemFor($mainID);
|
|
|
|
// If writing a new file use the correct visibility
|
|
if(!$filesystem) {
|
|
// Default to public store unless requesting protected store
|
|
if(isset($config['visibility']) && $config['visibility'] === self::VISIBILITY_PROTECTED) {
|
|
$filesystem = $this->getProtectedFilesystem();
|
|
} else {
|
|
$filesystem = $this->getPublicFilesystem();
|
|
}
|
|
}
|
|
|
|
// Submit and validate result
|
|
$result = $callback($filesystem, $resolvedID);
|
|
if(!$result) {
|
|
throw new Exception("Could not save {$filename}");
|
|
}
|
|
|
|
// in case conflict resolution renamed the file, return the renamed
|
|
$filename = $this->getOriginalFilename($resolvedID);
|
|
|
|
} elseif(empty($variant)) {
|
|
// If deferring to the existing file, return the sha of the existing file,
|
|
// unless we are writing a variant (which has the same hash value as its original file)
|
|
$stream = $this
|
|
->getFilesystemFor($fileID)
|
|
->readStream($fileID);
|
|
$hash = $this->getStreamSHA1($stream);
|
|
}
|
|
|
|
return array(
|
|
'Filename' => $filename,
|
|
'Hash' => $hash,
|
|
'Variant' => $variant
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Choose a default conflict resolution
|
|
*
|
|
* @param string $variant
|
|
* @return string
|
|
*/
|
|
protected function getDefaultConflictResolution($variant) {
|
|
// If using new naming scheme (segment by hash) it's normally safe to overwrite files.
|
|
// Variants are also normally safe to overwrite, since lazy-generation is implemented at a higher level.
|
|
$legacy = $this->useLegacyFilenames();
|
|
if(!$legacy || $variant) {
|
|
return AssetStore::CONFLICT_OVERWRITE;
|
|
}
|
|
|
|
// Legacy behaviour is to rename
|
|
return AssetStore::CONFLICT_RENAME;
|
|
}
|
|
|
|
/**
|
|
* Determine if legacy filenames should be used. These do not have hash path parts.
|
|
*
|
|
* @return bool
|
|
*/
|
|
protected function useLegacyFilenames() {
|
|
return Config::inst()->get(get_class($this), 'legacy_filenames');
|
|
}
|
|
|
|
public function getMetadata($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
$filesystem = $this->getFilesystemFor($fileID);
|
|
if($filesystem) {
|
|
return $filesystem->getMetadata($fileID);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function getMimeType($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
$filesystem = $this->getFilesystemFor($fileID);
|
|
if($filesystem) {
|
|
return $filesystem->getMimetype($fileID);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function exists($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
$filesystem = $this->getFilesystemFor($fileID);
|
|
return !empty($filesystem);
|
|
}
|
|
|
|
/**
|
|
* Determine the path that should be written to, given the conflict resolution scheme
|
|
*
|
|
* @param string $conflictResolution
|
|
* @param string $fileID
|
|
* @return string|false Safe filename to write to. If false, then don't write, and use existing file.
|
|
* @throws Exception
|
|
*/
|
|
protected function resolveConflicts($conflictResolution, $fileID) {
|
|
// If overwrite is requested, simply put
|
|
if($conflictResolution === AssetStore::CONFLICT_OVERWRITE) {
|
|
return $fileID;
|
|
}
|
|
|
|
// Otherwise, check if this exists
|
|
$exists = $this->getFilesystemFor($fileID);
|
|
if(!$exists) {
|
|
return $fileID;
|
|
}
|
|
|
|
// Flysystem defaults to use_existing
|
|
switch($conflictResolution) {
|
|
// Throw tantrum
|
|
case static::CONFLICT_EXCEPTION: {
|
|
throw new InvalidArgumentException("File already exists at path {$fileID}");
|
|
}
|
|
|
|
// Rename
|
|
case static::CONFLICT_RENAME: {
|
|
foreach($this->fileGeneratorFor($fileID) as $candidate) {
|
|
if(!$this->getFilesystemFor($candidate)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
throw new InvalidArgumentException("File could not be renamed with path {$fileID}");
|
|
}
|
|
|
|
// Use existing file
|
|
case static::CONFLICT_USE_EXISTING:
|
|
default: {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an asset renamer for the given filename.
|
|
*
|
|
* @param string $fileID Adapter specific identifier for this file/version
|
|
* @return AssetNameGenerator
|
|
*/
|
|
protected function fileGeneratorFor($fileID){
|
|
return Injector::inst()->createWithArgs('AssetNameGenerator', array($fileID));
|
|
}
|
|
|
|
/**
|
|
* Performs filename cleanup before sending it back.
|
|
*
|
|
* This name should not contain hash or variants.
|
|
*
|
|
* @param string $filename
|
|
* @return string
|
|
*/
|
|
protected function cleanFilename($filename) {
|
|
// Since we use double underscore to delimit variants, eradicate them from filename
|
|
return preg_replace('/_{2,}/', '_', $filename);
|
|
}
|
|
|
|
/**
|
|
* Given a FileID, map this back to the original filename, trimming variant and hash
|
|
*
|
|
* @param string $fileID Adapter specific identifier for this file/version
|
|
* @return string Filename for this file, omitting hash and variant
|
|
*/
|
|
protected function getOriginalFilename($fileID) {
|
|
// Remove variant
|
|
$originalID = $this->removeVariant($fileID);
|
|
|
|
// Remove hash (unless using legacy filenames, without hash)
|
|
if($this->useLegacyFilenames()) {
|
|
return $originalID;
|
|
} else {
|
|
return preg_replace(
|
|
'/(?<hash>[a-zA-Z0-9]{10}\\/)(?<name>[^\\/]+)$/',
|
|
'$2',
|
|
$originalID
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove variant from a fileID
|
|
*
|
|
* @param string $fileID
|
|
* @return string FileID without variant
|
|
*/
|
|
protected function removeVariant($fileID) {
|
|
// Check variant
|
|
if (preg_match('/^(?<before>((?<!__).)+)__(?<variant>[^\\.]+)(?<after>.*)$/', $fileID, $matches)) {
|
|
return $matches['before'] . $matches['after'];
|
|
}
|
|
// There is no variant, so return original value
|
|
return $fileID;
|
|
}
|
|
|
|
/**
|
|
* Map file tuple (hash, name, variant) to a filename to be used by flysystem
|
|
*
|
|
* The resulting file will look something like my/directory/EA775CB4D4/filename__variant.jpg
|
|
*
|
|
* @param string $filename Name of file
|
|
* @param string $hash Hash of original file
|
|
* @param string $variant (if given)
|
|
* @return string Adapter specific identifier for this file/version
|
|
*/
|
|
protected function getFileID($filename, $hash, $variant = null) {
|
|
// Since we use double underscore to delimit variants, eradicate them from filename
|
|
$filename = $this->cleanFilename($filename);
|
|
$name = basename($filename);
|
|
|
|
// Split extension
|
|
$extension = null;
|
|
if(($pos = strpos($name, '.')) !== false) {
|
|
$extension = substr($name, $pos);
|
|
$name = substr($name, 0, $pos);
|
|
}
|
|
|
|
// Unless in legacy mode, inject hash just prior to the filename
|
|
if($this->useLegacyFilenames()) {
|
|
$fileID = $name;
|
|
} else {
|
|
$fileID = substr($hash, 0, 10) . '/' . $name;
|
|
}
|
|
|
|
// Add directory
|
|
$dirname = ltrim(dirname($filename), '.');
|
|
if($dirname) {
|
|
$fileID = $dirname . '/' . $fileID;
|
|
}
|
|
|
|
// Add variant
|
|
if($variant) {
|
|
$fileID .= '__' . $variant;
|
|
}
|
|
|
|
// Add extension
|
|
if($extension) {
|
|
$fileID .= $extension;
|
|
}
|
|
|
|
return $fileID;
|
|
}
|
|
|
|
/**
|
|
* Ensure each adapter re-generates its own server configuration files
|
|
*/
|
|
public static function flush() {
|
|
// Ensure that this instance is constructed on flush, thus forcing
|
|
// bootstrapping of necessary .htaccess / web.config files
|
|
$instance = singleton('AssetStore');
|
|
if ($instance instanceof FlysystemAssetStore) {
|
|
$publicAdapter = $instance->getPublicFilesystem()->getAdapter();
|
|
if($publicAdapter instanceof AssetAdapter) {
|
|
$publicAdapter->flush();
|
|
}
|
|
$protectedAdapter = $instance->getProtectedFilesystem()->getAdapter();
|
|
if($protectedAdapter instanceof AssetAdapter) {
|
|
$protectedAdapter->flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
public function getResponseFor($asset) {
|
|
// Check if file exists
|
|
$filesystem = $this->getFilesystemFor($asset);
|
|
if(!$filesystem) {
|
|
return $this->createMissingResponse();
|
|
}
|
|
|
|
// Block directory access
|
|
if($filesystem->get($asset) instanceof Directory) {
|
|
return $this->createDeniedResponse();
|
|
}
|
|
|
|
// Deny if file is protected and denied
|
|
if($filesystem === $this->getProtectedFilesystem() && !$this->isGranted($asset)) {
|
|
return $this->createDeniedResponse();
|
|
}
|
|
|
|
// Serve up file response
|
|
return $this->createResponseFor($filesystem, $asset);
|
|
}
|
|
|
|
/**
|
|
* Generate an {@see HTTPResponse} for the given file from the source filesystem
|
|
* @param FilesystemInterface $flysystem
|
|
* @param string $fileID
|
|
* @return HTTPResponse
|
|
*/
|
|
protected function createResponseFor(FilesystemInterface $flysystem, $fileID) {
|
|
// Build response body
|
|
// @todo: gzip / buffer response?
|
|
$body = $flysystem->read($fileID);
|
|
$mime = $flysystem->getMimetype($fileID);
|
|
$response = new HTTPResponse($body, 200);
|
|
|
|
// Add headers
|
|
$response->addHeader('Content-Type', $mime);
|
|
$headers = Config::inst()->get(get_class($this), 'file_response_headers');
|
|
foreach($headers as $header => $value) {
|
|
$response->addHeader($header, $value);
|
|
}
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Generate a response for requests to a denied protected file
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
protected function createDeniedResponse() {
|
|
$code = (int)Config::inst()->get(get_class($this), 'denied_response_code');
|
|
return $this->createErrorResponse($code);
|
|
}
|
|
|
|
/**
|
|
* Generate a response for missing file requests
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
protected function createMissingResponse() {
|
|
$code = (int)Config::inst()->get(get_class($this), 'missing_response_code');
|
|
return $this->createErrorResponse($code);
|
|
}
|
|
|
|
/**
|
|
* Create a response with the given error code
|
|
*
|
|
* @param int $code
|
|
* @return HTTPResponse
|
|
*/
|
|
protected function createErrorResponse($code) {
|
|
$response = new HTTPResponse('', $code);
|
|
|
|
// Show message in dev
|
|
if(!Director::isLive()) {
|
|
$response->setBody($response->getStatusDescription());
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
}
|