mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #4863 from open-sausages/pulls/4.0/secure-assets
API Asset Access Control implementation
This commit is contained in:
commit
01ef4c2d05
@ -1,37 +1,66 @@
|
||||
---
|
||||
Name: assetstore
|
||||
Name: coreflysystem
|
||||
---
|
||||
Injector:
|
||||
# Public url plugin
|
||||
FlysystemUrlPlugin:
|
||||
class: 'SilverStripe\Filesystem\Flysystem\FlysystemUrlPlugin'
|
||||
# Define the default adapter for this filesystem
|
||||
FlysystemDefaultAdapter:
|
||||
class: 'SilverStripe\Filesystem\Flysystem\AssetAdapter'
|
||||
FlysystemPublicAdapter:
|
||||
class: 'SilverStripe\Filesystem\Flysystem\PublicAssetAdapter'
|
||||
# Define the secondary adapter for protected assets
|
||||
FlysystemProtectedAdapter:
|
||||
class: 'SilverStripe\Filesystem\Flysystem\ProtectedAssetAdapter'
|
||||
# Define the default filesystem
|
||||
FlysystemBackend:
|
||||
FlysystemPublicBackend:
|
||||
class: 'League\Flysystem\Filesystem'
|
||||
constructor:
|
||||
Adapter: '%$FlysystemDefaultAdapter'
|
||||
calls:
|
||||
PublicURLPlugin: [ addPlugin, [ %$FlysystemUrlPlugin ] ]
|
||||
Adapter: '%$FlysystemPublicAdapter'
|
||||
Config:
|
||||
visibility: public
|
||||
# Define the secondary filesystem for protected assets
|
||||
FlysystemProtectedBackend:
|
||||
class: 'League\Flysystem\Filesystem'
|
||||
constructor:
|
||||
Adapter: '%$FlysystemProtectedAdapter'
|
||||
Config:
|
||||
visibility: private
|
||||
---
|
||||
Name: coreassets
|
||||
After:
|
||||
- '#coreflysystem'
|
||||
---
|
||||
Injector:
|
||||
# Define our SS asset backend
|
||||
AssetStore:
|
||||
class: 'SilverStripe\Filesystem\Flysystem\FlysystemAssetStore'
|
||||
properties:
|
||||
Filesystem: '%$FlysystemBackend'
|
||||
PublicFilesystem: '%$FlysystemPublicBackend'
|
||||
ProtectedFilesystem: '%$FlysystemProtectedBackend'
|
||||
ProtectedFileController:
|
||||
class: SilverStripe\Filesystem\Storage\ProtectedFileController
|
||||
properties:
|
||||
RouteHandler: '%$AssetStore'
|
||||
AssetNameGenerator:
|
||||
class: SilverStripe\Filesystem\Storage\DefaultAssetNameGenerator
|
||||
type: prototype
|
||||
# Image mechanism
|
||||
Image_Backend: GDBackend
|
||||
# Requirements config
|
||||
GeneratedAssetHandler:
|
||||
class: SilverStripe\Filesystem\Storage\FlysystemGeneratedAssetHandler
|
||||
properties:
|
||||
Filesystem: '%$FlysystemBackend'
|
||||
Filesystem: '%$FlysystemPublicBackend'
|
||||
Requirements_Minifier:
|
||||
class: SilverStripe\View\JSMinifier
|
||||
Requirements_Backend:
|
||||
properties:
|
||||
AssetHandler: '%$GeneratedAssetHandler'
|
||||
---
|
||||
Name: coreassetroutes
|
||||
After:
|
||||
- '#coreassets'
|
||||
---
|
||||
Director:
|
||||
rules:
|
||||
'assets': 'ProtectedFileController'
|
||||
---
|
||||
Name: imageconfig
|
||||
---
|
||||
Injector:
|
||||
Image_Backend: GDBackend
|
||||
|
@ -86,14 +86,15 @@ abstract class Extension {
|
||||
|
||||
/**
|
||||
* Helper method to strip eval'ed arguments from a string
|
||||
* thats passed to {@link DataObject::$extensions} or
|
||||
* that's passed to {@link DataObject::$extensions} or
|
||||
* {@link Object::add_extension()}.
|
||||
*
|
||||
* @param string $extensionStr E.g. "Versioned('Stage','Live')"
|
||||
* @return string Extension classname, e.g. "Versioned"
|
||||
*/
|
||||
public static function get_classname_without_arguments($extensionStr) {
|
||||
return (($p = strpos($extensionStr, '(')) !== false) ? substr($extensionStr, 0, $p) : $extensionStr;
|
||||
$parts = explode('(', $extensionStr);
|
||||
return $parts[0];
|
||||
}
|
||||
|
||||
|
||||
|
@ -406,21 +406,20 @@ abstract class Object {
|
||||
//BC support
|
||||
if(func_num_args() > 1){
|
||||
$class = $classOrExtension;
|
||||
$requiredExtension = $requiredExtension;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$class = get_called_class();
|
||||
$requiredExtension = $classOrExtension;
|
||||
}
|
||||
|
||||
$requiredExtension = strtolower($requiredExtension);
|
||||
$extensions = Config::inst()->get($class, 'extensions');
|
||||
|
||||
if($extensions) foreach($extensions as $extension) {
|
||||
$left = strtolower(Extension::get_classname_without_arguments($extension));
|
||||
$right = strtolower(Extension::get_classname_without_arguments($requiredExtension));
|
||||
if($left == $right) return true;
|
||||
if (!$strict && is_subclass_of($left, $right)) return true;
|
||||
$requiredExtension = Extension::get_classname_without_arguments($requiredExtension);
|
||||
$extensions = self::get_extensions($class);
|
||||
foreach($extensions as $extension) {
|
||||
if(strcasecmp($extension, $requiredExtension) === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!$strict && is_subclass_of($extension, $requiredExtension)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -550,6 +549,12 @@ abstract class Object {
|
||||
*/
|
||||
public static function get_extensions($class, $includeArgumentString = false) {
|
||||
$extensions = Config::inst()->get($class, 'extensions');
|
||||
if(empty($extensions)) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Clean nullified named extensions
|
||||
$extensions = array_filter(array_values($extensions));
|
||||
|
||||
if($includeArgumentString) {
|
||||
return $extensions;
|
||||
|
@ -12,35 +12,47 @@ class CliTestReporter extends SapphireTestReporter {
|
||||
*/
|
||||
public function writeResults() {
|
||||
$passCount = 0;
|
||||
$failCount = 0;
|
||||
$failCount = $this->currentSession['failures'];
|
||||
$testCount = 0;
|
||||
$incompleteCount = 0;
|
||||
$errorCount = 0;
|
||||
$incompleteCount = $this->currentSession['incomplete'];
|
||||
$errorCount = $this->currentSession['errors'];
|
||||
|
||||
foreach($this->suiteResults['suites'] as $suite) {
|
||||
foreach($suite['tests'] as $test) {
|
||||
$testCount++;
|
||||
if($test['status'] == 2) {
|
||||
$incompleteCount++;
|
||||
} elseif($test['status'] === 1) {
|
||||
$passCount++;
|
||||
} else {
|
||||
$failCount++;
|
||||
switch($test['status']) {
|
||||
case TEST_INCOMPLETE: {
|
||||
$incompleteCount++;
|
||||
break;
|
||||
}
|
||||
case TEST_SUCCESS: {
|
||||
$passCount++;
|
||||
break;
|
||||
}
|
||||
case TEST_ERROR: {
|
||||
$errorCount++;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$failCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n\n";
|
||||
if ($failCount == 0 && $incompleteCount > 0) {
|
||||
$breakages = $errorCount + $failCount;
|
||||
if ($breakages == 0 && $incompleteCount > 0) {
|
||||
echo SS_Cli::text(" OK, BUT INCOMPLETE TESTS! ", "black", "yellow");
|
||||
} elseif ($failCount == 0) {
|
||||
} elseif ($breakages == 0) {
|
||||
echo SS_Cli::text(" ALL TESTS PASS ", "black", "green");
|
||||
} else {
|
||||
echo SS_Cli::text(" AT LEAST ONE FAILURE ", "black", "red");
|
||||
}
|
||||
|
||||
echo sprintf("\n\n%d tests run: %s, %s, and %s\n", $testCount, SS_Cli::text("$passCount passes"),
|
||||
SS_Cli::text("$failCount failures"), SS_Cli::text("$incompleteCount incomplete"));
|
||||
SS_Cli::text("$breakages failures"), SS_Cli::text("$incompleteCount incomplete"));
|
||||
|
||||
echo "Maximum memory usage: " . number_format(memory_get_peak_usage()/(1024*1024), 1) . "M\n\n";
|
||||
|
||||
@ -80,6 +92,19 @@ class CliTestReporter extends SapphireTestReporter {
|
||||
parent::endTest($test, $time);
|
||||
}
|
||||
|
||||
protected function addStatus($status, $message, $exception, $trace) {
|
||||
if(!$this->currentTest && !$this->currentSuite) {
|
||||
// Log non-test errors immediately
|
||||
$statusResult = array(
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'exception' => $exception,
|
||||
'trace' => $trace
|
||||
);
|
||||
$this->writeTest($statusResult);
|
||||
}
|
||||
parent::addStatus($status, $message, $exception, $trace);
|
||||
}
|
||||
|
||||
protected function writeTest($test) {
|
||||
if ($test['status'] != TEST_SUCCESS) {
|
||||
|
@ -1,6 +1,10 @@
|
||||
<?php
|
||||
if(!class_exists('PHPUnit_Framework_TestResult', false)) require_once 'PHPUnit/Framework/TestResult.php';
|
||||
if(!class_exists('PHPUnit_Framework_TestListener', false)) require_once 'PHPUnit/Framework/TestListener.php';
|
||||
if(!class_exists('PHPUnit_Framework_TestResult', false)) {
|
||||
require_once 'PHPUnit/Framework/TestResult.php';
|
||||
}
|
||||
if(!class_exists('PHPUnit_Framework_TestListener', false)) {
|
||||
require_once 'PHPUnit/Framework/TestListener.php';
|
||||
}
|
||||
|
||||
/**#@+
|
||||
* @var int
|
||||
@ -45,40 +49,54 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* @var array
|
||||
*/
|
||||
protected $suiteResults;
|
||||
|
||||
/**
|
||||
* Holds data of current suite that is been run
|
||||
* @var array
|
||||
*/
|
||||
protected $currentSuite;
|
||||
|
||||
/**
|
||||
* Holds data of current test that is been run
|
||||
* @var array
|
||||
*/
|
||||
protected $currentTest;
|
||||
|
||||
/**
|
||||
* Whether PEAR Benchmark_Timer is available for timing
|
||||
* @var boolean
|
||||
*/
|
||||
protected $hasTimer;
|
||||
|
||||
/**
|
||||
* Holds the PEAR Benchmark_Timer object
|
||||
* @var obj Benchmark_Timer
|
||||
*
|
||||
* @var Benchmark_Timer
|
||||
*/
|
||||
protected $timer;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $startTestTime;
|
||||
|
||||
/**
|
||||
* An array of all the test speeds
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $testSpeeds = array();
|
||||
|
||||
/**
|
||||
* Errors not belonging to a test or suite
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $currentSession = array();
|
||||
|
||||
/**
|
||||
* Constructor, checks to see availability of PEAR Benchmark_Timer and
|
||||
* sets up basic properties
|
||||
*
|
||||
* @access public
|
||||
* @return void
|
||||
*/
|
||||
public function __construct() {
|
||||
@include_once 'Benchmark/Timer.php';
|
||||
@ -94,6 +112,13 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
'hasTimer' => $this->hasTimer, // availability of PEAR Benchmark_Timer
|
||||
'totalTests' => 0 // total number of tests run
|
||||
);
|
||||
|
||||
$this->currentSession = array(
|
||||
'errors' => 0, // number of tests with errors (including setup errors)
|
||||
'failures' => 0, // number of tests which failed
|
||||
'incomplete' => 0, // number of tests that were not completed correctly
|
||||
'error' => array(), // Any error encountered outside of suites
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -110,54 +135,47 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* Sets up the container for result details of the current test suite when
|
||||
* each suite is first run
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit2_Framework_TestSuite, the suite that is been run
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_TestSuite $suite the suite that is been run
|
||||
*/
|
||||
public function startTestSuite( PHPUnit_Framework_TestSuite $suite) {
|
||||
if(strlen($suite->getName())) {
|
||||
$this->endCurrentTestSuite();
|
||||
public function startTestSuite(PHPUnit_Framework_TestSuite $suite) {
|
||||
$this->endCurrentTestSuite();
|
||||
$this->currentSuite = array(
|
||||
'suite' => $suite, // the test suite
|
||||
'tests' => array(), // the tests in the suite
|
||||
'errors' => 0, // number of tests with errors (including setup errors)
|
||||
'failures' => 0, // number of tests which failed
|
||||
'incomplete' => 0, // number of tests that were not completed correctly
|
||||
'error' => null); // Any error encountered during setup of the test suite
|
||||
}
|
||||
'error' => null, // Any error encountered during setup of the test suite
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the container for result details of the current test when each
|
||||
* test is first run
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_Test, the test that is being run
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_Test $test the test that is being run
|
||||
*/
|
||||
public function startTest(PHPUnit_Framework_Test $test) {
|
||||
$this->startTestTime = microtime(true);
|
||||
$this->endCurrentTest();
|
||||
|
||||
if($test instanceof PHPUnit_Framework_TestCase) {
|
||||
$this->endCurrentTest();
|
||||
$this->currentTest = array(
|
||||
// the name of the test (without the suite name)
|
||||
'name' => preg_replace('(\(.*\))', '', $test->toString()),
|
||||
// execution time of the test
|
||||
'timeElapsed' => 0,
|
||||
// status of the test execution
|
||||
'status' => TEST_SUCCESS,
|
||||
// user message of test result
|
||||
'message' => '',
|
||||
// original caught exception thrown by test upon failure/error
|
||||
'exception' => NULL,
|
||||
// Stacktrace used for exception handling
|
||||
'trace' => NULL,
|
||||
// a unique ID for this test (used for identification purposes in results)
|
||||
'uid' => md5(microtime())
|
||||
);
|
||||
if($this->hasTimer) $this->timer->start();
|
||||
}
|
||||
$this->startTestTime = microtime(true);
|
||||
$this->currentTest = array(
|
||||
// the name of the test (without the suite name)
|
||||
'name' => $this->descriptiveTestName($test),
|
||||
// execution time of the test
|
||||
'timeElapsed' => 0,
|
||||
// status of the test execution
|
||||
'status' => TEST_SUCCESS,
|
||||
// user message of test result
|
||||
'message' => '',
|
||||
// original caught exception thrown by test upon failure/error
|
||||
'exception' => NULL,
|
||||
// Stacktrace used for exception handling
|
||||
'trace' => NULL,
|
||||
// a unique ID for this test (used for identification purposes in results)
|
||||
'uid' => md5(microtime())
|
||||
);
|
||||
if($this->hasTimer) $this->timer->start();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,7 +188,7 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
*/
|
||||
protected function addStatus($status, $message, $exception, $trace) {
|
||||
// Build status body to be saved
|
||||
$status = array(
|
||||
$statusResult = array(
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'exception' => $exception,
|
||||
@ -179,9 +197,11 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
|
||||
// Log either to current test or suite record
|
||||
if($this->currentTest) {
|
||||
$this->currentTest = array_merge($this->currentTest, $status);
|
||||
$this->currentTest = array_merge($this->currentTest, $statusResult);
|
||||
} elseif($this->currentSuite) {
|
||||
$this->currentSuite['error'] = $statusResult;
|
||||
} else {
|
||||
$this->currentSuite['error'] = $status;
|
||||
$this->currentSession['error'][] = $statusResult;
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,13 +209,16 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* Adds the failure detail to the current test and increases the failure
|
||||
* count for the current suite
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_Test, current test that is being run
|
||||
* @param obj PHPUnit_Framework_AssertationFailedError, PHPUnit error
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_Test $test current test that is being run
|
||||
* @param PHPUnit_Framework_AssertionFailedError $e PHPUnit error
|
||||
* @param int $time
|
||||
*/
|
||||
public function addFailure(PHPUnit_Framework_Test $test, PHPUnit_Framework_AssertionFailedError $e, $time) {
|
||||
$this->currentSuite['failures']++;
|
||||
if($this->currentSuite) {
|
||||
$this->currentSuite['failures']++;
|
||||
} else {
|
||||
$this->currentSession['failures']++;
|
||||
}
|
||||
$this->addStatus(TEST_FAILURE, $e->toString(), $this->getTestException($test, $e), $e->getTrace());
|
||||
}
|
||||
|
||||
@ -203,13 +226,16 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* Adds the error detail to the current test and increases the error
|
||||
* count for the current suite
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_Test, current test that is being run
|
||||
* @param obj PHPUnit_Framework_AssertationFailedError, PHPUnit error
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_Test $test current test that is being run
|
||||
* @param Exception $e PHPUnit error
|
||||
* @param int $time
|
||||
*/
|
||||
public function addError(PHPUnit_Framework_Test $test, Exception $e, $time) {
|
||||
$this->currentSuite['errors']++;
|
||||
if($this->currentSuite) {
|
||||
$this->currentSuite['errors']++;
|
||||
} else {
|
||||
$this->currentSession['errors']++;
|
||||
}
|
||||
$this->addStatus(TEST_ERROR, $e->getMessage(), $this->getTestException($test, $e), $e->getTrace());
|
||||
}
|
||||
|
||||
@ -217,21 +243,24 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* Adds the test incomplete detail to the current test and increases the incomplete
|
||||
* count for the current suite
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_Test, current test that is being run
|
||||
* @param obj PHPUnit_Framework_AssertationFailedError, PHPUnit error
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_Test $test current test that is being run
|
||||
* @param Exception $e PHPUnit error
|
||||
*/
|
||||
public function addIncompleteTest(PHPUnit_Framework_Test $test, Exception $e, $time) {
|
||||
$this->currentSuite['incomplete']++;
|
||||
$this->addStatus(TEST_INCOMPLETE, $e->toString(), $this->getTestException($test, $e), $e->getTrace());
|
||||
if($this->currentSuite) {
|
||||
$this->currentSuite['incomplete']++;
|
||||
} else {
|
||||
$this->currentSession['incomplete']++;
|
||||
}
|
||||
$this->addStatus(TEST_INCOMPLETE, $e->getMessage(), $this->getTestException($test, $e), $e->getTrace());
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used
|
||||
*
|
||||
* @param PHPUnit_Framework_Test $test
|
||||
* @param unknown_type $time
|
||||
* @param Exception $e
|
||||
* @param int $time
|
||||
*/
|
||||
public function addSkippedTest(PHPUnit_Framework_Test $test, Exception $e, $time) {
|
||||
// not implemented
|
||||
@ -261,9 +290,8 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
* Upon completion of a test, records the execution time (if available) and adds the test to
|
||||
* the tests performed in the current suite.
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_Test, current test that is being run
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_Test $test Current test that is being run
|
||||
* @param int $time
|
||||
*/
|
||||
public function endTest( PHPUnit_Framework_Test $test, $time) {
|
||||
$this->endCurrentTest();
|
||||
@ -290,9 +318,7 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
/**
|
||||
* Upon completion of a test suite adds the suite to the suties performed
|
||||
*
|
||||
* @access public
|
||||
* @param obj PHPUnit_Framework_TestSuite, current suite that is being run
|
||||
* @return void
|
||||
* @param PHPUnit_Framework_TestSuite $suite current suite that is being run
|
||||
*/
|
||||
public function endTestSuite( PHPUnit_Framework_TestSuite $suite) {
|
||||
if(strlen($suite->getName())) {
|
||||
@ -313,28 +339,32 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trys to get the original exception thrown by the test on failure/error
|
||||
* Tries to get the original exception thrown by the test on failure/error
|
||||
* to enable us to give a bit more detail about the failure/error
|
||||
*
|
||||
* @access private
|
||||
* @param obj PHPUnit_Framework_Test, current test that is being run
|
||||
* @param obj PHPUnit_Framework_AssertationFailedError, PHPUnit error
|
||||
* @param PHPUnit_Framework_Test $test current test that is being run
|
||||
* @param Exception $e PHPUnit error
|
||||
* @return array
|
||||
*/
|
||||
private function getTestException(PHPUnit_Framework_Test $test, Exception $e) {
|
||||
// get the name of the testFile from the test
|
||||
$testName = preg_replace('/(.*)\((.*[^)])\)/', '\\2', $test->toString());
|
||||
$testName = $this->descriptiveTestName($test);
|
||||
$trace = $e->getTrace();
|
||||
// loop through the exception trace to find the original exception
|
||||
for($i = 0; $i < count($trace); $i++) {
|
||||
|
||||
if(array_key_exists('file', $trace[$i])) {
|
||||
if(stristr($trace[$i]['file'], $testName.'.php') != false) return $trace[$i];
|
||||
if(stristr($trace[$i]['file'], $testName.'.php') != false) {
|
||||
return $trace[$i];
|
||||
}
|
||||
}
|
||||
if(array_key_exists('file:protected', $trace[$i])) {
|
||||
if(stristr($trace[$i]['file:protected'], $testName.'.php') != false) return $trace[$i];
|
||||
if(stristr($trace[$i]['file:protected'], $testName.'.php') != false) {
|
||||
return $trace[$i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -364,6 +394,21 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
// A suite may not end correctly if there was an error during setUp
|
||||
$this->endCurrentTestSuite();
|
||||
|
||||
// Write session errors
|
||||
if($this->currentSession['error']) {
|
||||
$errorCount += $this->currentSession['errors'];
|
||||
$failCount += $this->currentSession['failures'];
|
||||
$incompleteCount += $this->currentSession['incomplete'];
|
||||
foreach($this->currentSession['error'] as $error) {
|
||||
$this->writeResultError(
|
||||
'Session',
|
||||
$error['message'],
|
||||
$error['trace']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write suite errors
|
||||
foreach($this->suiteResults['suites'] as $suite) {
|
||||
|
||||
// Report suite error. In the case of fatal non-success messages
|
||||
@ -409,5 +454,23 @@ class SapphireTestReporter implements PHPUnit_Framework_TestListener {
|
||||
protected function testNameToPhrase($name) {
|
||||
return ucfirst(preg_replace("/([a-z])([A-Z])/", "$1 $2", $name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name for this test
|
||||
*
|
||||
* @param PHPUnit_Framework_Test $test
|
||||
* @return string
|
||||
*/
|
||||
protected function descriptiveTestName(PHPUnit_Framework_Test $test) {
|
||||
if ($test instanceof PHPUnit_Framework_TestCase) {
|
||||
$name = $test->toString();
|
||||
} elseif(method_exists($test, 'getName')) {
|
||||
$name = $test->getName();
|
||||
} else {
|
||||
$name = get_class($test);
|
||||
}
|
||||
// the name of the test (without the suite name)
|
||||
return preg_replace('(\(.*\))', '', $name);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,8 +37,11 @@ But enough of the disclaimer, on to the actual configuration — typically in `n
|
||||
error_page 500 /assets/error-500.html;
|
||||
|
||||
location ^~ /assets/ {
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
sendfile on;
|
||||
try_files $uri =404;
|
||||
try_files $uri /framework/main.php?url=$uri&$query_string;
|
||||
}
|
||||
|
||||
location ~ /framework/.*(main|rpc|tiny_mce_gzip)\.php$ {
|
||||
|
@ -430,29 +430,10 @@ standard PHP way. See [casting](/topics/datamodel#casting).
|
||||
|
||||
## Filesystem
|
||||
|
||||
### Don't allow script-execution in /assets
|
||||
### Don't script-execution in /assets
|
||||
|
||||
As all uploaded files are stored by default on the /assets-directory, you should disallow script-execution for this
|
||||
folder. This is just an additional security-measure to making sure you avoid directory-traversal, check for filesize and
|
||||
disallow certain filetypes.
|
||||
|
||||
Example configuration for Apache2:
|
||||
|
||||
<VirtualHost *:80>
|
||||
<LocationMatch assets/>
|
||||
php_flag engine off
|
||||
Options -ExecCGI -Includes -Indexes
|
||||
</LocationMatch>
|
||||
</VirtualHost>
|
||||
|
||||
|
||||
If you are using shared hosting or in a situation where you cannot alter your Vhost definitions, you can use a .htaccess
|
||||
file in the assets directory. This requires PHP to be loaded as an Apache module (not CGI or FastCGI).
|
||||
|
||||
**/assets/.htaccess**
|
||||
|
||||
php_flag engine off
|
||||
Options -ExecCGI -Includes -Indexes
|
||||
Please refer to the article on [file security](/developer_guides/files/file_security)
|
||||
for instructions on how to secure the assets folder against malicious script execution.
|
||||
|
||||
### Don't allow access to YAML files
|
||||
|
||||
|
375
docs/en/02_Developer_Guides/14_Files/03_File_Security.md
Normal file
375
docs/en/02_Developer_Guides/14_Files/03_File_Security.md
Normal file
@ -0,0 +1,375 @@
|
||||
summary: Manage access permission to assets
|
||||
|
||||
# File Security
|
||||
|
||||
## Security overview
|
||||
|
||||
File security is an important concept, and is as essential as managing any other piece of data that exists
|
||||
in your system. As pages and dataobjects can be either versioned, or restricted to view by authenticated
|
||||
members, it is necessary at times to apply similar logic to any files which are attached to these objects
|
||||
in the same way.
|
||||
|
||||
Out of the box SilverStripe Framework comes with an asset storage mechanism with two stores, a public
|
||||
store and a protected one. Most operations which act on assets work independently of this mechanism,
|
||||
without having to consider whether any specific file is protected or public, but can normally be
|
||||
instructed to favour private or protected stores in some cases.
|
||||
|
||||
For instance, in order to write an asset to a protected location you can use the following additional
|
||||
config option:
|
||||
|
||||
|
||||
:::php
|
||||
$store = singleton('AssetStore');
|
||||
$store->setFromString('My protected content', 'Documents/Mydocument.txt', null, null, array(
|
||||
'visibility' => AssetStore::VISIBILITY_PROTECTED
|
||||
));
|
||||
|
||||
|
||||
## User access control
|
||||
|
||||
Access for files is granted on a per-session basis, rather than on a per-member basis, via
|
||||
whitelisting accessed assets. This means that access to any protected asset must be made prior to the user
|
||||
actually attempting to download that asset. This is normally done in the PHP request that generated
|
||||
the response containing the link to that file.
|
||||
|
||||
An automated system will, in most cases, handle this whitelisting for you. Calls to getURL()
|
||||
will automatically whitelist access to that file for the current user. Using this as a guide, you can easily
|
||||
control access to embedded assets at a template level.
|
||||
|
||||
:::ss
|
||||
<ul class="files">
|
||||
<% loop $File %>
|
||||
<% if $canView %>
|
||||
<li><a href="$URL">Download $Title</a></li>
|
||||
<% else %>
|
||||
<li>Permission denied for $Title</li>
|
||||
<% end_if %>
|
||||
<% end_loop >
|
||||
</ul>
|
||||
|
||||
Users who are able to guess the value of $URL will not be able to access those urls without being
|
||||
authorised by this code.
|
||||
|
||||
In order to ensure protected assets are not leaked publicly, but are properly whitelisted for
|
||||
authorised users, the following should be considered:
|
||||
|
||||
* Caching mechanisms which prevent `$URL` being invoked for the user's request (such as `$URL` within a
|
||||
partial cache block) will not whitelist those files automatically. You can manually whitelist a
|
||||
file via PHP for the current user instead, by using the following code to grant access.
|
||||
|
||||
|
||||
:::php
|
||||
class Page_Controller extends ContentController {
|
||||
public function init() {
|
||||
parent::init();
|
||||
// Whitelist any protected files on this page for the current user
|
||||
foreach($this->Files() as $file) {
|
||||
if($file->canView()) {
|
||||
$file->grantFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* If a user does not have access to a file, you can still generate the URL but suppress the default
|
||||
permission whitelist by invoking the getter as a method, but pass in a falsey value as a parameter.
|
||||
(or '0' in template as a workaround for all parameters being cast as string)
|
||||
|
||||
|
||||
::php
|
||||
<% if not $canView %>
|
||||
<!-- The user will be denied if they follow this url -->
|
||||
<li><a href="$getURL(0)">Access to $Title is denied</a></li>
|
||||
<% else %>
|
||||
|
||||
|
||||
* Alternatively, if a user has already been granted access, you can explicitly revoke their access using
|
||||
the `revokeFile` method.
|
||||
|
||||
|
||||
:::php
|
||||
class Page_Controller extends ContentController {
|
||||
public function init() {
|
||||
parent::init();
|
||||
// Whitelist any protected files on this page for the current user
|
||||
foreach($this->Files() as $file) {
|
||||
if($file->canView()) {
|
||||
$file->grantFile();
|
||||
} else {
|
||||
// Will revoke any historical grants
|
||||
$file->revokeFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Controlling asset visibility
|
||||
|
||||
The asset API provides three main mechanisms for setting the visibility of an asset. Note that
|
||||
these operations are applied on a per file basis, and unlike `revoke` or `grant` methods
|
||||
these do not affect visibility for specific users.
|
||||
|
||||
Visibility can be specified when files are created via one of the `AssetStore::VISIBILITY_PROTECTED`
|
||||
or `AssetStore::VISIBILITY_PUBLIC` constants. It's advisable to ensure the visibility of any file
|
||||
is declared as early as possible, so that potentially sensitive content never touches any
|
||||
public facing area.
|
||||
|
||||
E.g.
|
||||
|
||||
|
||||
:::php
|
||||
$object->MyFile->setFromLocalFile($tmpFile['Path'], $filename, null, null, array(
|
||||
'visibility' => AssetStore::VISIBILITY_PROTECTED
|
||||
));
|
||||
|
||||
|
||||
You can also adjust the visibility of any existing file to either public or protected.
|
||||
|
||||
|
||||
:::
|
||||
// This will make the file available only when a user calls `->grant()`
|
||||
$object->SecretFile->protectFile();
|
||||
|
||||
// This file will be available to everyone with the URL
|
||||
$object->PublicFile->publishFile();
|
||||
|
||||
|
||||
<div class="notice" markdown="1">
|
||||
One thing to note is that all variants of a single file will be treated as
|
||||
a single entity for access control, so specific variants cannot be individually controlled.
|
||||
</div>
|
||||
|
||||
## How file access is protected
|
||||
|
||||
Public urls to files do not change, regardless of whether the file is protected or public. Similarly,
|
||||
operations which modify files do not normally need to be told whether the file is protected or public
|
||||
either. This provides a consistent method for interacting with files.
|
||||
|
||||
In day to day operation, moving assets to or between either of these stores does not normally
|
||||
alter these asset urls, as the routing mechanism will infer file access requirements dynamically.
|
||||
This allows you to prepare predictable file urls on a draft site, which will not change once
|
||||
the page is published, but will be protected in the mean time.
|
||||
|
||||
For instance, consider two files `OldCompanyLogo.gif` in the public store, and `NewCompanyLogo.gif`
|
||||
in the protected store, awaiting publishing.
|
||||
|
||||
Internally your folder structure would look something like:
|
||||
|
||||
|
||||
:::
|
||||
assets/
|
||||
.htaccess
|
||||
.protected/
|
||||
.htaccess
|
||||
a870de278b/
|
||||
NewCompanyLogo.gif
|
||||
33be1b95cb/
|
||||
OldCompanyLogo.gif
|
||||
|
||||
|
||||
The urls for these two files, however, do not reflect the physical structure directly.
|
||||
|
||||
* `http://www.mysite.com/assets/33be1b95cb/OldCompanyLogo.gif` will be served directly from the web server,
|
||||
and will not invoke a php request.
|
||||
* `http://www.mysite.com/assets/a870de278b/NewCompanyLogo.gif` will be routed via a 404 handler to PHP,
|
||||
which will be passed to the `[api:ProtectedFileController]` controller, which will serve
|
||||
up the content of the hidden file, conditional on a permission check.
|
||||
|
||||
When the file `NewCompanyLogo.gif` is made public, the url will not change, but the file location
|
||||
will be moved to `assets/a870de278b/NewCompanyLogo.gif`, and will be served directly via
|
||||
the web server, bypassing the need for additional PHP requests.
|
||||
|
||||
|
||||
:::php
|
||||
$store = singleton('AssetStore');
|
||||
$store->publish('NewCompanyLogo.gif', 'a870de278b475cb75f5d9f451439b2d378e13af1');
|
||||
|
||||
|
||||
After this the filesystem will now look like below:
|
||||
|
||||
|
||||
:::
|
||||
assets/
|
||||
.htaccess
|
||||
.protected/
|
||||
.htaccess
|
||||
33be1b95cb/
|
||||
OldCompanyLogo.gif
|
||||
a870de278b/
|
||||
NewCompanyLogo.gif
|
||||
|
||||
|
||||
## Performance considerations
|
||||
|
||||
In order to ensure that a site does not invoke any unnecessary PHP processes when serving up files,
|
||||
it's important to ensure that your server is configured correctly. Serving public files via PHP
|
||||
will add unnecessary load to your server, but conversely, attempting to serve protected files
|
||||
directly may lead to necessary security checks being omitted.
|
||||
|
||||
See the web server setting section below for more information on configuring your server properly
|
||||
|
||||
### Performance: Static caching
|
||||
|
||||
If you are deploying your site to a server configuration that makes use of static caching, it's essential
|
||||
that you ensure any page or dataobject cached adequately publishes any linked assets. This is due to the
|
||||
fact that static caching will bypass any PHP request, which would otherwise be necessary to whitelist
|
||||
protected files for these users.
|
||||
|
||||
This is especially important when dealing with draft content, as frontend caches should not attempt to
|
||||
cache protected content being served to authenticated users. This can be achieved by configuring your cache
|
||||
correctly to skip `Pragma: no-cache` headers and the `bypassStaticCache` cookie.
|
||||
|
||||
## Configuring protected assets
|
||||
|
||||
### Configuring: Protected folder location
|
||||
|
||||
In the default SilverStripe configuration, protected assets are placed within the web root into the
|
||||
`assets/.protected` folder, into which is also generated a `.htaccess` or `web.config` configured
|
||||
to deny any and all direct web requests.
|
||||
|
||||
In order to better ensure these files are protected, it's recommended to move this outside of the web
|
||||
root altogether.
|
||||
|
||||
For instance, given your web root is in the folder `/sites/mysite/www`, you can tell the asset store
|
||||
to put protected files into `/sites/mysite/protected` with the below `_ss_environment.php` setting:
|
||||
|
||||
|
||||
:::php
|
||||
define('SS_PROTECTED_ASSETS_PATH', '/sites/mysite/protected');
|
||||
|
||||
|
||||
### Configuring: File types
|
||||
|
||||
In addition to configuring file locations, it's also important to ensure that you have allowed the
|
||||
appropriate file extensions for your instance. This can be done by setting the `File.allowed_extensions`
|
||||
config.
|
||||
|
||||
|
||||
:::yaml
|
||||
File:
|
||||
allowed_extensions:
|
||||
- 7zip
|
||||
- xzip
|
||||
|
||||
|
||||
<div class="warning" markdown="1">
|
||||
Any file not included in this config, or in the default list of extensions, will be blocked from
|
||||
any requests to the assets directory. Invalid files will be blocked regardless of whether they
|
||||
exist or not, and will not invoke any PHP processes.
|
||||
</div>
|
||||
|
||||
### Configuring: Protected file headers
|
||||
|
||||
In certain situations, it's necessary to customise HTTP headers required either by
|
||||
intermediary caching services, or by the client, or upstream caches.
|
||||
|
||||
When a protected file is served it will also be transmitted with all headers defined by the
|
||||
`SilverStripe\Filesystem\Flysystem\FlysystemAssetStore.file_response_headers` config.
|
||||
You can customise this with the below config:
|
||||
|
||||
|
||||
:::yaml
|
||||
SilverStripe\Filesystem\Flysystem\FlysystemAssetStore:
|
||||
file_response_headers:
|
||||
Pragma: 'no-cache'
|
||||
|
||||
|
||||
### Configuring: Archive behaviour
|
||||
|
||||
By default, the default extension `AssetControlExtension` will control the disposal of assets
|
||||
attached to objects when those objects are deleted. For example, unpublished versioned objects
|
||||
will automatically have their attached assets moved to the protected store. The deletion of
|
||||
draft or unversioned objects will have those assets permanantly deleted (along with all variants).
|
||||
|
||||
In some cases, it may be preferable to have any deleted assets archived for versioned dataobjects,
|
||||
rather than deleted. This uses more disk storage, but will allow the full recovery of archived
|
||||
records and files.
|
||||
|
||||
This can be applied to DataObjects on a case by case basis by setting the `archive_assets`
|
||||
config to true on that class. Note that this feature only works with dataobjects with
|
||||
the `Versioned` extension.
|
||||
|
||||
|
||||
:::php
|
||||
class MyVersiondObject extends DataObject {
|
||||
/** Enable archiving */
|
||||
private static $archive_assets = true;
|
||||
/** Versioned */
|
||||
private static $extensions = array('Versioned');
|
||||
}
|
||||
|
||||
|
||||
The extension can also be globally disabled by removing it at the root level:
|
||||
|
||||
|
||||
:::yaml
|
||||
DataObject:
|
||||
AssetControl: null
|
||||
|
||||
|
||||
### Configuring: Web server settings
|
||||
|
||||
If the default server configuration is not appropriate for your specific environment, then you can
|
||||
further customise the .htaccess or web.config by editing one or more of the below:
|
||||
|
||||
* `Assets_HTAccess.ss`: Template for public permissions on the Apache server.
|
||||
* `Assets_WebConfig.ss`: Template for public permissions on the IIS server.
|
||||
* `Protected_HTAccess.ss`: Template for the protected store on the Apache server (should deny all requests).
|
||||
* `Protected_WebConfig.ss`: Template for the protected store on the IIS server (should deny all requests).
|
||||
|
||||
Each of these files will be regenerated on ?flush, so it is important to ensure that these files are
|
||||
overridden at the template level, not via manually generated configuration files.
|
||||
|
||||
#### Configuring Web Server: Apache server
|
||||
|
||||
In order to ensure that public files are served correctly, you should check that your ./assets
|
||||
.htaccess bypasses PHP requests for files that do exist. The default template
|
||||
(declared by `Assets_HTAccess.ss`) has the following section, which may be customised in your project:
|
||||
|
||||
|
||||
# Non existant files passed to requesthandler
|
||||
RewriteCond %{REQUEST_URI} ^(.*)$
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule .* ../framework/main.php?url=%1 [QSA]
|
||||
|
||||
|
||||
You will need to ensure that your core apache configuration has the necessary `AllowOverride`
|
||||
settings to support the local .htaccess file.
|
||||
|
||||
#### Configuring Web Server: Windows IIS 7.5+
|
||||
|
||||
Configuring via IIS requires the Rewrite extension to be installed and configured properly.
|
||||
Any rules declared for the assets folder should be able to dynamically serve up existing files,
|
||||
while ensuring non-existent files are processed via the Framework.
|
||||
|
||||
The default rule for IIS is as below (only partial configuration displayed):
|
||||
|
||||
|
||||
<rule name="Protected and 404 File rewrite" stopProcessing="true">
|
||||
<match url="^(.*)$" />
|
||||
<conditions>
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="../framework/main.php?url={R:1}" appendQueryString="true" />
|
||||
</rule>
|
||||
|
||||
|
||||
You will need to make sure that the `allowOverride` property of your root web.config is not set
|
||||
to false, to allow these to take effect.
|
||||
|
||||
#### Configuring Web Server: Other server types
|
||||
|
||||
If using a server configuration which must be configured outside of the web or asset root, you
|
||||
will need to make sure you manually configure these rules.
|
||||
|
||||
For instance, this will allow your nginx site to serve files directly, while ensuring
|
||||
dynamic requests are processed via the Framework:
|
||||
|
||||
|
||||
:::
|
||||
location ^~ /assets/ {
|
||||
sendfile on;
|
||||
try_files $uri /framework/main.php?url=$uri&$query_string;
|
||||
}
|
||||
|
@ -21,6 +21,8 @@
|
||||
urls are no longer able to identify assets.
|
||||
* Extension point `HtmlEditorField::processImage` has been removed, and moved to `Image::regenerateImageHTML`
|
||||
* `Upload::load` now stores assets directly without saving into a `File` dataobject.
|
||||
* Protected file storage is now a core Framework API. See [/developer_guides/files/file_security] for
|
||||
more information.
|
||||
|
||||
## New API
|
||||
|
||||
@ -33,6 +35,9 @@
|
||||
* `Requirements_Minifier` API can be used to declare any new mechanism for minifying combined required files.
|
||||
By default this api is provided by the `JSMinifier` class, but user code can substitute their own.
|
||||
* `AssetField` formfield to provide an `UploadField` style uploader for the new `DBFile` database field.
|
||||
* `AssetControlExtension` is applied by default to all DataObjects, in order to support the management
|
||||
of linked assets and file protection.
|
||||
* `ProtectedFileController` class is used to serve up protected assets.
|
||||
|
||||
## Deprecated classes/methods
|
||||
|
||||
@ -461,3 +466,19 @@ E.g.
|
||||
extensions:
|
||||
- MyErrorPageExtension
|
||||
|
||||
### Upgrading asset web.config, .htaccess, or other server configuration
|
||||
|
||||
Server configuration files for /assets are no longer static, and are regenerated via a set of
|
||||
standard silverstripe templates on flush. These templates include:
|
||||
|
||||
* `Assets_HTAccess.ss`: Template for public permissions on the Apache server.
|
||||
* `Assets_WebConfig.ss`: Template for public permissions on the IIS server.
|
||||
* `Protected_HTAccess.ss`: Template for the protected store on the Apache server (should deny all requests).
|
||||
* `Protected_WebConfig.ss`: Template for the protected store on the IIS server (should deny all requests).
|
||||
|
||||
You will need to make sure that these files are writable via the web server, and that any necessary
|
||||
configuration customisation is done via overriding these templates.
|
||||
|
||||
If upgrading from an existing installation, make sure to invoke ?flush=all at least once.
|
||||
|
||||
See [/developer_guides/files/file_security] for more information.
|
||||
|
69
filesystem/AssetAdapterTest.php
Normal file
69
filesystem/AssetAdapterTest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use SilverStripe\Filesystem\Flysystem\ProtectedAssetAdapter;
|
||||
use SilverStripe\Filesystem\Flysystem\PublicAssetAdapter;
|
||||
|
||||
class AssetAdapterTest extends SapphireTest {
|
||||
|
||||
protected $rootDir = null;
|
||||
|
||||
protected $originalServer = null;
|
||||
|
||||
public function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
$this->rootDir = ASSETS_PATH . '/AssetAdapterTest';
|
||||
Filesystem::makeFolder($this->rootDir);
|
||||
$this->originalServer = $_SERVER;
|
||||
}
|
||||
|
||||
public function tearDown() {
|
||||
if($this->rootDir) {
|
||||
Filesystem::removeFolder($this->rootDir);
|
||||
$this->rootDir = null;
|
||||
}
|
||||
if($this->originalServer) {
|
||||
$_SERVER = $this->originalServer;
|
||||
$this->originalServer = null;
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testPublicAdapter() {
|
||||
$_SERVER['SERVER_SOFTWARE'] = 'Apache/2.2.22 (Win64) PHP/5.3.13';
|
||||
$adapter = new PublicAssetAdapter($this->rootDir);
|
||||
$this->assertFileExists($this->rootDir . '/.htaccess');
|
||||
$this->assertFileNotExists($this->rootDir . '/web.config');
|
||||
|
||||
$htaccess = $adapter->read('.htaccess');
|
||||
$content = $htaccess['contents'];
|
||||
// Allowed extensions set
|
||||
$this->assertContains('RewriteCond %{REQUEST_URI} !.(?i:', $content);
|
||||
foreach(File::config()->allowed_extensions as $extension) {
|
||||
$this->assertRegExp('/\b'.preg_quote($extension).'\b/', $content);
|
||||
}
|
||||
|
||||
// Rewrite rules
|
||||
$this->assertContains('RewriteRule .* ../framework/main.php?url=%1 [QSA]', $content);
|
||||
$this->assertContains('RewriteRule error[^\\/]*.html$ - [L]', $content);
|
||||
|
||||
// Test flush restores invalid content
|
||||
\file_put_contents($this->rootDir . '/.htaccess', '# broken content');
|
||||
$adapter->flush();
|
||||
$htaccess2 = $adapter->read('.htaccess');
|
||||
$this->assertEquals($content, $htaccess2['contents']);
|
||||
|
||||
// Test URL
|
||||
$this->assertEquals('/assets/AssetAdapterTest/file.jpg', $adapter->getPublicUrl('file.jpg'));
|
||||
}
|
||||
|
||||
public function testProtectedAdapter() {
|
||||
$_SERVER['SERVER_SOFTWARE'] = 'Apache/2.2.22 (Win64) PHP/5.3.13';
|
||||
$adapter = new ProtectedAssetAdapter($this->rootDir . '/.protected');
|
||||
$this->assertFileExists($this->rootDir . '/.protected/.htaccess');
|
||||
$this->assertFileNotExists($this->rootDir . '/.protected/web.config');
|
||||
|
||||
// Test url
|
||||
$this->assertEquals('/assets/file.jpg', $adapter->getProtectedUrl('file.jpg'));
|
||||
}
|
||||
}
|
113
filesystem/AssetControlExtension.php
Normal file
113
filesystem/AssetControlExtension.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem;
|
||||
|
||||
use DataObject;
|
||||
use Injector;
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
|
||||
/**
|
||||
* Provides an interaction mechanism between objects and linked asset references.
|
||||
*/
|
||||
class AssetControlExtension extends \DataExtension {
|
||||
|
||||
/**
|
||||
* When archiving versioned dataobjects, should assets be archived with them?
|
||||
* If false, assets will be deleted when the object is removed from draft.
|
||||
* If true, assets will be instead moved to the protected store.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $archive_assets = false;
|
||||
|
||||
public function onAfterDelete() {
|
||||
$assets = $this->findAssets($this->owner);
|
||||
|
||||
// When deleting from live, just secure assets
|
||||
// Note that DataObject::delete() ignores sourceQueryParams
|
||||
if($this->isVersioned() && \Versioned::current_stage() === \Versioned::get_live_stage()) {
|
||||
$this->protectAll($assets);
|
||||
return;
|
||||
}
|
||||
|
||||
// When deleting from stage then check if we should archive assets
|
||||
$archive = $this->owner->config()->archive_assets;
|
||||
if($archive && $this->isVersioned()) {
|
||||
// Archived assets are kept protected
|
||||
$this->protectAll($assets);
|
||||
} else {
|
||||
// Otherwise remove all assets
|
||||
$this->deleteAll($assets);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of all tuples attached to this dataobject
|
||||
* Note: Variants are excluded
|
||||
*
|
||||
* @param DataObject $record to search
|
||||
* @return array
|
||||
*/
|
||||
protected function findAssets(DataObject $record) {
|
||||
// Search for dbfile instances
|
||||
$files = array();
|
||||
foreach($record->db() as $field => $db) {
|
||||
// Extract assets from this database field
|
||||
list($dbClass) = explode('(', $db);
|
||||
if(!is_a($dbClass, 'DBFile', true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Omit variant and merge with set
|
||||
$next = $record->dbObject($field)->getValue();
|
||||
unset($next['Variant']);
|
||||
if ($next) {
|
||||
$files[] = $next;
|
||||
}
|
||||
}
|
||||
|
||||
// De-dupe
|
||||
return array_map("unserialize", array_unique(array_map("serialize", $files)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if versioning rules should be applied to this object
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isVersioned() {
|
||||
return $this->owner->has_extension('Versioned');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all assets in the tuple list
|
||||
*
|
||||
* @param array $assets
|
||||
*/
|
||||
protected function deleteAll($assets) {
|
||||
$store = $this->getAssetStore();
|
||||
foreach($assets as $asset) {
|
||||
$store->delete($asset['Filename'], $asset['Hash']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move all assets in the list to the protected store
|
||||
*
|
||||
* @param array $assets
|
||||
*/
|
||||
protected function protectAll($assets) {
|
||||
$store = $this->getAssetStore();
|
||||
foreach($assets as $asset) {
|
||||
$store->protect($asset['Filename'], $asset['Hash']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AssetStore
|
||||
*/
|
||||
protected function getAssetStore() {
|
||||
return Injector::inst()->get('AssetStore');
|
||||
}
|
||||
|
||||
}
|
@ -667,22 +667,24 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
* Gets the URL of this file
|
||||
*
|
||||
* @uses Director::baseURL()
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* @return string
|
||||
*/
|
||||
public function getURL() {
|
||||
public function getURL($grant = true) {
|
||||
if($this->File->exists()) {
|
||||
return $this->File->getURL();
|
||||
return $this->File->getURL($grant);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL, but without resampling.
|
||||
*
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* @return string
|
||||
*/
|
||||
public function getSourceURL() {
|
||||
public function getSourceURL($grant = true) {
|
||||
if($this->File->exists()) {
|
||||
return $this->File->getSourceURL();
|
||||
return $this->File->getSourceURL($grant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -971,8 +973,8 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
}
|
||||
}
|
||||
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
$result = $this->File->setFromLocalFile($path, $filename, $hash, $variant, $conflictResolution);
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) {
|
||||
$result = $this->File->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
||||
|
||||
// Update File record to name of the uploaded asset
|
||||
if($result) {
|
||||
@ -981,8 +983,8 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
$result = $this->File->setFromStream($stream, $filename, $hash, $variant, $conflictResolution);
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
$result = $this->File->setFromStream($stream, $filename, $hash, $variant, $config);
|
||||
|
||||
// Update File record to name of the uploaded asset
|
||||
if($result) {
|
||||
@ -991,8 +993,8 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
$result = $this->File->setFromString($data, $filename, $hash, $variant, $conflictResolution);
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
$result = $this->File->setFromString($data, $filename, $hash, $variant, $config);
|
||||
|
||||
// Update File record to name of the uploaded asset
|
||||
if($result) {
|
||||
@ -1075,4 +1077,31 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public function deleteFile() {
|
||||
return $this->File->deleteFile();
|
||||
}
|
||||
|
||||
public function getVisibility() {
|
||||
return $this->File->getVisibility();
|
||||
}
|
||||
|
||||
public function publishFile() {
|
||||
$this->File->publishFile();
|
||||
}
|
||||
|
||||
public function protectFile() {
|
||||
$this->File->protectFile();
|
||||
}
|
||||
|
||||
public function grantFile() {
|
||||
$this->File->grantFile();
|
||||
}
|
||||
|
||||
public function revokeFile() {
|
||||
$this->File->revokeFile();
|
||||
}
|
||||
|
||||
public function canViewFile() {
|
||||
return $this->File->canViewFile();
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,10 @@ class FileMigrationHelper extends Object {
|
||||
|
||||
// Copy local file into this filesystem
|
||||
$filename = $file->getFilename();
|
||||
$result = $file->setFromLocalFile($path, $filename, null, null, AssetStore::CONFLICT_OVERWRITE);
|
||||
$result = $file->setFromLocalFile(
|
||||
$path, $filename, null, null,
|
||||
array('conflict' => AssetStore::CONFLICT_OVERWRITE)
|
||||
);
|
||||
|
||||
// Move file if the APL changes filename value
|
||||
if($result['Filename'] !== $filename) {
|
||||
|
@ -239,9 +239,10 @@ class Folder extends File {
|
||||
/**
|
||||
* Folders do not have public URLs
|
||||
*
|
||||
* @return null
|
||||
* @param bool $grant
|
||||
* @return null|string
|
||||
*/
|
||||
public function getURL() {
|
||||
public function getURL($grant = true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -548,14 +548,14 @@ class GDBackend extends Object implements Image_Backend, Flushable {
|
||||
return $output;
|
||||
}
|
||||
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
// Write to temporary file, taking care to maintain the extension
|
||||
$path = tempnam(sys_get_temp_dir(), 'gd');
|
||||
if($extension = pathinfo($filename, PATHINFO_EXTENSION)) {
|
||||
$path .= "." . $extension;
|
||||
}
|
||||
$this->writeTo($path);
|
||||
$result = $assetStore->setFromLocalFile($path, $filename, $hash, $variant, $conflictResolution);
|
||||
$result = $assetStore->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
||||
unlink($path);
|
||||
return $result;
|
||||
}
|
||||
|
@ -54,9 +54,10 @@ trait ImageManipulation {
|
||||
abstract public function getStream();
|
||||
|
||||
/**
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* @return string public url to the asset in this container
|
||||
*/
|
||||
abstract public function getURL();
|
||||
abstract public function getURL($grant = true);
|
||||
|
||||
/**
|
||||
* @return string The absolute URL to the asset in this container
|
||||
@ -692,7 +693,10 @@ trait ImageManipulation {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $backend->writeToStore($store, $filename, $hash, $variant, AssetStore::CONFLICT_USE_EXISTING);
|
||||
return $backend->writeToStore(
|
||||
$store, $filename, $hash, $variant,
|
||||
array('conflict' => AssetStore::CONFLICT_USE_EXISTING)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -49,14 +49,14 @@ class ImagickBackend extends Imagick implements Image_Backend {
|
||||
$this->setQuality(Config::inst()->get('ImagickBackend', 'default_quality'));
|
||||
}
|
||||
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
// Write to temporary file, taking care to maintain the extension
|
||||
$path = tempnam(sys_get_temp_dir(), 'imagemagick');
|
||||
if($extension = pathinfo($filename, PATHINFO_EXTENSION)) {
|
||||
$path .= "." . $extension;
|
||||
}
|
||||
$this->writeimage($path);
|
||||
$result = $assetStore->setFromLocalFile($path, $filename, $hash, $variant, $conflictResolution);
|
||||
$result = $assetStore->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
||||
unlink($path);
|
||||
return $result;
|
||||
}
|
||||
|
@ -198,7 +198,8 @@ class Upload extends Controller {
|
||||
$conflictResolution = $this->replaceFile
|
||||
? AssetStore::CONFLICT_OVERWRITE
|
||||
: AssetStore::CONFLICT_RENAME;
|
||||
return $container->setFromLocalFile($tmpFile['tmp_name'], $filename, null, null, $conflictResolution);
|
||||
$config = array('conflict' => $conflictResolution);
|
||||
return $container->setFromLocalFile($tmpFile['tmp_name'], $filename, null, null, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,18 +2,24 @@
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use Controller;
|
||||
use Director;
|
||||
use League\Flysystem\Adapter\Local;
|
||||
|
||||
/**
|
||||
* Adaptor for local filesystem based on assets directory
|
||||
* Adapter for local filesystem based on assets directory
|
||||
*
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
class AssetAdapter extends Local {
|
||||
|
||||
/**
|
||||
* Server specific configuration necessary to block http traffic to a local folder
|
||||
*
|
||||
* @config
|
||||
* @var array Mapping of server configurations to configuration files necessary
|
||||
*/
|
||||
private static $server_configuration = array();
|
||||
|
||||
/**
|
||||
* Config compatible permissions configuration
|
||||
*
|
||||
@ -33,39 +39,101 @@ class AssetAdapter extends Local {
|
||||
|
||||
public function __construct($root = null, $writeFlags = LOCK_EX, $linkHandling = self::DISALLOW_LINKS) {
|
||||
// Get root path
|
||||
if (!$root) {
|
||||
// Empty root will set the path to assets
|
||||
$root = ASSETS_PATH;
|
||||
} elseif(strpos($root, './') === 0) {
|
||||
// Substitute leading ./ with BASE_PATH
|
||||
$root = BASE_PATH . substr($root, 1);
|
||||
} elseif(strpos($root, '../') === 0) {
|
||||
// Substitute leading ./ with parent of BASE_PATH, in case storage is outside of the webroot.
|
||||
$root = dirname(BASE_PATH) . substr($root, 2);
|
||||
}
|
||||
$root = $this->findRoot($root);
|
||||
|
||||
// Override permissions with config
|
||||
$permissions = \Config::inst()->get(get_class($this), 'file_permissions');
|
||||
|
||||
parent::__construct($root, $writeFlags, $linkHandling, $permissions);
|
||||
|
||||
// Configure server
|
||||
$this->configureServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide downloadable url
|
||||
* Determine the root folder absolute system path
|
||||
*
|
||||
* @param string $path
|
||||
* @return string|null
|
||||
* @param string $root
|
||||
* @return string
|
||||
*/
|
||||
public function getPublicUrl($path) {
|
||||
$rootPath = realpath(BASE_PATH);
|
||||
$filesPath = realpath($this->pathPrefix);
|
||||
|
||||
if(stripos($filesPath, $rootPath) === 0) {
|
||||
$dir = substr($filesPath, strlen($rootPath));
|
||||
return Controller::join_links(Director::baseURL(), $dir, $path);
|
||||
protected function findRoot($root) {
|
||||
// Empty root will set the path to assets
|
||||
if (!$root) {
|
||||
throw new \InvalidArgumentException("Missing argument for root path");
|
||||
}
|
||||
|
||||
// File outside of webroot can't be used
|
||||
return null;
|
||||
// Substitute leading ./ with BASE_PATH
|
||||
if(strpos($root, './') === 0) {
|
||||
return BASE_PATH . substr($root, 1);
|
||||
}
|
||||
|
||||
// Substitute leading ./ with parent of BASE_PATH, in case storage is outside of the webroot.
|
||||
if(strpos($root, '../') === 0) {
|
||||
return dirname(BASE_PATH) . substr($root, 2);
|
||||
}
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force flush and regeneration of server files
|
||||
*/
|
||||
public function flush() {
|
||||
$this->configureServer(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure server files for this store
|
||||
*
|
||||
* @param bool $forceOverwrite Force regeneration even if files already exist
|
||||
*/
|
||||
protected function configureServer($forceOverwrite = false) {
|
||||
// Get server type
|
||||
$type = isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : '*';
|
||||
list($type) = explode('/', strtolower($type));
|
||||
|
||||
// Determine configurations to write
|
||||
$rules = \Config::inst()->get(get_class($this), 'server_configuration', \Config::FIRST_SET);
|
||||
if(empty($rules[$type])) {
|
||||
return;
|
||||
}
|
||||
$configurations = $rules[$type];
|
||||
|
||||
// Apply each configuration
|
||||
$config = new \League\Flysystem\Config();
|
||||
$config->set('visibility', 'private');
|
||||
foreach($configurations as $file => $template) {
|
||||
if ($forceOverwrite || !$this->has($file)) {
|
||||
// Evaluate file
|
||||
$content = $this->renderTemplate($template);
|
||||
$success = $this->write($file, $content, $config);
|
||||
if(!$success) {
|
||||
throw new \Exception("Error writing server configuration file \"{$file}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render server configuration file from a template file
|
||||
*
|
||||
* @param string $template
|
||||
* @return \HTMLText Rendered results
|
||||
*/
|
||||
protected function renderTemplate($template) {
|
||||
// Build allowed extensions
|
||||
$allowedExtensions = new \ArrayList();
|
||||
foreach(\File::config()->allowed_extensions as $extension) {
|
||||
if($extension) {
|
||||
$allowedExtensions->push(new \ArrayData(array(
|
||||
'Extension' => preg_quote($extension)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
$viewer = new \SSViewer(array($template));
|
||||
return (string)$viewer->process(new \ArrayData(array(
|
||||
'AllowedExtensions' => $allowedExtensions
|
||||
)));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,13 +3,20 @@
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use Config;
|
||||
use Generator;
|
||||
use Injector;
|
||||
use Session;
|
||||
use Flushable;
|
||||
use InvalidArgumentException;
|
||||
use League\Flysystem\Directory;
|
||||
use League\Flysystem\Exception;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use League\Flysystem\Util;
|
||||
use SilverStripe\Filesystem\Storage\AssetNameGenerator;
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
use SilverStripe\Filesystem\Storage\AssetStoreRouter;
|
||||
use SS_HTTPResponse;
|
||||
|
||||
/**
|
||||
* Asset store based on flysystem Filesystem as a backend
|
||||
@ -17,29 +24,83 @@ use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
class FlysystemAssetStore implements AssetStore {
|
||||
class FlysystemAssetStore implements AssetStore, AssetStoreRouter, Flushable {
|
||||
|
||||
/**
|
||||
* Session key to use for user grants
|
||||
*/
|
||||
const GRANTS_SESSION = 'AssetStore_Grants';
|
||||
|
||||
/**
|
||||
* @var Filesystem
|
||||
*/
|
||||
private $filesystem = null;
|
||||
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 setFilesystem(Filesystem $filesystem) {
|
||||
$this->filesystem = $filesystem;
|
||||
public function setPublicFilesystem(Filesystem $filesystem) {
|
||||
if(!$filesystem->getAdapter() instanceof PublicAdapter) {
|
||||
throw new \InvalidArgumentException("Configured adapter must implement PublicAdapter");
|
||||
}
|
||||
$this->publicFilesystem = $filesystem;
|
||||
return $this;
|
||||
}
|
||||
|
||||
@ -48,26 +109,115 @@ class FlysystemAssetStore implements AssetStore {
|
||||
*
|
||||
* @return Filesystem
|
||||
*/
|
||||
public function getFilesystem() {
|
||||
return $this->filesystem;
|
||||
public function getPublicFilesystem() {
|
||||
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
|
||||
*/
|
||||
public function getProtectedFilesystem() {
|
||||
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->getFilesystem()->readStream($fileID);
|
||||
return $this
|
||||
->getFilesystemFor($fileID)
|
||||
->readStream($fileID);
|
||||
}
|
||||
|
||||
public function getAsString($filename, $hash, $variant = null) {
|
||||
$fileID = $this->getFileID($filename, $hash, $variant);
|
||||
return $this->getFilesystem()->read($fileID);
|
||||
return $this
|
||||
->getFilesystemFor($fileID)
|
||||
->read($fileID);
|
||||
}
|
||||
|
||||
public function getAsURL($filename, $hash, $variant = null) {
|
||||
public function getAsURL($filename, $hash, $variant = null, $grant = true) {
|
||||
if($grant) {
|
||||
$this->grant($filename, $hash);
|
||||
}
|
||||
$fileID = $this->getFileID($filename, $hash, $variant);
|
||||
return $this->getFilesystem()->getPublicUrl($fileID);
|
||||
|
||||
// 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, $conflictResolution = null) {
|
||||
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");
|
||||
@ -79,8 +229,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Callback for saving content
|
||||
$filesystem = $this->getFilesystem();
|
||||
$callback = function($fileID) use ($filesystem, $path) {
|
||||
$callback = function(Filesystem $filesystem, $fileID) use ($path) {
|
||||
// Read contents as string into flysystem
|
||||
$handle = fopen($path, 'r');
|
||||
if($handle === false) {
|
||||
@ -97,13 +246,12 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Submit to conflict check
|
||||
return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution);
|
||||
return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
|
||||
}
|
||||
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
// Callback for saving content
|
||||
$filesystem = $this->getFilesystem();
|
||||
$callback = function($fileID) use ($filesystem, $data) {
|
||||
$callback = function(Filesystem $filesystem, $fileID) use ($data) {
|
||||
return $filesystem->put($fileID, $data);
|
||||
};
|
||||
|
||||
@ -113,21 +261,20 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Submit to conflict check
|
||||
return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution);
|
||||
return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
|
||||
}
|
||||
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
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, $conflictResolution);
|
||||
$result = $this->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
||||
unlink($path);
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Callback for saving content
|
||||
$filesystem = $this->getFilesystem();
|
||||
$callback = function($fileID) use ($filesystem, $stream) {
|
||||
$callback = function(Filesystem $filesystem, $fileID) use ($stream) {
|
||||
return $filesystem->putStream($fileID, $stream);
|
||||
};
|
||||
|
||||
@ -137,7 +284,144 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Submit to conflict check
|
||||
return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution);
|
||||
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
|
||||
&& ! 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) {
|
||||
foreach($filesystem->listContents(dirname($fileID)) 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]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,21 +442,22 @@ class FlysystemAssetStore implements AssetStore {
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
stream_copy_to_stream($stream, $buffer);
|
||||
if (! fclose($buffer)) {
|
||||
throw new Exception("Could not write stream to temporary file");
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
@ -195,14 +480,16 @@ class FlysystemAssetStore implements AssetStore {
|
||||
* @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
|
||||
* @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, $conflictResolution = null) {
|
||||
protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = array()) {
|
||||
// Set default conflict resolution
|
||||
if(!$conflictResolution) {
|
||||
if(empty($config['conflict'])) {
|
||||
$conflictResolution = $this->getDefaultConflictResolution($variant);
|
||||
} else {
|
||||
$conflictResolution = $config['conflict'];
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
@ -223,8 +510,22 @@ class FlysystemAssetStore implements AssetStore {
|
||||
// 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($resolvedID);
|
||||
$result = $callback($filesystem, $resolvedID);
|
||||
if(!$result) {
|
||||
throw new Exception("Could not save {$filename}");
|
||||
}
|
||||
@ -233,10 +534,10 @@ class FlysystemAssetStore implements AssetStore {
|
||||
$filename = $this->getOriginalFilename($resolvedID);
|
||||
|
||||
} elseif(empty($variant)) {
|
||||
// If defering to the existing file, return the sha of the existing file,
|
||||
// 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
|
||||
->getFilesystem()
|
||||
->getFilesystemFor($fileID)
|
||||
->readStream($fileID);
|
||||
$hash = $this->getStreamSHA1($stream);
|
||||
}
|
||||
@ -277,17 +578,26 @@ class FlysystemAssetStore implements AssetStore {
|
||||
|
||||
public function getMetadata($filename, $hash, $variant = null) {
|
||||
$fileID = $this->getFileID($filename, $hash, $variant);
|
||||
return $this->getFilesystem()->getMetadata($fileID);
|
||||
$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);
|
||||
return $this->getFilesystem()->getMimetype($fileID);
|
||||
$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);
|
||||
return $this->getFilesystem()->has($fileID);
|
||||
$filesystem = $this->getFilesystemFor($fileID);
|
||||
return !empty($filesystem);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -305,7 +615,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Otherwise, check if this exists
|
||||
$exists = $this->getFilesystem()->has($fileID);
|
||||
$exists = $this->getFilesystemFor($fileID);
|
||||
if(!$exists) {
|
||||
return $fileID;
|
||||
}
|
||||
@ -313,15 +623,14 @@ class FlysystemAssetStore implements AssetStore {
|
||||
// Flysystem defaults to use_existing
|
||||
switch($conflictResolution) {
|
||||
// Throw tantrum
|
||||
case AssetStore::CONFLICT_EXCEPTION: {
|
||||
case static::CONFLICT_EXCEPTION: {
|
||||
throw new \InvalidArgumentException("File already exists at path {$fileID}");
|
||||
}
|
||||
|
||||
// Rename
|
||||
case AssetStore::CONFLICT_RENAME: {
|
||||
case static::CONFLICT_RENAME: {
|
||||
foreach($this->fileGeneratorFor($fileID) as $candidate) {
|
||||
// @todo better infinite loop breaking
|
||||
if(!$this->getFilesystem()->has($candidate)) {
|
||||
if(!$this->getFilesystemFor($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@ -330,7 +639,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Use existing file
|
||||
case AssetStore::CONFLICT_USE_EXISTING:
|
||||
case static::CONFLICT_USE_EXISTING:
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
@ -340,7 +649,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
/**
|
||||
* Get an asset renamer for the given filename.
|
||||
*
|
||||
* @param string $fileID Adaptor specific identifier for this file/version
|
||||
* @param string $fileID Adapter specific identifier for this file/version
|
||||
* @return AssetNameGenerator
|
||||
*/
|
||||
protected function fileGeneratorFor($fileID){
|
||||
@ -361,33 +670,42 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a FileID, map this back to the original filename, trimming variant
|
||||
* Given a FileID, map this back to the original filename, trimming variant and hash
|
||||
*
|
||||
* @param string $fileID Adaptor specific identifier for this file/version
|
||||
* @param string $variant Out parameter for any found variant
|
||||
* @return string
|
||||
* @param string $fileID Adapter specific identifier for this file/version
|
||||
* @return string Filename for this file, omitting hash and variant
|
||||
*/
|
||||
protected function getOriginalFilename($fileID, &$variant = '') {
|
||||
protected function getOriginalFilename($fileID) {
|
||||
// Remove variant
|
||||
$original = $fileID;
|
||||
$variant = '';
|
||||
if(preg_match('/^(?<before>((?<!__).)+)__(?<variant>[^\\.]+)(?<after>.*)$/', $fileID, $matches)) {
|
||||
$original = $matches['before'].$matches['after'];
|
||||
$variant = $matches['variant'];
|
||||
}
|
||||
$originalID = $this->removeVariant($fileID);
|
||||
|
||||
// Remove hash (unless using legacy filenames, without hash)
|
||||
if($this->useLegacyFilenames()) {
|
||||
return $original;
|
||||
return $originalID;
|
||||
} else {
|
||||
return preg_replace(
|
||||
'/(?<hash>[a-zA-Z0-9]{10}\\/)(?<name>[^\\/]+)$/',
|
||||
'$2',
|
||||
$original
|
||||
$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
|
||||
*
|
||||
@ -396,7 +714,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
* @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
|
||||
* @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
|
||||
@ -411,7 +729,7 @@ class FlysystemAssetStore implements AssetStore {
|
||||
}
|
||||
|
||||
// Unless in legacy mode, inject hash just prior to the filename
|
||||
if(Config::inst()->get(__CLASS__, 'legacy_filenames')) {
|
||||
if($this->useLegacyFilenames()) {
|
||||
$fileID = $name;
|
||||
} else {
|
||||
$fileID = substr($hash, 0, 10) . '/' . $name;
|
||||
@ -436,4 +754,102 @@ class FlysystemAssetStore implements AssetStore {
|
||||
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 SS_HTTPResponse} for the given file from the source filesystem
|
||||
* @param FilesystemInterface $flysystem
|
||||
* @param string $fileID
|
||||
* @return SS_HTTPResponse
|
||||
*/
|
||||
protected function createResponseFor(FilesystemInterface $flysystem, $fileID) {
|
||||
// Build response body
|
||||
// @todo: gzip / buffer response?
|
||||
$body = $flysystem->read($fileID);
|
||||
$mime = $flysystem->getMimetype($fileID);
|
||||
$response = new SS_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 SS_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 SS_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 SS_HTTPResponse
|
||||
*/
|
||||
protected function createErrorResponse($code) {
|
||||
$response = new SS_HTTPResponse('', $code);
|
||||
|
||||
// Show message in dev
|
||||
if(!\Director::isLive()) {
|
||||
$response->setBody($response->getStatusDescription());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ class FlysystemGeneratedAssetHandler implements GeneratedAssetHandler {
|
||||
if($result) {
|
||||
return $this
|
||||
->getFilesystem()
|
||||
->getAdapter()
|
||||
->getPublicUrl($filename);
|
||||
}
|
||||
}
|
||||
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use League\Flysystem\AwsS3v2\AwsS3Adapter;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\FilesystemInterface;
|
||||
use League\Flysystem\PluginInterface;
|
||||
use Oneup\FlysystemBundle\Adapter\LocalWithHost;
|
||||
|
||||
|
||||
/**
|
||||
* Allows urls for files to be exposed
|
||||
*
|
||||
* Credit to https://github.com/SmartestEdu/FlysystemPublicUrlPlugin
|
||||
*
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
class FlysystemUrlPlugin implements PluginInterface {
|
||||
|
||||
/**
|
||||
* @var Filesystem adapter
|
||||
*/
|
||||
protected $adapter;
|
||||
|
||||
public function setFilesystem(FilesystemInterface $filesystem) {
|
||||
$this->adapter = $filesystem->getAdapter();
|
||||
}
|
||||
|
||||
public function getMethod() {
|
||||
return 'getPublicUrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate public url
|
||||
*
|
||||
* @param string $path
|
||||
* @return string The full url to the file
|
||||
*/
|
||||
public function handle($path) {
|
||||
// Default adaptor
|
||||
if($this->adapter instanceof AssetAdapter) {
|
||||
return $this->adapter->getPublicUrl($path);
|
||||
}
|
||||
|
||||
// Check S3 adaptor
|
||||
if (class_exists('League\Flysystem\AwsS3v2\AwsS3Adapter')
|
||||
&& $this->adapter instanceof AwsS3Adapter
|
||||
) {
|
||||
return sprintf(
|
||||
'https://s3.amazonaws.com/%s/%s',
|
||||
$this->adapter->getBucket(),
|
||||
$path
|
||||
);
|
||||
}
|
||||
|
||||
// Local with host
|
||||
if (class_exists('Oneup\FlysystemBundle\Adapter\LocalWithHost')
|
||||
&& $this->adapter instanceof LocalWithHost
|
||||
) {
|
||||
return sprintf(
|
||||
'%s/%s/%s',
|
||||
$this->adapter->getBasePath(),
|
||||
$this->adapter->getWebpath(),
|
||||
$path
|
||||
);
|
||||
}
|
||||
|
||||
// no url available
|
||||
return null;
|
||||
}
|
||||
}
|
19
filesystem/flysystem/ProtectedAdapter.php
Normal file
19
filesystem/flysystem/ProtectedAdapter.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use League\Flysystem\AdapterInterface;
|
||||
|
||||
/**
|
||||
* An adapter which does not publicly expose protected files
|
||||
*/
|
||||
interface ProtectedAdapter extends AdapterInterface {
|
||||
|
||||
/**
|
||||
* Provide downloadable url that is restricted to granted users
|
||||
*
|
||||
* @param string $path
|
||||
* @return string|null
|
||||
*/
|
||||
public function getProtectedUrl($path);
|
||||
}
|
52
filesystem/flysystem/ProtectedAssetAdapter.php
Normal file
52
filesystem/flysystem/ProtectedAssetAdapter.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
class ProtectedAssetAdapter extends AssetAdapter implements ProtectedAdapter {
|
||||
|
||||
/**
|
||||
* Name of default folder to save secure assets in under ASSETS_PATH.
|
||||
* This can be bypassed by specifying an absolute filesystem path via
|
||||
* the SS_PROTECTED_ASSETS_PATH environment definition.
|
||||
*
|
||||
* @config
|
||||
* @var string
|
||||
*/
|
||||
private static $secure_folder = '.protected';
|
||||
|
||||
private static $server_configuration = array(
|
||||
'apache' => array(
|
||||
'.htaccess' => "Protected_HTAccess"
|
||||
),
|
||||
'microsoft-iis' => array(
|
||||
'web.config' => "Protected_WebConfig"
|
||||
)
|
||||
);
|
||||
|
||||
protected function findRoot($root) {
|
||||
// Use explicitly defined path
|
||||
if($root) {
|
||||
return parent::findRoot($root);
|
||||
}
|
||||
|
||||
// Use environment defined path
|
||||
if(defined('SS_PROTECTED_ASSETS_PATH')) {
|
||||
return SS_PROTECTED_ASSETS_PATH;
|
||||
}
|
||||
|
||||
// Default location is under assets
|
||||
return ASSETS_PATH . '/' . \Config::inst()->get(static::class, 'secure_folder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide secure downloadable
|
||||
*
|
||||
* @param string $path
|
||||
* @return string|null
|
||||
*/
|
||||
public function getProtectedUrl($path) {
|
||||
// Public URLs are handled via a request handler within /assets.
|
||||
// If assets are stored locally, then asset paths of protected files should be equivalent.
|
||||
return \Controller::join_links(\Director::baseURL(), ASSETS_DIR, $path);
|
||||
}
|
||||
}
|
19
filesystem/flysystem/PublicAdapter.php
Normal file
19
filesystem/flysystem/PublicAdapter.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use League\Flysystem\AdapterInterface;
|
||||
|
||||
/**
|
||||
* Represents an AbstractAdapter which exposes its assets via public urls
|
||||
*/
|
||||
interface PublicAdapter extends AdapterInterface {
|
||||
|
||||
/**
|
||||
* Provide downloadable url that is open to the public
|
||||
*
|
||||
* @param string $path
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPublicUrl($path);
|
||||
}
|
58
filesystem/flysystem/PublicAssetAdapter.php
Normal file
58
filesystem/flysystem/PublicAssetAdapter.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
* Created by PhpStorm.
|
||||
* User: dmooyman
|
||||
* Date: 16/12/15
|
||||
* Time: 1:54 PM
|
||||
*/
|
||||
|
||||
namespace SilverStripe\Filesystem\Flysystem;
|
||||
|
||||
use Controller;
|
||||
use Director;
|
||||
|
||||
class PublicAssetAdapter extends AssetAdapter implements PublicAdapter {
|
||||
|
||||
/**
|
||||
* Server specific configuration necessary to block http traffic to a local folder
|
||||
*
|
||||
* @config
|
||||
* @var array Mapping of server configurations to configuration files necessary
|
||||
*/
|
||||
private static $server_configuration = array(
|
||||
'apache' => array(
|
||||
'.htaccess' => "Assets_HTAccess"
|
||||
),
|
||||
'microsoft-iis' => array(
|
||||
'web.config' => "Assets_WebConfig"
|
||||
)
|
||||
);
|
||||
|
||||
protected function findRoot($root) {
|
||||
if ($root) {
|
||||
return parent::findRoot($root);
|
||||
}
|
||||
|
||||
// Empty root will set the path to assets
|
||||
return ASSETS_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide downloadable url
|
||||
*
|
||||
* @param string $path
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPublicUrl($path) {
|
||||
$rootPath = realpath(BASE_PATH);
|
||||
$filesPath = realpath($this->pathPrefix);
|
||||
|
||||
if(stripos($filesPath, $rootPath) === 0) {
|
||||
$dir = substr($filesPath, strlen($rootPath));
|
||||
return Controller::join_links(Director::baseURL(), $dir, $path);
|
||||
}
|
||||
|
||||
// File outside of webroot can't be used
|
||||
return null;
|
||||
}
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
|
||||
namespace SilverStripe\Filesystem\Storage;
|
||||
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
|
||||
/**
|
||||
* Represents a container for a specific asset.
|
||||
*
|
||||
@ -13,7 +15,8 @@ namespace SilverStripe\Filesystem\Storage;
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
interface AssetContainer {
|
||||
interface AssetContainer
|
||||
{
|
||||
|
||||
/**
|
||||
* Assign a set of data to the backend
|
||||
@ -22,53 +25,58 @@ interface AssetContainer {
|
||||
* @param string $filename Name for the resulting file
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}. Will default to one chosen by the backend
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the given data.
|
||||
*/
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
/**
|
||||
* Assign a local file to the backend.
|
||||
*
|
||||
* @param string $path Absolute filesystem path to file
|
||||
* @param type $filename Optional path to ask the backend to name as.
|
||||
* @param string $filename Optional path to ask the backend to name as.
|
||||
* Will default to the filename of the $path, excluding directories.
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the local file content.
|
||||
*/
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
/**
|
||||
* Assign a stream to the backend
|
||||
*
|
||||
* @param resource $stream Streamable resource
|
||||
* @param string $filename Name for the resulting file
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the raw stream.
|
||||
*/
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
* @return string Data from the file in this container
|
||||
*/
|
||||
public function getString();
|
||||
/**
|
||||
* @return string Data from the file in this container
|
||||
*/
|
||||
public function getString();
|
||||
|
||||
/**
|
||||
/**
|
||||
* @return resource Data stream to the asset in this container
|
||||
*/
|
||||
public function getStream();
|
||||
public function getStream();
|
||||
|
||||
/**
|
||||
* @return string public url to the asset in this container
|
||||
*/
|
||||
public function getURL();
|
||||
/**
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* If set to true, and the file is currently in protected mode, the asset store will ensure the
|
||||
* returned URL is accessible for the duration of the current session / user.
|
||||
* This will have no effect if the file is in published mode.
|
||||
* This will not grant access to users other than the owner of the current session.
|
||||
* @return string public url to the asset in this container
|
||||
*/
|
||||
public function getURL($grant = true);
|
||||
|
||||
/**
|
||||
* @return string The absolute URL to the asset in this container
|
||||
@ -103,6 +111,14 @@ interface AssetContainer {
|
||||
*/
|
||||
public function getIsImage();
|
||||
|
||||
/**
|
||||
* Determine visibility of the given file
|
||||
*
|
||||
* @return string one of values defined by the constants VISIBILITY_PROTECTED or VISIBILITY_PUBLIC, or
|
||||
* null if the file does not exist
|
||||
*/
|
||||
public function getVisibility();
|
||||
|
||||
/**
|
||||
* Determine if this container has a valid value
|
||||
*
|
||||
@ -130,4 +146,47 @@ interface AssetContainer {
|
||||
* @return string
|
||||
*/
|
||||
public function getVariant();
|
||||
|
||||
/**
|
||||
* Delete a file (and all variants).
|
||||
* {@see AssetStore::delete()}
|
||||
*
|
||||
* @return bool Flag if a file was deleted
|
||||
*/
|
||||
public function deleteFile();
|
||||
|
||||
/**
|
||||
* Publicly expose the file (and all variants) identified by the given filename and hash
|
||||
* {@see AssetStore::publish}
|
||||
*/
|
||||
public function publishFile();
|
||||
|
||||
/**
|
||||
* Protect a file (and all variants) from public access, identified by the given filename and hash.
|
||||
* {@see AssetStore::protect()}
|
||||
*/
|
||||
public function protectFile();
|
||||
|
||||
/**
|
||||
* Ensures that access to the specified protected file is granted for the current user.
|
||||
* If this file is currently in protected mode, the asset store will ensure the
|
||||
* returned asset for the duration of the current session / user.
|
||||
* This will have no effect if the file is in published mode.
|
||||
* This will not grant access to users other than the owner of the current session.
|
||||
* Does not require a member to be logged in.
|
||||
*/
|
||||
public function grantFile();
|
||||
|
||||
/**
|
||||
* Revoke access to the given file for the current user.
|
||||
* Note: This will have no effect if the given file is public
|
||||
*/
|
||||
public function revokeFile();
|
||||
|
||||
/**
|
||||
* Check if the current user can view the given file.
|
||||
*
|
||||
* @return bool True if the file is verified and grants access to the current session / user.
|
||||
*/
|
||||
public function canViewFile();
|
||||
}
|
||||
|
@ -18,6 +18,10 @@ namespace SilverStripe\Filesystem\Storage;
|
||||
* of the mechanism used to generate this file, and is up to user code to perform the actual
|
||||
* generation. An empty variant identifies this file as the original file.
|
||||
*
|
||||
* Write options have an additional $config parameter to provide additional options to the backend.
|
||||
* This is an associative array. Standard array options include 'visibility' and 'conflict'.
|
||||
*
|
||||
* 'conflict' config option determines the conflict resolution mechanism.
|
||||
* When assets are stored in the backend, user code may request one of the following conflict resolution
|
||||
* mechanisms:
|
||||
*
|
||||
@ -29,6 +33,12 @@ namespace SilverStripe\Filesystem\Storage;
|
||||
* existing file instead.
|
||||
* - CONFLICT_EXCEPTION - If there is an existing file with this tuple, throw an exception.
|
||||
*
|
||||
* 'visibility' config option determines whether the file should be marked as publicly visible.
|
||||
* This may be assigned to one of the below values:
|
||||
*
|
||||
* - VISIBILITY_PUBLIC: This file may be accessed by any public user.
|
||||
* - VISIBILITY_PROTECTED: This file must be whitelisted for individual users before being made available to that user.
|
||||
*
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
@ -57,6 +67,26 @@ interface AssetStore {
|
||||
*/
|
||||
const CONFLICT_USE_EXISTING = 'existing';
|
||||
|
||||
/**
|
||||
* Protect this file
|
||||
*/
|
||||
const VISIBILITY_PROTECTED = 'protected';
|
||||
|
||||
/**
|
||||
* Make this file public
|
||||
*/
|
||||
const VISIBILITY_PUBLIC = 'public';
|
||||
|
||||
/**
|
||||
* Return list of feature capabilities of this backend as an array.
|
||||
* Array keys will be the options supported by $config, and the
|
||||
* values will be the list of accepted values for each option (or
|
||||
* true if any value is allowed).
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCapabilities();
|
||||
|
||||
/**
|
||||
* Assign a set of data to the backend
|
||||
*
|
||||
@ -64,38 +94,38 @@ interface AssetStore {
|
||||
* @param string $filename Name for the resulting file
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}. Will default to one chosen by the backend
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the given data.
|
||||
*/
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
/**
|
||||
* Assign a local file to the backend.
|
||||
*
|
||||
* @param string $path Absolute filesystem path to file
|
||||
* @param type $filename Optional path to ask the backend to name as.
|
||||
* @param string $filename Optional path to ask the backend to name as.
|
||||
* Will default to the filename of the $path, excluding directories.
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the local file content.
|
||||
*/
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
/**
|
||||
* Assign a stream to the backend
|
||||
*
|
||||
* @param resource $stream Streamable resource
|
||||
* @param string $filename Name for the resulting file
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the raw stream.
|
||||
*/
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
* Get contents of a given file
|
||||
@ -126,9 +156,14 @@ interface AssetStore {
|
||||
* @param string $hash sha1 hash of the file content.
|
||||
* If a variant is requested, this is the hash of the file before it was modified.
|
||||
* @param string|null $variant Optional variant string for this file
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* If set to true, and the file is currently in protected mode, the asset store will ensure the
|
||||
* returned URL is accessible for the duration of the current session / user.
|
||||
* This will have no effect if the file is in published mode.
|
||||
* This will not grant access to users other than the owner of the current session.
|
||||
* @return string public url to this resource
|
||||
*/
|
||||
public function getAsURL($filename, $hash, $variant = null);
|
||||
public function getAsURL($filename, $hash, $variant = null, $grant = true);
|
||||
|
||||
/**
|
||||
* Get metadata for this file, if available
|
||||
@ -152,6 +187,16 @@ interface AssetStore {
|
||||
*/
|
||||
public function getMimeType($filename, $hash, $variant = null);
|
||||
|
||||
/**
|
||||
* Determine visibility of the given file
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $hash
|
||||
* @return string one of values defined by the constants VISIBILITY_PROTECTED or VISIBILITY_PUBLIC, or
|
||||
* null if the file does not exist
|
||||
*/
|
||||
public function getVisibility($filename, $hash);
|
||||
|
||||
/**
|
||||
* Determine if a file exists with the given tuple
|
||||
*
|
||||
@ -162,4 +207,63 @@ interface AssetStore {
|
||||
* @return bool Flag as to whether the file exists
|
||||
*/
|
||||
public function exists($filename, $hash, $variant = null);
|
||||
|
||||
/**
|
||||
* Delete a file (and all variants) identified by the given filename and hash
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $hash
|
||||
* @return bool Flag if a file was deleted
|
||||
*/
|
||||
public function delete($filename, $hash);
|
||||
|
||||
/**
|
||||
* Publicly expose the file (and all variants) identified by the given filename and hash
|
||||
*
|
||||
* @param string $filename Filename (not including assets)
|
||||
* @param string $hash sha1 hash of the file content.
|
||||
*/
|
||||
public function publish($filename, $hash);
|
||||
|
||||
/**
|
||||
* Protect a file (and all variants) from public access, identified by the given filename and hash.
|
||||
*
|
||||
* A protected file can be granted access to users on a per-session or per-user basis as response
|
||||
* to any future invocations of {@see grant()} or {@see getAsURL()} with $grant = true
|
||||
*
|
||||
* @param string $filename Filename (not including assets)
|
||||
* @param string $hash sha1 hash of the file content.
|
||||
*/
|
||||
public function protect($filename, $hash);
|
||||
|
||||
/**
|
||||
* Ensures that access to the specified protected file is granted for the current user.
|
||||
* If this file is currently in protected mode, the asset store will ensure the
|
||||
* returned asset for the duration of the current session / user.
|
||||
* This will have no effect if the file is in published mode.
|
||||
* This will not grant access to users other than the owner of the current session.
|
||||
* Does not require a member to be logged in.
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $hash
|
||||
*/
|
||||
public function grant($filename, $hash);
|
||||
|
||||
/**
|
||||
* Revoke access to the given file for the current user.
|
||||
* Note: This will have no effect if the given file is public
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $hash
|
||||
*/
|
||||
public function revoke($filename, $hash);
|
||||
|
||||
/**
|
||||
* Check if the current user can view the given file.
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $hash
|
||||
* @return bool True if the file is verified and grants access to the current session / user.
|
||||
*/
|
||||
public function canView($filename, $hash);
|
||||
}
|
||||
|
22
filesystem/storage/AssetStoreRouter.php
Normal file
22
filesystem/storage/AssetStoreRouter.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Storage;
|
||||
|
||||
use SS_HTTPResponse;
|
||||
use SS_HTTPResponse_Exception;
|
||||
|
||||
/**
|
||||
* Represents a store usable with ProtectedFileController to serve up non-direct file requests
|
||||
*/
|
||||
interface AssetStoreRouter {
|
||||
|
||||
/**
|
||||
* Generate a custom HTTP response for a request to a given asset, identified by a path.
|
||||
*
|
||||
*
|
||||
* @param string $asset Asset path name, omitting any leading 'assets'
|
||||
* @return SS_HTTPResponse
|
||||
* @throws SS_HTTPResponse_Exception
|
||||
*/
|
||||
public function getResponseFor($asset);
|
||||
}
|
@ -144,9 +144,10 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
* @return string
|
||||
*/
|
||||
public function getBasename() {
|
||||
if($this->exists()) {
|
||||
return basename($this->getSourceURL());
|
||||
if(!$this->exists()) {
|
||||
return null;
|
||||
}
|
||||
return basename($this->getSourceURL());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -155,9 +156,10 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
* @return string
|
||||
*/
|
||||
public function getExtension() {
|
||||
if($this->exists()) {
|
||||
return pathinfo($this->Filename, PATHINFO_EXTENSION);
|
||||
if(!$this->exists()) {
|
||||
return null;
|
||||
}
|
||||
return pathinfo($this->Filename, PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -174,11 +176,11 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
return $this->getBasename();
|
||||
}
|
||||
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) {
|
||||
$this->assertFilenameValid($filename ?: $path);
|
||||
$result = $this
|
||||
->getStore()
|
||||
->setFromLocalFile($path, $filename, $hash, $variant, $conflictResolution);
|
||||
->setFromLocalFile($path, $filename, $hash, $variant, $config);
|
||||
// Update from result
|
||||
if($result) {
|
||||
$this->setValue($result);
|
||||
@ -186,11 +188,11 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
$this->assertFilenameValid($filename);
|
||||
$result = $this
|
||||
->getStore()
|
||||
->setFromStream($stream, $filename, $hash, $variant, $conflictResolution);
|
||||
->setFromStream($stream, $filename, $hash, $variant, $config);
|
||||
// Update from result
|
||||
if($result) {
|
||||
$this->setValue($result);
|
||||
@ -198,11 +200,11 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null) {
|
||||
public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) {
|
||||
$this->assertFilenameValid($filename);
|
||||
$result = $this
|
||||
->getStore()
|
||||
->setFromString($data, $filename, $hash, $variant, $conflictResolution);
|
||||
->setFromString($data, $filename, $hash, $variant, $config);
|
||||
// Update from result
|
||||
if($result) {
|
||||
$this->setValue($result);
|
||||
@ -228,11 +230,11 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
->getAsString($this->Filename, $this->Hash, $this->Variant);
|
||||
}
|
||||
|
||||
public function getURL() {
|
||||
public function getURL($grant = true) {
|
||||
if(!$this->exists()) {
|
||||
return null;
|
||||
}
|
||||
$url = $this->getSourceURL();
|
||||
$url = $this->getSourceURL($grant);
|
||||
$this->updateURL($url);
|
||||
$this->extend('updateURL', $url);
|
||||
return $url;
|
||||
@ -242,18 +244,19 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
* Get URL, but without resampling.
|
||||
* Note that this will return the url even if the file does not exist.
|
||||
*
|
||||
* @param bool $grant Ensures that the url for any protected assets is granted for the current user.
|
||||
* @return string
|
||||
*/
|
||||
public function getSourceURL() {
|
||||
public function getSourceURL($grant = true) {
|
||||
return $this
|
||||
->getStore()
|
||||
->getAsURL($this->Filename, $this->Hash, $this->Variant);
|
||||
->getAsURL($this->Filename, $this->Hash, $this->Variant, $grant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute URL to this resource
|
||||
*
|
||||
* @return type
|
||||
* @return string
|
||||
*/
|
||||
public function getAbsoluteURL() {
|
||||
if(!$this->exists()) {
|
||||
@ -281,13 +284,23 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
}
|
||||
|
||||
public function getValue() {
|
||||
if($this->exists()) {
|
||||
return array(
|
||||
'Filename' => $this->Filename,
|
||||
'Hash' => $this->Hash,
|
||||
'Variant' => $this->Variant
|
||||
);
|
||||
if(!$this->exists()) {
|
||||
return null;
|
||||
}
|
||||
return array(
|
||||
'Filename' => $this->Filename,
|
||||
'Hash' => $this->Hash,
|
||||
'Variant' => $this->Variant
|
||||
);
|
||||
}
|
||||
|
||||
public function getVisibility() {
|
||||
if(empty($this->Filename)) {
|
||||
return null;
|
||||
}
|
||||
return $this
|
||||
->getStore()
|
||||
->getVisibility($this->Filename, $this->Hash);
|
||||
}
|
||||
|
||||
public function exists() {
|
||||
@ -329,6 +342,7 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
if(isset($metadata['size'])) {
|
||||
return $metadata['size'];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -470,4 +484,53 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function deleteFile() {
|
||||
if(!$this->Filename) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this
|
||||
->getStore()
|
||||
->delete($this->Filename, $this->Hash);
|
||||
}
|
||||
|
||||
public function publishFile() {
|
||||
if($this->Filename) {
|
||||
$this
|
||||
->getStore()
|
||||
->publish($this->Filename, $this->Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public function protectFile() {
|
||||
if($this->Filename) {
|
||||
$this
|
||||
->getStore()
|
||||
->protect($this->Filename, $this->Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public function grantFile() {
|
||||
if($this->Filename) {
|
||||
$this
|
||||
->getStore()
|
||||
->grant($this->Filename, $this->Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeFile() {
|
||||
if($this->Filename) {
|
||||
$this
|
||||
->getStore()
|
||||
->revoke($this->Filename, $this->Hash);
|
||||
}
|
||||
}
|
||||
|
||||
public function canViewFile() {
|
||||
return $this->Filename
|
||||
&& $this
|
||||
->getStore()
|
||||
->canView($this->Filename, $this->Hash);
|
||||
}
|
||||
}
|
||||
|
92
filesystem/storage/ProtectedFileController.php
Normal file
92
filesystem/storage/ProtectedFileController.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem\Storage;
|
||||
|
||||
/**
|
||||
* Provides routing for session-whitelisted protected files
|
||||
*
|
||||
* Class ProtectedFileController
|
||||
* @package SilverStripe\Filesystem\Storage
|
||||
*/
|
||||
class ProtectedFileController extends \Controller {
|
||||
|
||||
/**
|
||||
* Designated router
|
||||
*
|
||||
* @var AssetStoreRouter
|
||||
*/
|
||||
protected $handler = null;
|
||||
|
||||
/**
|
||||
* @return AssetStoreRouter
|
||||
*/
|
||||
public function getRouteHandler() {
|
||||
return $this->handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AssetStoreRouter $handler
|
||||
* @return $this
|
||||
*/
|
||||
public function setRouteHandler(AssetStoreRouter $handler) {
|
||||
$this->handler = $handler;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private static $url_handlers = array(
|
||||
'$Filename' => "handleFile"
|
||||
);
|
||||
|
||||
private static $allowed_actions = array(
|
||||
'handleFile'
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide a response for the given file request
|
||||
*
|
||||
* @param \SS_HTTPRequest $request
|
||||
* @return \SS_HTTPResponse
|
||||
*/
|
||||
public function handleFile(\SS_HTTPRequest $request) {
|
||||
$filename = $this->parseFilename($request);
|
||||
|
||||
// Deny requests to private file
|
||||
if(!$this->isValidFilename($filename)) {
|
||||
return $this->httpError(400, "Invalid request");
|
||||
}
|
||||
|
||||
// Pass through to backend
|
||||
return $this->getRouteHandler()->getResponseFor($filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given filename is safe to pass to the route handler.
|
||||
* This should block direct requests to assets/.protected/ paths
|
||||
*
|
||||
* @param $filename
|
||||
* @return bool True if the filename is allowed
|
||||
*/
|
||||
public function isValidFilename($filename) {
|
||||
// Block hidden files
|
||||
return !preg_match('#(^|[\\\\/])\\..*#', $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file component from the request
|
||||
*
|
||||
* @param \SS_HTTPRequest $request
|
||||
* @return string
|
||||
*/
|
||||
protected function parseFilename(\SS_HTTPRequest $request) {
|
||||
$filename = '';
|
||||
$next = $request->param('Filename');
|
||||
while($next) {
|
||||
$filename = $filename ? \File::join_paths($filename, $next) : $next;
|
||||
$next = $request->shift();
|
||||
}
|
||||
if($extension = $request->getExtension()) {
|
||||
$filename = $filename . "." . $extension;
|
||||
}
|
||||
return $filename;
|
||||
}
|
||||
}
|
@ -195,6 +195,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
'Created' => 'SS_Datetime',
|
||||
);
|
||||
|
||||
/**
|
||||
* Core dataobject extensions
|
||||
*
|
||||
* @config
|
||||
* @var array
|
||||
*/
|
||||
private static $extensions = array(
|
||||
'AssetControl' => '\\SilverStripe\\Filesystem\\AssetControlExtension'
|
||||
);
|
||||
|
||||
/**
|
||||
* Non-static relationship cache, indexed by component name.
|
||||
*/
|
||||
|
@ -56,11 +56,11 @@ interface Image_Backend {
|
||||
* @param string $filename Name for the resulting file
|
||||
* @param string $hash Hash of original file, if storing a variant.
|
||||
* @param string $variant Name of variant, if storing a variant.
|
||||
* @param string $conflictResolution {@see AssetStore}. Will default to one chosen by the backend
|
||||
* @param array $config Write options. {@see AssetStore}
|
||||
* @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash
|
||||
* will be calculated from the given data.
|
||||
*/
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $conflictResolution = null);
|
||||
public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $config = array());
|
||||
|
||||
/**
|
||||
* Write the backend to a local path
|
||||
|
@ -30,9 +30,9 @@ class ValidationResult extends Object {
|
||||
|
||||
/**
|
||||
* Record an error against this validation result,
|
||||
* @param $message The validation error message
|
||||
* @param $code An optional error code string, that can be accessed with {@link $this->codeList()}.
|
||||
* @return ValidationResult this
|
||||
* @param string $message The validation error message
|
||||
* @param int $code An optional error code string, that can be accessed with {@link $this->codeList()}.
|
||||
* @return $this
|
||||
*/
|
||||
public function error($message, $code = null) {
|
||||
$this->isValid = false;
|
||||
|
@ -805,10 +805,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider {
|
||||
/**
|
||||
* Move a database record from one stage to the other.
|
||||
*
|
||||
* @param fromStage Place to copy from. Can be either a stage name or a version number.
|
||||
* @param toStage Place to copy to. Must be a stage name.
|
||||
* @param createNewVersion Set this to true to create a new version number. By default, the existing version
|
||||
* number will be copied over.
|
||||
* @param string $fromStage Place to copy from. Can be either a stage name or a version number.
|
||||
* @param string $toStage Place to copy to. Must be a stage name.
|
||||
* @param bool $createNewVersion Set this to true to create a new version number.
|
||||
* By default, the existing version number will be copied over.
|
||||
*/
|
||||
public function publish($fromStage, $toStage, $createNewVersion = false) {
|
||||
$this->owner->extend('onBeforeVersionedPublish', $fromStage, $toStage, $createNewVersion);
|
||||
|
27
templates/filesystem/Assets_HTAccess.ss
Normal file
27
templates/filesystem/Assets_HTAccess.ss
Normal file
@ -0,0 +1,27 @@
|
||||
#
|
||||
# Whitelist appropriate assets files.
|
||||
# This file is automatically generated via File.allowed_extensions configuration
|
||||
# See AssetAdapter::renderTemplate() for reference.
|
||||
#
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
SetEnv HTTP_MOD_REWRITE On
|
||||
RewriteEngine On
|
||||
|
||||
# Disable PHP handler
|
||||
RewriteCond %{REQUEST_URI} .(?i:php|phtml|php3|php4|php5|inc)$
|
||||
RewriteRule .* - [F]
|
||||
|
||||
# Allow error pages
|
||||
RewriteCond %{REQUEST_FILENAME} -f
|
||||
RewriteRule error[^\\/]*\.html$ - [L]
|
||||
|
||||
# Block invalid file extensions
|
||||
RewriteCond %{REQUEST_URI} !\.(?i:<% loop $AllowedExtensions %>$Extension<% if not $Last %>|<% end_if %><% end_loop %>)$
|
||||
RewriteRule .* - [F]
|
||||
|
||||
# Non existant files passed to requesthandler
|
||||
RewriteCond %{REQUEST_URI} ^(.*)$
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule .* ../framework/main.php?url=%1 [QSA]
|
||||
</IfModule>
|
29
templates/filesystem/Assets_WebConfig.ss
Normal file
29
templates/filesystem/Assets_WebConfig.ss
Normal file
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Configuration to whitelist appropriate asset files, for IIS.
|
||||
This file is automatically generated via File.allowed_extensions configuration
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<fileExtensions allowUnlisted="false" applyToWebDAV="true">
|
||||
<% loop $AllowedExtensions %>
|
||||
<add fileExtension=".{$Extension}" allowed="true" />
|
||||
<% end_loop %>
|
||||
</fileExtensions>
|
||||
</requestFiltering>
|
||||
</security>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="Secure and 404 File rewrite" stopProcessing="true">
|
||||
<match url="^(.*)$" />
|
||||
<conditions>
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="../framework/main.php?url={R:1}" appendQueryString="true" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
</system.webServer>
|
||||
</configuration>
|
2
templates/filesystem/Protected_HTAccess.ss
Normal file
2
templates/filesystem/Protected_HTAccess.ss
Normal file
@ -0,0 +1,2 @@
|
||||
Deny from all
|
||||
RewriteRule .* - [F]
|
17
templates/filesystem/Protected_WebConfig.ss
Normal file
17
templates/filesystem/Protected_WebConfig.ss
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Configuration to block web access to secure folders
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<clear />
|
||||
<rule name="BlockProtectedAssets" patternSyntax="Wildcard" stopProcessing="true">
|
||||
<match url="*" />
|
||||
<action type="CustomResponse" statusCode="403" statusReason="Forbidden: Access is denied." statusDescription="You do not have permission to view this directory or page using the credentials that you supplied." />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
</system.webServer>
|
||||
</configuration>
|
154
tests/filesystem/AssetControlExtensionTest.php
Normal file
154
tests/filesystem/AssetControlExtensionTest.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
|
||||
/**
|
||||
* Tests {@see AssetControlExtension}
|
||||
*/
|
||||
class AssetControlExtensionTest extends SapphireTest {
|
||||
|
||||
protected $extraDataObjects = array(
|
||||
'AssetControlExtensionTest_VersionedObject',
|
||||
'AssetControlExtensionTest_Object'
|
||||
);
|
||||
|
||||
public function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
// Set backend and base url
|
||||
AssetStoreTest_SpyStore::activate('AssetControlExtensionTest');
|
||||
|
||||
// Setup fixture manually
|
||||
$object1 = new AssetControlExtensionTest_VersionedObject();
|
||||
$object1->Title = 'My object';
|
||||
$fish1 = realpath(__DIR__ .'/../model/testimages/test-image-high-quality.jpg');
|
||||
$object1->Header->setFromLocalFile($fish1, 'Header/MyObjectHeader.jpg');
|
||||
$object1->Download->setFromString('file content', 'Documents/File.txt');
|
||||
$object1->write();
|
||||
$object1->publish('Stage', 'Live');
|
||||
|
||||
$object2 = new AssetControlExtensionTest_Object();
|
||||
$object2->Title = 'Unversioned';
|
||||
$object2->Image->setFromLocalFile($fish1, 'Images/BeautifulFish.jpg');
|
||||
$object2->write();
|
||||
|
||||
$object3 = new AssetControlExtensionTest_ArchivedObject();
|
||||
$object3->Title = 'Archived';
|
||||
$object3->Header->setFromLocalFile($fish1, 'Archived/MyObjectHeader.jpg');
|
||||
$object3->write();
|
||||
$object3->publish('Stage', 'Live');
|
||||
}
|
||||
|
||||
public function tearDown() {
|
||||
AssetStoreTest_SpyStore::reset();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testFileDelete() {
|
||||
/** @var AssetControlExtensionTest_VersionedObject $object1 */
|
||||
$object1 = AssetControlExtensionTest_VersionedObject::get()
|
||||
->filter('Title', 'My object')
|
||||
->first();
|
||||
/** @var AssetControlExtensionTest_Object $object2 */
|
||||
$object2 = AssetControlExtensionTest_Object::get()
|
||||
->filter('Title', 'Unversioned')
|
||||
->first();
|
||||
|
||||
/** @var AssetControlExtensionTest_ArchivedObject $object3 */
|
||||
$object3 = AssetControlExtensionTest_ArchivedObject::get()
|
||||
->filter('Title', 'Archived')
|
||||
->first();
|
||||
|
||||
$this->assertTrue($object1->Download->exists());
|
||||
$this->assertTrue($object1->Header->exists());
|
||||
$this->assertTrue($object2->Image->exists());
|
||||
$this->assertTrue($object3->Header->exists());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object1->Download->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object1->Header->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object2->Image->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object3->Header->getVisibility());
|
||||
|
||||
// Check live stage for versioned objects
|
||||
$object1Live = Versioned::get_one_by_stage('AssetControlExtensionTest_VersionedObject', 'Live',
|
||||
array('"ID"' => $object1->ID)
|
||||
);
|
||||
$object3Live = Versioned::get_one_by_stage('AssetControlExtensionTest_ArchivedObject', 'Live',
|
||||
array('"ID"' => $object3->ID)
|
||||
);
|
||||
$this->assertTrue($object1Live->Download->exists());
|
||||
$this->assertTrue($object1Live->Header->exists());
|
||||
$this->assertTrue($object3Live->Header->exists());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object1Live->Download->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object1Live->Header->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PUBLIC, $object3Live->Header->getVisibility());
|
||||
|
||||
// Delete live records; Should cause versioned records to be protected
|
||||
$object1Live->deleteFromStage('Live');
|
||||
$object3Live->deleteFromStage('Live');
|
||||
$this->assertTrue($object1->Download->exists());
|
||||
$this->assertTrue($object1->Header->exists());
|
||||
$this->assertTrue($object3->Header->exists());
|
||||
$this->assertTrue($object1Live->Download->exists());
|
||||
$this->assertTrue($object1Live->Header->exists());
|
||||
$this->assertTrue($object3Live->Header->exists());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PROTECTED, $object1->Download->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PROTECTED, $object1->Header->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PROTECTED, $object3->Header->getVisibility());
|
||||
|
||||
// Delete draft record; Should remove all records
|
||||
// Archived assets only should remain
|
||||
$object1->delete();
|
||||
$object2->delete();
|
||||
$object3->delete();
|
||||
$this->assertFalse($object1->Download->exists());
|
||||
$this->assertFalse($object1->Header->exists());
|
||||
$this->assertFalse($object2->Image->exists());
|
||||
$this->assertTrue($object3->Header->exists());
|
||||
$this->assertFalse($object1Live->Download->exists());
|
||||
$this->assertFalse($object1Live->Header->exists());
|
||||
$this->assertTrue($object3Live->Header->exists());
|
||||
$this->assertNull($object1->Download->getVisibility());
|
||||
$this->assertNull($object1->Header->getVisibility());
|
||||
$this->assertNull($object2->Image->getVisibility());
|
||||
$this->assertEquals(AssetStore::VISIBILITY_PROTECTED, $object3->Header->getVisibility());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned object with attached assets
|
||||
*
|
||||
* @property string $Title
|
||||
* @property DBFile $Header
|
||||
* @property DBFile $Download
|
||||
* @mixin Versioned
|
||||
*/
|
||||
class AssetControlExtensionTest_VersionedObject extends DataObject implements TestOnly {
|
||||
private static $extensions = array(
|
||||
'Versioned'
|
||||
);
|
||||
|
||||
private static $db = array(
|
||||
'Title' => 'Varchar(255)',
|
||||
'Header' => "DBFile('image/supported')",
|
||||
'Download' => 'DBFile'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A basic unversioned object
|
||||
*
|
||||
* @property string $Title
|
||||
* @property DBFile $Image
|
||||
*/
|
||||
class AssetControlExtensionTest_Object extends DataObject implements TestOnly {
|
||||
private static $db = array(
|
||||
'Title' => 'Varchar(255)',
|
||||
'Image' => "DBFile('image/supported')"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned object that always archives its assets
|
||||
*/
|
||||
class AssetControlExtensionTest_ArchivedObject extends AssetControlExtensionTest_VersionedObject {
|
||||
private static $archive_assets = true;
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Filesystem as SS_Filesystem;
|
||||
use League\Flysystem\Adapter\Local;
|
||||
use League\Flysystem\AdapterInterface;
|
||||
use League\Flysystem\Filesystem;
|
||||
use SilverStripe\Filesystem\Flysystem\AssetAdapter;
|
||||
use SilverStripe\Filesystem\Flysystem\FlysystemAssetStore;
|
||||
use SilverStripe\Filesystem\Flysystem\FlysystemUrlPlugin;
|
||||
use SilverStripe\Filesystem\Flysystem\ProtectedAssetAdapter;
|
||||
use SilverStripe\Filesystem\Flysystem\PublicAssetAdapter;
|
||||
use SilverStripe\Filesystem\Storage\AssetContainer;
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
use SilverStripe\Filesystem\Storage\FlysystemGeneratedAssetHandler;
|
||||
@ -15,7 +17,7 @@ class AssetStoreTest extends SapphireTest {
|
||||
parent::setUp();
|
||||
|
||||
// Set backend and base url
|
||||
AssetStoreTest_SpyStore::activate('DBFileTest');
|
||||
AssetStoreTest_SpyStore::activate('AssetStoreTest');
|
||||
}
|
||||
|
||||
public function tearDown() {
|
||||
@ -99,14 +101,20 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish1Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg',
|
||||
'/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg',
|
||||
$backend->getAsURL($fish1Tuple['Filename'], $fish1Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Write a different file with same name. Should not detect duplicates since sha are different
|
||||
$fish2 = realpath(__DIR__ .'/../model/testimages/test-image-low-quality.jpg');
|
||||
try {
|
||||
$fish2Tuple = $backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_EXCEPTION);
|
||||
$fish2Tuple = $backend->setFromLocalFile(
|
||||
$fish2,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_EXCEPTION)
|
||||
);
|
||||
} catch(Exception $ex) {
|
||||
return $this->fail('Writing file with different sha to same location failed with exception');
|
||||
}
|
||||
@ -119,13 +127,19 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish2Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/33be1b95cb/lovely-fish.jpg',
|
||||
'/assets/AssetStoreTest/directory/33be1b95cb/lovely-fish.jpg',
|
||||
$backend->getAsURL($fish2Tuple['Filename'], $fish2Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Write original file back with rename
|
||||
$this->assertFileExists($fish1);
|
||||
$fish3Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_RENAME);
|
||||
$fish3Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_RENAME)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1',
|
||||
@ -135,12 +149,18 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish3Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/a870de278b/lovely-fish-v2.jpg',
|
||||
'/assets/AssetStoreTest/directory/a870de278b/lovely-fish-v2.jpg',
|
||||
$backend->getAsURL($fish3Tuple['Filename'], $fish3Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Write another file should increment to -v3
|
||||
$fish4Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_RENAME);
|
||||
$fish4Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish-v2.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_RENAME)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1',
|
||||
@ -150,12 +170,18 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish4Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/a870de278b/lovely-fish-v3.jpg',
|
||||
'/assets/AssetStoreTest/directory/a870de278b/lovely-fish-v3.jpg',
|
||||
$backend->getAsURL($fish4Tuple['Filename'], $fish4Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Test conflict use existing file
|
||||
$fish5Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_USE_EXISTING);
|
||||
$fish5Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_USE_EXISTING)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1',
|
||||
@ -165,12 +191,18 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish5Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg',
|
||||
'/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg',
|
||||
$backend->getAsURL($fish5Tuple['Filename'], $fish5Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Test conflict use existing file
|
||||
$fish6Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_OVERWRITE);
|
||||
$fish6Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_OVERWRITE)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1',
|
||||
@ -180,7 +212,7 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish6Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg',
|
||||
'/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg',
|
||||
$backend->getAsURL($fish6Tuple['Filename'], $fish6Tuple['Hash'])
|
||||
);
|
||||
}
|
||||
@ -192,44 +224,36 @@ class AssetStoreTest extends SapphireTest {
|
||||
$store = new AssetStoreTest_SpyStore();
|
||||
$this->assertEquals(
|
||||
'directory/lovely-fish.jpg',
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely-fish.jpg', $variant)
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely-fish.jpg')
|
||||
);
|
||||
$this->assertEmpty($variant);
|
||||
$this->assertEquals(
|
||||
'directory/lovely-fish.jpg',
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely-fish__variant.jpg', $variant)
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely-fish__variant.jpg')
|
||||
);
|
||||
$this->assertEquals('variant', $variant);
|
||||
$this->assertEquals(
|
||||
'directory/lovely_fish.jpg',
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely_fish__vari_ant.jpg', $variant)
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely_fish__vari_ant.jpg')
|
||||
);
|
||||
$this->assertEquals('vari_ant', $variant);
|
||||
$this->assertEquals(
|
||||
'directory/lovely_fish.jpg',
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely_fish.jpg', $variant)
|
||||
$store->getOriginalFilename('directory/a870de278b/lovely_fish.jpg')
|
||||
);
|
||||
$this->assertEmpty($variant);
|
||||
$this->assertEquals(
|
||||
'lovely-fish.jpg',
|
||||
$store->getOriginalFilename('a870de278b/lovely-fish.jpg', $variant)
|
||||
$store->getOriginalFilename('a870de278b/lovely-fish.jpg')
|
||||
);
|
||||
$this->assertEmpty($variant);
|
||||
$this->assertEquals(
|
||||
'lovely-fish.jpg',
|
||||
$store->getOriginalFilename('a870de278b/lovely-fish__variant.jpg', $variant)
|
||||
$store->getOriginalFilename('a870de278b/lovely-fish__variant.jpg')
|
||||
);
|
||||
$this->assertEquals('variant', $variant);
|
||||
$this->assertEquals(
|
||||
'lovely_fish.jpg',
|
||||
$store->getOriginalFilename('a870de278b/lovely_fish__vari__ant.jpg', $variant)
|
||||
$store->getOriginalFilename('a870de278b/lovely_fish__vari__ant.jpg')
|
||||
);
|
||||
$this->assertEquals('vari__ant', $variant);
|
||||
$this->assertEquals(
|
||||
'lovely_fish.jpg',
|
||||
$store->getOriginalFilename('a870de278b/lovely_fish.jpg', $variant)
|
||||
$store->getOriginalFilename('a870de278b/lovely_fish.jpg')
|
||||
);
|
||||
$this->assertEmpty($variant);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -278,7 +302,6 @@ class AssetStoreTest extends SapphireTest {
|
||||
$this->assertEquals('file', $fishMeta['type']);
|
||||
$this->assertNotEmpty($fishMeta['timestamp']);
|
||||
|
||||
|
||||
// text
|
||||
$puppies = 'puppies';
|
||||
$puppiesTuple = $backend->setFromString($puppies, 'pets/my-puppy.txt');
|
||||
@ -313,7 +336,7 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish1Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/lovely-fish.jpg',
|
||||
'/assets/AssetStoreTest/directory/lovely-fish.jpg',
|
||||
$backend->getAsURL($fish1Tuple['Filename'], $fish1Tuple['Hash'])
|
||||
);
|
||||
|
||||
@ -321,14 +344,26 @@ class AssetStoreTest extends SapphireTest {
|
||||
// Since we are using legacy filenames, this should generate a new filename
|
||||
$fish2 = realpath(__DIR__ .'/../model/testimages/test-image-low-quality.jpg');
|
||||
try {
|
||||
$backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_EXCEPTION);
|
||||
$backend->setFromLocalFile(
|
||||
$fish2,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_EXCEPTION)
|
||||
);
|
||||
return $this->fail('Writing file with different sha to same location should throw exception');
|
||||
} catch(Exception $ex) {
|
||||
// Success
|
||||
}
|
||||
|
||||
// Re-attempt this file write with conflict_rename
|
||||
$fish3Tuple = $backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_RENAME);
|
||||
$fish3Tuple = $backend->setFromLocalFile(
|
||||
$fish2,
|
||||
'directory/lovely-fish.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_RENAME)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => '33be1b95cba0358fe54e8b13532162d52f97421c',
|
||||
@ -338,12 +373,18 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish3Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/lovely-fish-v2.jpg',
|
||||
'/assets/AssetStoreTest/directory/lovely-fish-v2.jpg',
|
||||
$backend->getAsURL($fish3Tuple['Filename'], $fish3Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Write back original file, but with CONFLICT_EXISTING. The file should not change
|
||||
$fish4Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_USE_EXISTING);
|
||||
$fish4Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish-v2.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_USE_EXISTING)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => '33be1b95cba0358fe54e8b13532162d52f97421c',
|
||||
@ -353,12 +394,18 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish4Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/lovely-fish-v2.jpg',
|
||||
'/assets/AssetStoreTest/directory/lovely-fish-v2.jpg',
|
||||
$backend->getAsURL($fish4Tuple['Filename'], $fish4Tuple['Hash'])
|
||||
);
|
||||
|
||||
// Write back original file with CONFLICT_OVERWRITE. The file sha should now be updated
|
||||
$fish5Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_OVERWRITE);
|
||||
$fish5Tuple = $backend->setFromLocalFile(
|
||||
$fish1,
|
||||
'directory/lovely-fish-v2.jpg',
|
||||
null,
|
||||
null,
|
||||
array('conflict' => AssetStore::CONFLICT_OVERWRITE)
|
||||
);
|
||||
$this->assertEquals(
|
||||
array(
|
||||
'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1',
|
||||
@ -368,7 +415,7 @@ class AssetStoreTest extends SapphireTest {
|
||||
$fish5Tuple
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/DBFileTest/directory/lovely-fish-v2.jpg',
|
||||
'/assets/AssetStoreTest/directory/lovely-fish-v2.jpg',
|
||||
$backend->getAsURL($fish5Tuple['Filename'], $fish5Tuple['Hash'])
|
||||
);
|
||||
}
|
||||
@ -389,6 +436,74 @@ class AssetStoreTest extends SapphireTest {
|
||||
$this->assertEquals(AssetStore::CONFLICT_RENAME, $store->getDefaultConflictResolution(null));
|
||||
$this->assertEquals(AssetStore::CONFLICT_OVERWRITE, $store->getDefaultConflictResolution('somevariant'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test protect / publish mechanisms
|
||||
*/
|
||||
public function testProtect() {
|
||||
$backend = $this->getBackend();
|
||||
$fish = realpath(__DIR__ .'/../model/testimages/test-image-high-quality.jpg');
|
||||
$fishTuple = $backend->setFromLocalFile($fish, 'parent/lovely-fish.jpg');
|
||||
$fishVariantTuple = $backend->setFromLocalFile($fish, $fishTuple['Filename'], $fishTuple['Hash'], 'copy');
|
||||
|
||||
// Test public file storage
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg');
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg');
|
||||
$this->assertEquals(
|
||||
AssetStore::VISIBILITY_PUBLIC,
|
||||
$backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash'])
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/AssetStoreTest/parent/a870de278b/lovely-fish.jpg',
|
||||
$backend->getAsURL($fishTuple['Filename'], $fishTuple['Hash'])
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg',
|
||||
$backend->getAsURL($fishVariantTuple['Filename'], $fishVariantTuple['Hash'], $fishVariantTuple['Variant'])
|
||||
);
|
||||
|
||||
// Test access rights to public files cannot be revoked
|
||||
$backend->revoke($fishTuple['Filename'], $fishTuple['Hash']); // can't revoke public assets
|
||||
$this->assertTrue($backend->canView($fishTuple['Filename'], $fishTuple['Hash']));
|
||||
|
||||
// Test protected file storage
|
||||
$backend->protect($fishTuple['Filename'], $fishTuple['Hash']);
|
||||
$this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg');
|
||||
$this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg');
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish.jpg');
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish__copy.jpg');
|
||||
$this->assertEquals(
|
||||
AssetStore::VISIBILITY_PROTECTED,
|
||||
$backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash'])
|
||||
);
|
||||
|
||||
// Test access rights
|
||||
$backend->revoke($fishTuple['Filename'], $fishTuple['Hash']);
|
||||
$this->assertFalse($backend->canView($fishTuple['Filename'], $fishTuple['Hash']));
|
||||
$backend->grant($fishTuple['Filename'], $fishTuple['Hash']);
|
||||
$this->assertTrue($backend->canView($fishTuple['Filename'], $fishTuple['Hash']));
|
||||
|
||||
// Protected urls should go through asset routing mechanism
|
||||
$this->assertEquals(
|
||||
'/assets/parent/a870de278b/lovely-fish.jpg',
|
||||
$backend->getAsURL($fishTuple['Filename'], $fishTuple['Hash'])
|
||||
);
|
||||
$this->assertEquals(
|
||||
'/assets/parent/a870de278b/lovely-fish__copy.jpg',
|
||||
$backend->getAsURL($fishVariantTuple['Filename'], $fishVariantTuple['Hash'], $fishVariantTuple['Variant'])
|
||||
);
|
||||
|
||||
// Publish reverts visibility
|
||||
$backend->publish($fishTuple['Filename'], $fishTuple['Hash']);
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg');
|
||||
$this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg');
|
||||
$this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish.jpg');
|
||||
$this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish__copy.jpg');
|
||||
$this->assertEquals(
|
||||
AssetStore::VISIBILITY_PUBLIC,
|
||||
$backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -396,6 +511,14 @@ class AssetStoreTest extends SapphireTest {
|
||||
*/
|
||||
class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
|
||||
/**
|
||||
* Enable disclosure of secure assets
|
||||
*
|
||||
* @config
|
||||
* @var int
|
||||
*/
|
||||
private static $denied_response_code = 403;
|
||||
|
||||
/**
|
||||
* Set to true|false to override all isSeekableStream calls
|
||||
*
|
||||
@ -417,16 +540,23 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
*/
|
||||
public static function activate($basedir) {
|
||||
// Assign this as the new store
|
||||
$adapter = new AssetAdapter(ASSETS_PATH . '/' . $basedir);
|
||||
$filesystem = new Filesystem($adapter);
|
||||
$filesystem->addPlugin(new FlysystemUrlPlugin());
|
||||
$publicAdapter = new PublicAssetAdapter(ASSETS_PATH . '/' . $basedir);
|
||||
$publicFilesystem = new Filesystem($publicAdapter, [
|
||||
'visibility' => AdapterInterface::VISIBILITY_PUBLIC
|
||||
]);
|
||||
$protectedAdapter = new ProtectedAssetAdapter(ASSETS_PATH . '/' . $basedir . '/.protected');
|
||||
$protectedFilesystem = new Filesystem($protectedAdapter, [
|
||||
'visibility' => AdapterInterface::VISIBILITY_PRIVATE
|
||||
]);
|
||||
|
||||
$backend = new AssetStoreTest_SpyStore();
|
||||
$backend->setFilesystem($filesystem);
|
||||
$backend->setPublicFilesystem($publicFilesystem);
|
||||
$backend->setProtectedFilesystem($protectedFilesystem);
|
||||
Injector::inst()->registerService($backend, 'AssetStore');
|
||||
|
||||
// Assign flysystem backend to generated asset handler at the same time
|
||||
$generated = new FlysystemGeneratedAssetHandler();
|
||||
$generated->setFilesystem($filesystem);
|
||||
$generated->setFilesystem($publicFilesystem);
|
||||
Injector::inst()->registerService($generated, 'GeneratedAssetHandler');
|
||||
Requirements::backend()->setAssetHandler($generated);
|
||||
|
||||
@ -439,7 +569,7 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
self::$basedir = $basedir;
|
||||
|
||||
// Ensure basedir exists
|
||||
SS_Filesystem::makeFolder(self::base_path());
|
||||
\Filesystem::makeFolder(self::base_path());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -461,7 +591,7 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
if(self::$basedir) {
|
||||
$path = self::base_path();
|
||||
if(file_exists($path)) {
|
||||
SS_Filesystem::removeFolder($path);
|
||||
\Filesystem::removeFolder($path);
|
||||
}
|
||||
}
|
||||
self::$seekable_override = null;
|
||||
@ -480,10 +610,18 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
if($asset instanceof File) {
|
||||
$asset = $asset->File;
|
||||
}
|
||||
if($asset instanceof DBFile) {
|
||||
return BASE_PATH . $asset->getSourceURL();
|
||||
// Extract filesystem used to store this object
|
||||
/** @var AssetStoreTest_SpyStore $assetStore */
|
||||
$assetStore = Injector::inst()->get('AssetStore');
|
||||
$fileID = $assetStore->getFileID($asset->Filename, $asset->Hash, $asset->Variant);
|
||||
$filesystem = $assetStore->getProtectedFilesystem();
|
||||
if(!$filesystem->has($fileID)) {
|
||||
$filesystem = $assetStore->getPublicFilesystem();
|
||||
}
|
||||
return BASE_PATH . $asset->getUrl();
|
||||
/** @var Local $adapter */
|
||||
$adapter = $filesystem->getAdapter();
|
||||
return $adapter->applyPathPrefix($fileID);
|
||||
|
||||
}
|
||||
|
||||
public function cleanFilename($filename) {
|
||||
@ -495,8 +633,12 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
|
||||
}
|
||||
|
||||
|
||||
public function getOriginalFilename($fileID, &$variant = '') {
|
||||
return parent::getOriginalFilename($fileID, $variant);
|
||||
public function getOriginalFilename($fileID) {
|
||||
return parent::getOriginalFilename($fileID);
|
||||
}
|
||||
|
||||
public function removeVariant($fileID) {
|
||||
return parent::removeVariant($fileID);
|
||||
}
|
||||
|
||||
public function getDefaultConflictResolution($variant) {
|
||||
|
@ -352,15 +352,15 @@ class FileTest extends SapphireTest {
|
||||
$this->assertEquals("93132.3 GB", File::format_size(100000000000000));
|
||||
}
|
||||
|
||||
public function testDeleteDatabaseOnly() {
|
||||
public function testDeleteFile() {
|
||||
$file = $this->objFromFixture('File', 'asdf');
|
||||
$fileID = $file->ID;
|
||||
$filePath = AssetStoreTest_SpyStore::getLocalPath($file);
|
||||
|
||||
$file->delete();
|
||||
|
||||
$this->assertFileExists($filePath);
|
||||
$this->assertFalse(DataObject::get_by_id('File', $fileID));
|
||||
// File is deleted
|
||||
$this->assertFileNotExists($filePath);
|
||||
$this->assertEmpty(DataObject::get_by_id('File', $fileID));
|
||||
}
|
||||
|
||||
public function testRenameFolder() {
|
||||
|
201
tests/filesystem/ProtectedFileControllerTest.php
Normal file
201
tests/filesystem/ProtectedFileControllerTest.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
use SilverStripe\Filesystem\Storage\ProtectedFileController;
|
||||
|
||||
class ProtectedFileControllerTest extends FunctionalTest {
|
||||
|
||||
protected static $fixture_file = 'FileTest.yml';
|
||||
|
||||
public function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
// Set backend root to /ImageTest
|
||||
AssetStoreTest_SpyStore::activate('ProtectedFileControllerTest');
|
||||
|
||||
// Create a test folders for each of the fixture references
|
||||
foreach (Folder::get() as $folder) {
|
||||
/** @var Folder $folder */
|
||||
$filePath = AssetStoreTest_SpyStore::getLocalPath($folder);
|
||||
\Filesystem::makeFolder($filePath);
|
||||
}
|
||||
|
||||
// Create a test files for each of the fixture references
|
||||
foreach (File::get()->exclude('ClassName', 'Folder') as $file) {
|
||||
/** @var File $file */
|
||||
$path = AssetStoreTest_SpyStore::getLocalPath($file);
|
||||
\Filesystem::makeFolder(dirname($path));
|
||||
$fh = fopen($path, "w+");
|
||||
fwrite($fh, str_repeat('x', 1000000));
|
||||
fclose($fh);
|
||||
|
||||
// Create variant for each file
|
||||
$this->getAssetStore()->setFromString(
|
||||
str_repeat('y', 100),
|
||||
$file->Filename,
|
||||
$file->Hash,
|
||||
'variant'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getFilenames
|
||||
*/
|
||||
public function testIsValidFilename($name, $isValid) {
|
||||
$controller = new ProtectedFileController();
|
||||
$this->assertEquals(
|
||||
$isValid,
|
||||
$controller->isValidFilename($name),
|
||||
"Assert filename \"$name\" is " . $isValid ? "valid" : "invalid"
|
||||
);
|
||||
}
|
||||
|
||||
public function getFilenames() {
|
||||
return array(
|
||||
// Valid names
|
||||
array('name.jpg', true),
|
||||
array('parent/name.jpg', true),
|
||||
array('parent/name', true),
|
||||
array('parent\name.jpg', true),
|
||||
array('parent\name', true),
|
||||
array('name', true),
|
||||
|
||||
// Invalid names
|
||||
array('.invalid/name.jpg', false),
|
||||
array('.invalid\name.jpg', false),
|
||||
array('.htaccess', false),
|
||||
array('test/.htaccess.jpg', false),
|
||||
array('name/.jpg', false),
|
||||
array('test\.htaccess.jpg', false),
|
||||
array('name\.jpg', false)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that certain requests are denied
|
||||
*/
|
||||
public function testInvalidRequest() {
|
||||
$result = $this->get('assets/.protected/file.jpg');
|
||||
$this->assertResponseEquals(400, null, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that invalid files generate 404 response
|
||||
*/
|
||||
public function testFileNotFound() {
|
||||
$result = $this->get('assets/missing.jpg');
|
||||
$this->assertResponseEquals(404, null, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check public access to assets is available at the appropriate time
|
||||
*/
|
||||
public function testAccessControl() {
|
||||
$expectedContent = str_repeat('x', 1000000);
|
||||
$variantContent = str_repeat('y', 100);
|
||||
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(200, $expectedContent, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(200, $variantContent, $result);
|
||||
|
||||
// Make this file protected
|
||||
$this->getAssetStore()->protect(
|
||||
'FileTest.txt',
|
||||
'55b443b60176235ef09801153cca4e6da7494a0c'
|
||||
);
|
||||
|
||||
// Should now return explicitly denied errors
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
|
||||
// Other assets remain available
|
||||
$result = $this->get('assets/55b443b601/FileTest.pdf');
|
||||
$this->assertResponseEquals(200, $expectedContent, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.pdf');
|
||||
$this->assertResponseEquals(200, $variantContent, $result);
|
||||
|
||||
// granting access will allow access
|
||||
$this->getAssetStore()->grant(
|
||||
'FileTest.txt',
|
||||
'55b443b60176235ef09801153cca4e6da7494a0c'
|
||||
);
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(200, $expectedContent, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(200, $variantContent, $result);
|
||||
|
||||
// Revoking access will remove access again
|
||||
$this->getAssetStore()->revoke(
|
||||
'FileTest.txt',
|
||||
'55b443b60176235ef09801153cca4e6da7494a0c'
|
||||
);
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
|
||||
// Moving file back to public store restores access
|
||||
$this->getAssetStore()->publish(
|
||||
'FileTest.txt',
|
||||
'55b443b60176235ef09801153cca4e6da7494a0c'
|
||||
);
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(200, $expectedContent, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(200, $variantContent, $result);
|
||||
|
||||
// Deleting the file will make the response 404
|
||||
$this->getAssetStore()->delete(
|
||||
'FileTest.txt',
|
||||
'55b443b60176235ef09801153cca4e6da7494a0c'
|
||||
);
|
||||
$result = $this->get('assets/55b443b601/FileTest.txt');
|
||||
$this->assertResponseEquals(404, null, $result);
|
||||
$result = $this->get('assets/55b443b601/FileTest__variant.txt');
|
||||
$this->assertResponseEquals(404, null, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that access to folders is not permitted
|
||||
*/
|
||||
public function testFolders() {
|
||||
$result = $this->get('assets/55b443b601');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
|
||||
$result = $this->get('assets/FileTest-subfolder');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
|
||||
$result = $this->get('assets');
|
||||
$this->assertResponseEquals(403, null, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AssetStore
|
||||
*/
|
||||
protected function getAssetStore() {
|
||||
return singleton('AssetStore');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a response matches the given parameters
|
||||
*
|
||||
* @param int $code HTTP code
|
||||
* @param string $body Body expected for 200 responses
|
||||
* @param SS_HTTPResponse $response
|
||||
*/
|
||||
protected function assertResponseEquals($code, $body, SS_HTTPResponse $response) {
|
||||
$this->assertEquals($code, $response->getStatusCode());
|
||||
if($code === 200) {
|
||||
$this->assertFalse($response->isError());
|
||||
$this->assertEquals($body, $response->getBody());
|
||||
$this->assertEquals('text/plain', $response->getHeader('Content-Type'));
|
||||
} else {
|
||||
$this->assertTrue($response->isError());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
<?php
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
|
||||
/**
|
||||
* Description of DBFileTest
|
||||
@ -63,6 +64,28 @@ class DBFileTest extends SapphireTest {
|
||||
$obj->MyFile->setFromString('puppies', 'subdir/puppy-document.txt');
|
||||
}
|
||||
|
||||
public function testPermission() {
|
||||
$obj = new DBFileTest_Object();
|
||||
|
||||
// Test from image
|
||||
$fish = realpath(__DIR__ .'/../model/testimages/test-image-high-quality.jpg');
|
||||
$this->assertFileExists($fish);
|
||||
$obj->MyFile->setFromLocalFile($fish, 'private/awesome-fish.jpg', null, null, array(
|
||||
'visibility' => AssetStore::VISIBILITY_PROTECTED
|
||||
));
|
||||
|
||||
// Test various file permissions work on DBFile
|
||||
$this->assertFalse($obj->MyFile->canViewFile());
|
||||
$obj->MyFile->getURL();
|
||||
$this->assertTrue($obj->MyFile->canViewFile());
|
||||
$obj->MyFile->revokeFile();
|
||||
$this->assertFalse($obj->MyFile->canViewFile());
|
||||
$obj->MyFile->getURL(false);
|
||||
$this->assertFalse($obj->MyFile->canViewFile());
|
||||
$obj->MyFile->grantFile();
|
||||
$this->assertTrue($obj->MyFile->canViewFile());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,14 +97,18 @@ class DBFileTest_Object extends DataObject implements TestOnly {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @property DBFile $AnotherFile
|
||||
*/
|
||||
class DBFileTest_Subclass extends DBFileTest_Object implements TestOnly {
|
||||
private static $db = array(
|
||||
"AnotherFile" => "DBFile"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @property DBFile $MyFile
|
||||
*/
|
||||
class DBFileTest_ImageOnly extends DataObject implements TestOnly {
|
||||
private static $db = array(
|
||||
"MyFile" => "DBFile('image/supported')"
|
||||
|
Loading…
Reference in New Issue
Block a user