silverstripe-framework/src/Assets/Flysystem/FlysystemAssetStore.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;
}
}