mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
be239896d3
API Remove filesystem sync API to handle file manipulations
440 lines
12 KiB
PHP
440 lines
12 KiB
PHP
<?php
|
|
|
|
namespace SilverStripe\Filesystem\Flysystem;
|
|
|
|
use Config;
|
|
use Injector;
|
|
use InvalidArgumentException;
|
|
use League\Flysystem\Exception;
|
|
use League\Flysystem\Filesystem;
|
|
use League\Flysystem\Util;
|
|
use SilverStripe\Filesystem\Storage\AssetNameGenerator;
|
|
use SilverStripe\Filesystem\Storage\AssetStore;
|
|
|
|
/**
|
|
* Asset store based on flysystem Filesystem as a backend
|
|
*
|
|
* @package framework
|
|
* @subpackage filesystem
|
|
*/
|
|
class FlysystemAssetStore implements AssetStore {
|
|
|
|
/**
|
|
* @var Filesystem
|
|
*/
|
|
private $filesystem = null;
|
|
|
|
/**
|
|
* Enable to use legacy filename behaviour (omits hash)
|
|
*
|
|
* @config
|
|
* @var bool
|
|
*/
|
|
private static $legacy_filenames = false;
|
|
|
|
/**
|
|
* Assign new flysystem backend
|
|
*
|
|
* @param Filesystem $filesystem
|
|
* @return $this
|
|
*/
|
|
public function setFilesystem(Filesystem $filesystem) {
|
|
$this->filesystem = $filesystem;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get the currently assigned flysystem backend
|
|
*
|
|
* @return Filesystem
|
|
*/
|
|
public function getFilesystem() {
|
|
return $this->filesystem;
|
|
}
|
|
|
|
public function getAsStream($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this->getFilesystem()->readStream($fileID);
|
|
}
|
|
|
|
public function getAsString($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this->getFilesystem()->read($fileID);
|
|
}
|
|
|
|
public function getAsURL($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this->getFilesystem()->getPublicUrl($fileID);
|
|
}
|
|
|
|
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null) {
|
|
// 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
|
|
$filesystem = $this->getFilesystem();
|
|
$callback = function($fileID) use ($filesystem, $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, $conflictResolution);
|
|
}
|
|
|
|
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
|
// Callback for saving content
|
|
$filesystem = $this->getFilesystem();
|
|
$callback = function($fileID) use ($filesystem, $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, $conflictResolution);
|
|
}
|
|
|
|
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
|
// 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, $conflictResolution);
|
|
unlink($path);
|
|
return $result;
|
|
}
|
|
|
|
// Callback for saving content
|
|
$filesystem = $this->getFilesystem();
|
|
$callback = function($fileID) use ($filesystem, $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, $conflictResolution);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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 string $conflictResolution {@see AssetStore}. Will default to one chosen by the backend
|
|
* @return array Tuple associative array (Filename, Hash, Variant)
|
|
* @throws Exception
|
|
*/
|
|
protected function writeWithCallback($callback, $filename, $hash, $variant = null, $conflictResolution = null) {
|
|
// Set default conflict resolution
|
|
if(!$conflictResolution) {
|
|
$conflictResolution = $this->getDefaultConflictResolution($variant);
|
|
}
|
|
|
|
// 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) {
|
|
// Submit and validate result
|
|
$result = $callback($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 defering 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
|
|
->getFilesystem()
|
|
->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);
|
|
return $this->getFilesystem()->getMetadata($fileID);
|
|
}
|
|
|
|
public function getMimeType($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this->getFilesystem()->getMimetype($fileID);
|
|
}
|
|
|
|
public function exists($filename, $hash, $variant = null) {
|
|
$fileID = $this->getFileID($filename, $hash, $variant);
|
|
return $this->getFilesystem()->has($fileID);
|
|
}
|
|
|
|
/**
|
|
* 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->getFilesystem()->has($fileID);
|
|
if(!$exists) {
|
|
return $fileID;
|
|
}
|
|
|
|
// Flysystem defaults to use_existing
|
|
switch($conflictResolution) {
|
|
// Throw tantrum
|
|
case AssetStore::CONFLICT_EXCEPTION: {
|
|
throw new \InvalidArgumentException("File already exists at path {$fileID}");
|
|
}
|
|
|
|
// Rename
|
|
case AssetStore::CONFLICT_RENAME: {
|
|
foreach($this->fileGeneratorFor($fileID) as $candidate) {
|
|
// @todo better infinite loop breaking
|
|
if(!$this->getFilesystem()->has($candidate)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
throw new \InvalidArgumentException("File could not be renamed with path {$fileID}");
|
|
}
|
|
|
|
// Use existing file
|
|
case AssetStore::CONFLICT_USE_EXISTING:
|
|
default: {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an asset renamer for the given filename.
|
|
*
|
|
* @param string $fileID Adaptor 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
|
|
*
|
|
* @param string $fileID Adaptor specific identifier for this file/version
|
|
* @param string $variant Out parameter for any found variant
|
|
* @return string
|
|
*/
|
|
protected function getOriginalFilename($fileID, &$variant = '') {
|
|
// Remove variant
|
|
$original = $fileID;
|
|
$variant = '';
|
|
if(preg_match('/^(?<before>((?<!__).)+)__(?<variant>[^\\.]+)(?<after>.*)$/', $fileID, $matches)) {
|
|
$original = $matches['before'].$matches['after'];
|
|
$variant = $matches['variant'];
|
|
}
|
|
|
|
// Remove hash (unless using legacy filenames, without hash)
|
|
if($this->useLegacyFilenames()) {
|
|
return $original;
|
|
} else {
|
|
return preg_replace(
|
|
'/(?<hash>[a-zA-Z0-9]{10}\\/)(?<name>[^\\/]+)$/',
|
|
'$2',
|
|
$original
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 Adaptor 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(Config::inst()->get(__CLASS__, 'legacy_filenames')) {
|
|
$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;
|
|
}
|
|
|
|
}
|