API Asset Access Control implementation

This commit is contained in:
Damian Mooyman 2015-12-10 10:19:23 +13:00
parent f665e96b21
commit 037467beae
44 changed files with 2622 additions and 431 deletions

View File

@ -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

View File

@ -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];
}

View File

@ -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;

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -38,7 +38,7 @@ But enough of the disclaimer, on to the actual configuration — typically in `n
location ^~ /assets/ {
sendfile on;
try_files $uri =404;
try_files $uri /framework/main.php?url=$uri&$query_string;
}
location ~ /framework/.*(main|rpc|tiny_mce_gzip)\.php$ {

View File

@ -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

View 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 a
whitelist action. 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;
}

View File

@ -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.

View 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'));
}
}

View 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');
}
}

View File

@ -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) {
@ -1063,7 +1065,7 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer {
if(count($args) === 1 && is_array($args[0])) {
$args = $args[0];
}
$parts = array();
foreach($args as $arg) {
$part = trim($arg, ' \\/');
@ -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();
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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)
);
}
);
}

View File

@ -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;
}

View File

@ -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);
}
/**

View File

@ -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
)));
}
}

View File

@ -3,13 +3,19 @@
namespace SilverStripe\Filesystem\Flysystem;
use Config;
use Generator;
use Injector;
use League\Flysystem\Directory;
use Session;
use Flushable;
use InvalidArgumentException;
use League\Flysystem\Exception;
use League\Flysystem\Filesystem;
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 +23,65 @@ 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;
/**
* 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 +90,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 +210,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 +227,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 +242,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 +265,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('AssetStore_Grants') ?: array();
return !empty($granted[$originalID]);
}
/**
@ -158,21 +423,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 +461,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 +491,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 +515,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 +559,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 +596,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 +604,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 +620,7 @@ class FlysystemAssetStore implements AssetStore {
}
// Use existing file
case AssetStore::CONFLICT_USE_EXISTING:
case static::CONFLICT_USE_EXISTING:
default: {
return false;
}
@ -340,7 +630,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 +651,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 +695,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 +710,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 +735,91 @@ 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->createInvalidResponse();
}
// 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 Filesystem $flysystem
* @param string $fileID
* @return SS_HTTPResponse
*/
protected function createResponseFor(Filesystem $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 403 response for the given file
*
* @return SS_HTTPResponse
*/
protected function createDeniedResponse() {
$response = new SS_HTTPResponse(null, 403);
return $response;
}
/**
* Generate 404 response for missing file requests
*
* @return SS_HTTPResponse
*/
protected function createInvalidResponse() {
$response = new SS_HTTPResponse('', 404);
// Show message in dev
if(!\Director::isLive()) {
$response->setBody($response->getStatusDescription());
}
return $response;
}
}

View File

@ -46,6 +46,7 @@ class FlysystemGeneratedAssetHandler implements GeneratedAssetHandler {
if($result) {
return $this
->getFilesystem()
->getAdapter()
->getPublicUrl($filename);
}
}

View File

@ -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;
}
}

View 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);
}

View 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);
}
}

View 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);
}

View 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;
}
}

View File

@ -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();
}

View File

@ -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);
}

View 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);
}

View File

@ -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);
}
}

View 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;
}
}

View File

@ -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.
*/

View File

@ -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

View File

@ -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;

View File

@ -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);

View 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>

View 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>

View File

@ -0,0 +1,2 @@
Deny from all
RewriteRule .* - [F]

View 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>

View 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;
}

View File

@ -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'])
);
}
}
/**
@ -417,16 +532,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 +561,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 +583,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 +602,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 +625,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) {

View File

@ -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() {

View 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 testRequestDenied() {
$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());
}
}
}

View File

@ -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')"