diff --git a/_config/asset.yml b/_config/asset.yml index 1b4249277..3c8541537 100644 --- a/_config/asset.yml +++ b/_config/asset.yml @@ -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 diff --git a/core/Extension.php b/core/Extension.php index 57e1ca3b4..7a17bcddd 100644 --- a/core/Extension.php +++ b/core/Extension.php @@ -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]; } diff --git a/core/Object.php b/core/Object.php index 6c9d17f84..ac29b5408 100755 --- a/core/Object.php +++ b/core/Object.php @@ -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; diff --git a/dev/CliTestReporter.php b/dev/CliTestReporter.php index c0a3486c0..677fa869b 100644 --- a/dev/CliTestReporter.php +++ b/dev/CliTestReporter.php @@ -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) { diff --git a/dev/SapphireTestReporter.php b/dev/SapphireTestReporter.php index b658b8f67..14bed0ce2 100644 --- a/dev/SapphireTestReporter.php +++ b/dev/SapphireTestReporter.php @@ -1,6 +1,10 @@ $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); + } } diff --git a/docs/en/00_Getting_Started/01_Installation/How_To/Configure_Nginx.md b/docs/en/00_Getting_Started/01_Installation/How_To/Configure_Nginx.md index a8ae1cbd3..86d6e5247 100644 --- a/docs/en/00_Getting_Started/01_Installation/How_To/Configure_Nginx.md +++ b/docs/en/00_Getting_Started/01_Installation/How_To/Configure_Nginx.md @@ -37,8 +37,11 @@ But enough of the disclaimer, on to the actual configuration — typically in `n error_page 500 /assets/error-500.html; location ^~ /assets/ { + location ~ /\. { + deny all; + } sendfile on; - try_files $uri =404; + try_files $uri /framework/main.php?url=$uri&$query_string; } location ~ /framework/.*(main|rpc|tiny_mce_gzip)\.php$ { diff --git a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md index ac36400b0..7c19759cc 100644 --- a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md +++ b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md @@ -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: - - - - php_flag engine off - Options -ExecCGI -Includes -Indexes - - - - -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 diff --git a/docs/en/02_Developer_Guides/14_Files/03_File_Security.md b/docs/en/02_Developer_Guides/14_Files/03_File_Security.md new file mode 100644 index 000000000..f4687fb60 --- /dev/null +++ b/docs/en/02_Developer_Guides/14_Files/03_File_Security.md @@ -0,0 +1,375 @@ +summary: Manage access permission to assets + +# File Security + +## Security overview + +File security is an important concept, and is as essential as managing any other piece of data that exists +in your system. As pages and dataobjects can be either versioned, or restricted to view by authenticated +members, it is necessary at times to apply similar logic to any files which are attached to these objects +in the same way. + +Out of the box SilverStripe Framework comes with an asset storage mechanism with two stores, a public +store and a protected one. Most operations which act on assets work independently of this mechanism, +without having to consider whether any specific file is protected or public, but can normally be +instructed to favour private or protected stores in some cases. + +For instance, in order to write an asset to a protected location you can use the following additional +config option: + + + :::php + $store = singleton('AssetStore'); + $store->setFromString('My protected content', 'Documents/Mydocument.txt', null, null, array( + 'visibility' => AssetStore::VISIBILITY_PROTECTED + )); + + +## User access control + +Access for files is granted on a per-session basis, rather than on a per-member basis, via +whitelisting accessed assets. This means that access to any protected asset must be made prior to the user +actually attempting to download that asset. This is normally done in the PHP request that generated +the response containing the link to that file. + +An automated system will, in most cases, handle this whitelisting for you. Calls to getURL() +will automatically whitelist access to that file for the current user. Using this as a guide, you can easily +control access to embedded assets at a template level. + + :::ss + + +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 %> + +
  • Access to $Title is denied
  • + <% 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(); + + +
    +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. +
    + +## 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 + + +
    +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. +
    + +### 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): + + + + + + + + + + + +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; + } + diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index 2e7933127..22a005809 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -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. diff --git a/filesystem/AssetAdapterTest.php b/filesystem/AssetAdapterTest.php new file mode 100644 index 000000000..d83ec6360 --- /dev/null +++ b/filesystem/AssetAdapterTest.php @@ -0,0 +1,69 @@ +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')); + } +} \ No newline at end of file diff --git a/filesystem/AssetControlExtension.php b/filesystem/AssetControlExtension.php new file mode 100644 index 000000000..634987590 --- /dev/null +++ b/filesystem/AssetControlExtension.php @@ -0,0 +1,113 @@ +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'); + } + +} diff --git a/filesystem/File.php b/filesystem/File.php index 06f5e1337..dd69d3854 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -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(); + } } diff --git a/filesystem/FileMigrationHelper.php b/filesystem/FileMigrationHelper.php index 9f59c0714..681eb46ba 100644 --- a/filesystem/FileMigrationHelper.php +++ b/filesystem/FileMigrationHelper.php @@ -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) { diff --git a/filesystem/Folder.php b/filesystem/Folder.php index 9251e55eb..6ee808942 100644 --- a/filesystem/Folder.php +++ b/filesystem/Folder.php @@ -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; } diff --git a/filesystem/GDBackend.php b/filesystem/GDBackend.php index 9d37d9d3c..a580df013 100644 --- a/filesystem/GDBackend.php +++ b/filesystem/GDBackend.php @@ -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; } diff --git a/filesystem/ImageManipulation.php b/filesystem/ImageManipulation.php index ee4f65b62..4614bc1c9 100644 --- a/filesystem/ImageManipulation.php +++ b/filesystem/ImageManipulation.php @@ -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) + ); } ); } diff --git a/filesystem/ImagickBackend.php b/filesystem/ImagickBackend.php index ba4e548b2..5f796ba93 100644 --- a/filesystem/ImagickBackend.php +++ b/filesystem/ImagickBackend.php @@ -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; } diff --git a/filesystem/Upload.php b/filesystem/Upload.php index 76e574c4b..1d6e4ab36 100644 --- a/filesystem/Upload.php +++ b/filesystem/Upload.php @@ -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); } /** diff --git a/filesystem/flysystem/AssetAdapter.php b/filesystem/flysystem/AssetAdapter.php index ac9fadd31..03273e2f4 100644 --- a/filesystem/flysystem/AssetAdapter.php +++ b/filesystem/flysystem/AssetAdapter.php @@ -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 + ))); + } + } diff --git a/filesystem/flysystem/FlysystemAssetStore.php b/filesystem/flysystem/FlysystemAssetStore.php index f4b4c8212..d80792394 100644 --- a/filesystem/flysystem/FlysystemAssetStore.php +++ b/filesystem/flysystem/FlysystemAssetStore.php @@ -3,13 +3,20 @@ namespace SilverStripe\Filesystem\Flysystem; use Config; +use Generator; use Injector; +use Session; +use Flushable; use InvalidArgumentException; +use League\Flysystem\Directory; use League\Flysystem\Exception; use League\Flysystem\Filesystem; +use League\Flysystem\FilesystemInterface; use League\Flysystem\Util; use SilverStripe\Filesystem\Storage\AssetNameGenerator; use SilverStripe\Filesystem\Storage\AssetStore; +use SilverStripe\Filesystem\Storage\AssetStoreRouter; +use SS_HTTPResponse; /** * Asset store based on flysystem Filesystem as a backend @@ -17,29 +24,83 @@ use SilverStripe\Filesystem\Storage\AssetStore; * @package framework * @subpackage filesystem */ -class FlysystemAssetStore implements AssetStore { +class FlysystemAssetStore implements AssetStore, AssetStoreRouter, Flushable { + + /** + * Session key to use for user grants + */ + const GRANTS_SESSION = 'AssetStore_Grants'; /** * @var Filesystem */ - private $filesystem = null; + private $publicFilesystem = null; + + /** + * Filesystem to use for protected files + * + * @var Filesystem + */ + private $protectedFilesystem = null; /** * Enable to use legacy filename behaviour (omits hash) * + * Note that if using legacy filenames then duplicate files will not work. + * * @config * @var bool */ private static $legacy_filenames = false; + /** + * Flag if empty folders are allowed. + * If false, empty folders are cleared up when their contents are deleted. + * + * @config + * @var bool + */ + private static $keep_empty_dirs = false; + + /** + * Set HTTP error code for requests to secure denied assets. + * Note that this defaults to 404 to prevent information disclosure + * of secure files + * + * @config + * @var int + */ + private static $denied_response_code = 404; + + /** + * Set HTTP error code to use for missing secure assets + * + * @config + * @var int + */ + private static $missing_response_code = 404; + + /** + * Custom headers to add to all custom file responses + * + * @config + * @var array + */ + private static $file_response_headers = array( + 'Cache-Control' => 'private' + ); + /** * Assign new flysystem backend * * @param Filesystem $filesystem * @return $this */ - public function setFilesystem(Filesystem $filesystem) { - $this->filesystem = $filesystem; + public function setPublicFilesystem(Filesystem $filesystem) { + if(!$filesystem->getAdapter() instanceof PublicAdapter) { + throw new \InvalidArgumentException("Configured adapter must implement PublicAdapter"); + } + $this->publicFilesystem = $filesystem; return $this; } @@ -48,26 +109,115 @@ class FlysystemAssetStore implements AssetStore { * * @return Filesystem */ - public function getFilesystem() { - return $this->filesystem; + public function getPublicFilesystem() { + return $this->publicFilesystem; } + /** + * Assign filesystem to use for non-public files + * + * @param Filesystem $filesystem + * @return $this + */ + public function setProtectedFilesystem(Filesystem $filesystem) { + if(!$filesystem->getAdapter() instanceof ProtectedAdapter) { + throw new \InvalidArgumentException("Configured adapter must implement ProtectedAdapter"); + } + $this->protectedFilesystem = $filesystem; + return $this; + } + + /** + * Get filesystem to use for non-public files + * + * @return Filesystem + */ + public function getProtectedFilesystem() { + return $this->protectedFilesystem; + } + + /** + * Return the store that contains the given fileID + * + * @param string $fileID Internal file identifier + * @return Filesystem + */ + protected function getFilesystemFor($fileID) { + if($this->getPublicFilesystem()->has($fileID)) { + return $this->getPublicFilesystem(); + } + + if($this->getProtectedFilesystem()->has($fileID)) { + return $this->getProtectedFilesystem(); + } + + return null; + } + + public function getCapabilities() { + return array( + 'visibility' => array( + self::VISIBILITY_PUBLIC, + self::VISIBILITY_PROTECTED + ), + 'conflict' => array( + self::CONFLICT_EXCEPTION, + self::CONFLICT_OVERWRITE, + self::CONFLICT_RENAME, + self::CONFLICT_USE_EXISTING + ) + ); + } + + public function getVisibility($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + if($this->getPublicFilesystem()->has($fileID)) { + return self::VISIBILITY_PUBLIC; + } + + if($this->getProtectedFilesystem()->has($fileID)) { + return self::VISIBILITY_PROTECTED; + } + + return null; + } + + public function getAsStream($filename, $hash, $variant = null) { $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->readStream($fileID); + return $this + ->getFilesystemFor($fileID) + ->readStream($fileID); } public function getAsString($filename, $hash, $variant = null) { $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->read($fileID); + return $this + ->getFilesystemFor($fileID) + ->read($fileID); } - public function getAsURL($filename, $hash, $variant = null) { + public function getAsURL($filename, $hash, $variant = null, $grant = true) { + if($grant) { + $this->grant($filename, $hash); + } $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->getPublicUrl($fileID); + + // Check with filesystem this asset exists in + $public = $this->getPublicFilesystem(); + $protected = $this->getProtectedFilesystem(); + if($public->has($fileID) || !$protected->has($fileID)) { + /** @var PublicAdapter $publicAdapter */ + $publicAdapter = $public->getAdapter(); + return $publicAdapter->getPublicUrl($fileID); + } else { + /** @var ProtectedAdapter $protectedAdapter */ + $protectedAdapter = $protected->getAdapter(); + return $protectedAdapter->getProtectedUrl($fileID); + } } - public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $conflictResolution = null) { + public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) { // Validate this file exists if(!file_exists($path)) { throw new InvalidArgumentException("$path does not exist"); @@ -79,8 +229,7 @@ class FlysystemAssetStore implements AssetStore { } // Callback for saving content - $filesystem = $this->getFilesystem(); - $callback = function($fileID) use ($filesystem, $path) { + $callback = function(Filesystem $filesystem, $fileID) use ($path) { // Read contents as string into flysystem $handle = fopen($path, 'r'); if($handle === false) { @@ -97,13 +246,12 @@ class FlysystemAssetStore implements AssetStore { } // Submit to conflict check - return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution); + return $this->writeWithCallback($callback, $filename, $hash, $variant, $config); } - public function setFromString($data, $filename, $hash = null, $variant = null, $conflictResolution = null) { + public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) { // Callback for saving content - $filesystem = $this->getFilesystem(); - $callback = function($fileID) use ($filesystem, $data) { + $callback = function(Filesystem $filesystem, $fileID) use ($data) { return $filesystem->put($fileID, $data); }; @@ -113,21 +261,20 @@ class FlysystemAssetStore implements AssetStore { } // Submit to conflict check - return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution); + return $this->writeWithCallback($callback, $filename, $hash, $variant, $config); } - public function setFromStream($stream, $filename, $hash = null, $variant = null, $conflictResolution = null) { + public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) { // If the stream isn't rewindable, write to a temporary filename if(!$this->isSeekableStream($stream)) { $path = $this->getStreamAsFile($stream); - $result = $this->setFromLocalFile($path, $filename, $hash, $variant, $conflictResolution); + $result = $this->setFromLocalFile($path, $filename, $hash, $variant, $config); unlink($path); return $result; } // Callback for saving content - $filesystem = $this->getFilesystem(); - $callback = function($fileID) use ($filesystem, $stream) { + $callback = function(Filesystem $filesystem, $fileID) use ($stream) { return $filesystem->putStream($fileID, $stream); }; @@ -137,7 +284,144 @@ class FlysystemAssetStore implements AssetStore { } // Submit to conflict check - return $this->writeWithCallback($callback, $filename, $hash, $variant, $conflictResolution); + return $this->writeWithCallback($callback, $filename, $hash, $variant, $config); + } + + public function delete($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + $protected = $this->deleteFromFilesystem($fileID, $this->getProtectedFilesystem()); + $public = $this->deleteFromFilesystem($fileID, $this->getPublicFilesystem()); + return $protected || $public; + } + + /** + * Delete the given file (and any variants) in the given {@see Filesystem} + * + * @param string $fileID + * @param Filesystem $filesystem + * @return bool True if a file was deleted + */ + protected function deleteFromFilesystem($fileID, Filesystem $filesystem) { + $deleted = false; + foreach($this->findVariants($fileID, $filesystem) as $nextID) { + $filesystem->delete($nextID); + $deleted = true; + } + + // Truncate empty dirs + $this->truncateDirectory(dirname($fileID), $filesystem); + + return $deleted; + } + + /** + * Clear directory if it's empty + * + * @param string $dirname Name of directory + * @param Filesystem $filesystem + */ + protected function truncateDirectory($dirname, Filesystem $filesystem) { + if ($dirname + && ! Config::inst()->get(get_class($this), 'keep_empty_dirs') + && ! $filesystem->listContents($dirname) + ) { + $filesystem->deleteDir($dirname); + } + } + + /** + * Returns an iterable {@see Generator} of all files / variants for the given $fileID in the given $filesystem + * This includes the empty (no) variant. + * + * @param string $fileID ID of original file to compare with. + * @param Filesystem $filesystem + * @return Generator + */ + protected function findVariants($fileID, Filesystem $filesystem) { + foreach($filesystem->listContents(dirname($fileID)) as $next) { + if($next['type'] !== 'file') { + continue; + } + $nextID = $next['path']; + // Compare given file to target, omitting variant + if($fileID === $this->removeVariant($nextID)) { + yield $nextID; + } + } + } + + public function publish($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + $protected = $this->getProtectedFilesystem(); + $public = $this->getPublicFilesystem(); + $this->moveBetweenFilesystems($fileID, $protected, $public); + } + + public function protect($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + $public = $this->getPublicFilesystem(); + $protected = $this->getProtectedFilesystem(); + $this->moveBetweenFilesystems($fileID, $public, $protected); + } + + /** + * Move a file (and its associative variants) between filesystems + * + * @param string $fileID + * @param Filesystem $from + * @param Filesystem $to + */ + protected function moveBetweenFilesystems($fileID, Filesystem $from, Filesystem $to) { + foreach($this->findVariants($fileID, $from) as $nextID) { + // Copy via stream + $stream = $from->readStream($nextID); + $to->putStream($nextID, $stream); + fclose($stream); + $from->delete($nextID); + } + + // Truncate empty dirs + $this->truncateDirectory(dirname($fileID), $from); + } + + public function grant($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + $granted = Session::get(self::GRANTS_SESSION) ?: array(); + $granted[$fileID] = true; + Session::set(self::GRANTS_SESSION, $granted); + } + + public function revoke($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + $granted = Session::get(self::GRANTS_SESSION) ?: array(); + unset($granted[$fileID]); + if($granted) { + Session::set(self::GRANTS_SESSION, $granted); + } else { + Session::clear(self::GRANTS_SESSION); + } + } + + public function canView($filename, $hash) { + $fileID = $this->getFileID($filename, $hash); + if($this->getProtectedFilesystem()->has($fileID)) { + return $this->isGranted($fileID); + } + return true; + } + + /** + * Determine if a grant exists for the given FileID + * + * @param string $fileID + * @return bool + */ + protected function isGranted($fileID) { + // Since permissions are applied to the non-variant only, + // map back to the original file before checking + $originalID = $this->removeVariant($fileID); + $granted = Session::get(self::GRANTS_SESSION) ?: array(); + return !empty($granted[$originalID]); } /** @@ -158,21 +442,22 @@ class FlysystemAssetStore implements AssetStore { * * @param resource $stream * @return string Filename of resulting stream content + * @throws Exception */ protected function getStreamAsFile($stream) { // Get temporary file and name $file = tempnam(sys_get_temp_dir(), 'ssflysystem'); $buffer = fopen($file, 'w'); - if (!$buffer) { - throw new Exception("Could not create temporary file"); - } + if (!$buffer) { + throw new Exception("Could not create temporary file"); + } // Transfer from given stream Util::rewindStream($stream); - stream_copy_to_stream($stream, $buffer); - if (! fclose($buffer)) { - throw new Exception("Could not write stream to temporary file"); - } + stream_copy_to_stream($stream, $buffer); + if (! fclose($buffer)) { + throw new Exception("Could not write stream to temporary file"); + } return $file; } @@ -195,14 +480,16 @@ class FlysystemAssetStore implements AssetStore { * @param string $filename Name for the resulting file * @param string $hash SHA1 of the original file content * @param string $variant Variant to write - * @param string $conflictResolution {@see AssetStore}. Will default to one chosen by the backend + * @param array $config Write options. {@see AssetStore} * @return array Tuple associative array (Filename, Hash, Variant) * @throws Exception */ - protected function writeWithCallback($callback, $filename, $hash, $variant = null, $conflictResolution = null) { + protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = array()) { // Set default conflict resolution - if(!$conflictResolution) { + if(empty($config['conflict'])) { $conflictResolution = $this->getDefaultConflictResolution($variant); + } else { + $conflictResolution = $config['conflict']; } // Validate parameters @@ -223,8 +510,22 @@ class FlysystemAssetStore implements AssetStore { // Check conflict resolution scheme $resolvedID = $this->resolveConflicts($conflictResolution, $fileID); if($resolvedID !== false) { + // Check if source file already exists on the filesystem + $mainID = $this->getFileID($filename, $hash); + $filesystem = $this->getFilesystemFor($mainID); + + // If writing a new file use the correct visibility + if(!$filesystem) { + // Default to public store unless requesting protected store + if(isset($config['visibility']) && $config['visibility'] === self::VISIBILITY_PROTECTED) { + $filesystem = $this->getProtectedFilesystem(); + } else { + $filesystem = $this->getPublicFilesystem(); + } + } + // Submit and validate result - $result = $callback($resolvedID); + $result = $callback($filesystem, $resolvedID); if(!$result) { throw new Exception("Could not save {$filename}"); } @@ -233,10 +534,10 @@ class FlysystemAssetStore implements AssetStore { $filename = $this->getOriginalFilename($resolvedID); } elseif(empty($variant)) { - // If defering to the existing file, return the sha of the existing file, + // If deferring to the existing file, return the sha of the existing file, // unless we are writing a variant (which has the same hash value as its original file) $stream = $this - ->getFilesystem() + ->getFilesystemFor($fileID) ->readStream($fileID); $hash = $this->getStreamSHA1($stream); } @@ -277,17 +578,26 @@ class FlysystemAssetStore implements AssetStore { public function getMetadata($filename, $hash, $variant = null) { $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->getMetadata($fileID); + $filesystem = $this->getFilesystemFor($fileID); + if($filesystem) { + return $filesystem->getMetadata($fileID); + } + return null; } public function getMimeType($filename, $hash, $variant = null) { $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->getMimetype($fileID); + $filesystem = $this->getFilesystemFor($fileID); + if($filesystem) { + return $filesystem->getMimetype($fileID); + } + return null; } public function exists($filename, $hash, $variant = null) { $fileID = $this->getFileID($filename, $hash, $variant); - return $this->getFilesystem()->has($fileID); + $filesystem = $this->getFilesystemFor($fileID); + return !empty($filesystem); } /** @@ -305,7 +615,7 @@ class FlysystemAssetStore implements AssetStore { } // Otherwise, check if this exists - $exists = $this->getFilesystem()->has($fileID); + $exists = $this->getFilesystemFor($fileID); if(!$exists) { return $fileID; } @@ -313,15 +623,14 @@ class FlysystemAssetStore implements AssetStore { // Flysystem defaults to use_existing switch($conflictResolution) { // Throw tantrum - case AssetStore::CONFLICT_EXCEPTION: { + case static::CONFLICT_EXCEPTION: { throw new \InvalidArgumentException("File already exists at path {$fileID}"); } // Rename - case AssetStore::CONFLICT_RENAME: { + case static::CONFLICT_RENAME: { foreach($this->fileGeneratorFor($fileID) as $candidate) { - // @todo better infinite loop breaking - if(!$this->getFilesystem()->has($candidate)) { + if(!$this->getFilesystemFor($candidate)) { return $candidate; } } @@ -330,7 +639,7 @@ class FlysystemAssetStore implements AssetStore { } // Use existing file - case AssetStore::CONFLICT_USE_EXISTING: + case static::CONFLICT_USE_EXISTING: default: { return false; } @@ -340,7 +649,7 @@ class FlysystemAssetStore implements AssetStore { /** * Get an asset renamer for the given filename. * - * @param string $fileID Adaptor specific identifier for this file/version + * @param string $fileID Adapter specific identifier for this file/version * @return AssetNameGenerator */ protected function fileGeneratorFor($fileID){ @@ -361,33 +670,42 @@ class FlysystemAssetStore implements AssetStore { } /** - * Given a FileID, map this back to the original filename, trimming variant + * Given a FileID, map this back to the original filename, trimming variant and hash * - * @param string $fileID Adaptor specific identifier for this file/version - * @param string $variant Out parameter for any found variant - * @return string + * @param string $fileID Adapter specific identifier for this file/version + * @return string Filename for this file, omitting hash and variant */ - protected function getOriginalFilename($fileID, &$variant = '') { + protected function getOriginalFilename($fileID) { // Remove variant - $original = $fileID; - $variant = ''; - if(preg_match('/^(?((?[^\\.]+)(?.*)$/', $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( '/(?[a-zA-Z0-9]{10}\\/)(?[^\\/]+)$/', '$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('/^(?((?[^\\.]+)(?.*)$/', $fileID, $matches)) { + return $matches['before'] . $matches['after']; + } + // There is no variant, so return original value + return $fileID; + } + /** * Map file tuple (hash, name, variant) to a filename to be used by flysystem * @@ -396,7 +714,7 @@ class FlysystemAssetStore implements AssetStore { * @param string $filename Name of file * @param string $hash Hash of original file * @param string $variant (if given) - * @return string Adaptor specific identifier for this file/version + * @return string Adapter specific identifier for this file/version */ protected function getFileID($filename, $hash, $variant = null) { // Since we use double underscore to delimit variants, eradicate them from filename @@ -411,7 +729,7 @@ class FlysystemAssetStore implements AssetStore { } // Unless in legacy mode, inject hash just prior to the filename - if(Config::inst()->get(__CLASS__, 'legacy_filenames')) { + if($this->useLegacyFilenames()) { $fileID = $name; } else { $fileID = substr($hash, 0, 10) . '/' . $name; @@ -436,4 +754,102 @@ class FlysystemAssetStore implements AssetStore { return $fileID; } + /** + * Ensure each adapter re-generates its own server configuration files + */ + public static function flush() { + // Ensure that this instance is constructed on flush, thus forcing + // bootstrapping of necessary .htaccess / web.config files + $instance = singleton('AssetStore'); + if ($instance instanceof FlysystemAssetStore) { + $publicAdapter = $instance->getPublicFilesystem()->getAdapter(); + if($publicAdapter instanceof AssetAdapter) { + $publicAdapter->flush(); + } + $protectedAdapter = $instance->getProtectedFilesystem()->getAdapter(); + if($protectedAdapter instanceof AssetAdapter) { + $protectedAdapter->flush(); + } + } + } + + public function getResponseFor($asset) { + // Check if file exists + $filesystem = $this->getFilesystemFor($asset); + if(!$filesystem) { + return $this->createMissingResponse(); + } + + // Block directory access + if($filesystem->get($asset) instanceof Directory) { + return $this->createDeniedResponse(); + } + + // Deny if file is protected and denied + if($filesystem === $this->getProtectedFilesystem() && !$this->isGranted($asset)) { + return $this->createDeniedResponse(); + } + + // Serve up file response + return $this->createResponseFor($filesystem, $asset); + } + + /** + * Generate an {@see SS_HTTPResponse} for the given file from the source filesystem + * @param FilesystemInterface $flysystem + * @param string $fileID + * @return SS_HTTPResponse + */ + protected function createResponseFor(FilesystemInterface $flysystem, $fileID) { + // Build response body + // @todo: gzip / buffer response? + $body = $flysystem->read($fileID); + $mime = $flysystem->getMimetype($fileID); + $response = new SS_HTTPResponse($body, 200); + + // Add headers + $response->addHeader('Content-Type', $mime); + $headers = Config::inst()->get(get_class($this), 'file_response_headers'); + foreach($headers as $header => $value) { + $response->addHeader($header, $value); + } + return $response; + } + + /** + * Generate a response for requests to a denied protected file + * + * @return SS_HTTPResponse + */ + protected function createDeniedResponse() { + $code = (int)Config::inst()->get(get_class($this), 'denied_response_code'); + return $this->createErrorResponse($code); + } + + /** + * Generate a response for missing file requests + * + * @return SS_HTTPResponse + */ + protected function createMissingResponse() { + $code = (int)Config::inst()->get(get_class($this), 'missing_response_code'); + return $this->createErrorResponse($code); + } + + /** + * Create a response with the given error code + * + * @param int $code + * @return SS_HTTPResponse + */ + protected function createErrorResponse($code) { + $response = new SS_HTTPResponse('', $code); + + // Show message in dev + if(!\Director::isLive()) { + $response->setBody($response->getStatusDescription()); + } + + return $response; + } } diff --git a/filesystem/flysystem/FlysystemGeneratedAssetHandler.php b/filesystem/flysystem/FlysystemGeneratedAssetHandler.php index 5606e072d..9baff7e62 100644 --- a/filesystem/flysystem/FlysystemGeneratedAssetHandler.php +++ b/filesystem/flysystem/FlysystemGeneratedAssetHandler.php @@ -46,6 +46,7 @@ class FlysystemGeneratedAssetHandler implements GeneratedAssetHandler { if($result) { return $this ->getFilesystem() + ->getAdapter() ->getPublicUrl($filename); } } diff --git a/filesystem/flysystem/FlysystemUrlPlugin.php b/filesystem/flysystem/FlysystemUrlPlugin.php deleted file mode 100644 index ddd6fd23e..000000000 --- a/filesystem/flysystem/FlysystemUrlPlugin.php +++ /dev/null @@ -1,73 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/filesystem/flysystem/ProtectedAdapter.php b/filesystem/flysystem/ProtectedAdapter.php new file mode 100644 index 000000000..5841baef7 --- /dev/null +++ b/filesystem/flysystem/ProtectedAdapter.php @@ -0,0 +1,19 @@ + 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); + } +} diff --git a/filesystem/flysystem/PublicAdapter.php b/filesystem/flysystem/PublicAdapter.php new file mode 100644 index 000000000..d58e991c6 --- /dev/null +++ b/filesystem/flysystem/PublicAdapter.php @@ -0,0 +1,19 @@ + 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; + } +} \ No newline at end of file diff --git a/filesystem/storage/AssetContainer.php b/filesystem/storage/AssetContainer.php index cea738203..c853536aa 100644 --- a/filesystem/storage/AssetContainer.php +++ b/filesystem/storage/AssetContainer.php @@ -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(); } diff --git a/filesystem/storage/AssetStore.php b/filesystem/storage/AssetStore.php index 632300489..41981ec74 100644 --- a/filesystem/storage/AssetStore.php +++ b/filesystem/storage/AssetStore.php @@ -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); } diff --git a/filesystem/storage/AssetStoreRouter.php b/filesystem/storage/AssetStoreRouter.php new file mode 100644 index 000000000..465d74e05 --- /dev/null +++ b/filesystem/storage/AssetStoreRouter.php @@ -0,0 +1,22 @@ +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); + } } diff --git a/filesystem/storage/ProtectedFileController.php b/filesystem/storage/ProtectedFileController.php new file mode 100644 index 000000000..118d9aa8f --- /dev/null +++ b/filesystem/storage/ProtectedFileController.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/model/DataObject.php b/model/DataObject.php index 35c2b2f46..64a3491e0 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -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. */ diff --git a/model/Image_Backend.php b/model/Image_Backend.php index 406b4599a..9fbc9806d 100644 --- a/model/Image_Backend.php +++ b/model/Image_Backend.php @@ -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 diff --git a/model/ValidationResult.php b/model/ValidationResult.php index 75cdee244..6b6dce155 100644 --- a/model/ValidationResult.php +++ b/model/ValidationResult.php @@ -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; diff --git a/model/Versioned.php b/model/Versioned.php index e6fba50e2..63c46c4fb 100644 --- a/model/Versioned.php +++ b/model/Versioned.php @@ -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); diff --git a/templates/filesystem/Assets_HTAccess.ss b/templates/filesystem/Assets_HTAccess.ss new file mode 100644 index 000000000..3e76eedfe --- /dev/null +++ b/templates/filesystem/Assets_HTAccess.ss @@ -0,0 +1,27 @@ +# +# Whitelist appropriate assets files. +# This file is automatically generated via File.allowed_extensions configuration +# See AssetAdapter::renderTemplate() for reference. +# + + + 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] + diff --git a/templates/filesystem/Assets_WebConfig.ss b/templates/filesystem/Assets_WebConfig.ss new file mode 100644 index 000000000..18cc41ca8 --- /dev/null +++ b/templates/filesystem/Assets_WebConfig.ss @@ -0,0 +1,29 @@ + + + + + + + + <% loop $AllowedExtensions %> + + <% end_loop %> + + + + + + + + + + + + + + + + diff --git a/templates/filesystem/Protected_HTAccess.ss b/templates/filesystem/Protected_HTAccess.ss new file mode 100644 index 000000000..e324f8bb9 --- /dev/null +++ b/templates/filesystem/Protected_HTAccess.ss @@ -0,0 +1,2 @@ +Deny from all +RewriteRule .* - [F] diff --git a/templates/filesystem/Protected_WebConfig.ss b/templates/filesystem/Protected_WebConfig.ss new file mode 100644 index 000000000..bce81c05b --- /dev/null +++ b/templates/filesystem/Protected_WebConfig.ss @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/tests/filesystem/AssetControlExtensionTest.php b/tests/filesystem/AssetControlExtensionTest.php new file mode 100644 index 000000000..0744d54e9 --- /dev/null +++ b/tests/filesystem/AssetControlExtensionTest.php @@ -0,0 +1,154 @@ +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; +} diff --git a/tests/filesystem/AssetStoreTest.php b/tests/filesystem/AssetStoreTest.php index c000cf175..dfaa28a3d 100644 --- a/tests/filesystem/AssetStoreTest.php +++ b/tests/filesystem/AssetStoreTest.php @@ -1,10 +1,12 @@ assertEquals( - '/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg', + '/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg', $backend->getAsURL($fish1Tuple['Filename'], $fish1Tuple['Hash']) ); // Write a different file with same name. Should not detect duplicates since sha are different $fish2 = realpath(__DIR__ .'/../model/testimages/test-image-low-quality.jpg'); try { - $fish2Tuple = $backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_EXCEPTION); + $fish2Tuple = $backend->setFromLocalFile( + $fish2, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_EXCEPTION) + ); } catch(Exception $ex) { return $this->fail('Writing file with different sha to same location failed with exception'); } @@ -119,13 +127,19 @@ class AssetStoreTest extends SapphireTest { $fish2Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/33be1b95cb/lovely-fish.jpg', + '/assets/AssetStoreTest/directory/33be1b95cb/lovely-fish.jpg', $backend->getAsURL($fish2Tuple['Filename'], $fish2Tuple['Hash']) ); // Write original file back with rename $this->assertFileExists($fish1); - $fish3Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_RENAME); + $fish3Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_RENAME) + ); $this->assertEquals( array( 'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1', @@ -135,12 +149,18 @@ class AssetStoreTest extends SapphireTest { $fish3Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/a870de278b/lovely-fish-v2.jpg', + '/assets/AssetStoreTest/directory/a870de278b/lovely-fish-v2.jpg', $backend->getAsURL($fish3Tuple['Filename'], $fish3Tuple['Hash']) ); // Write another file should increment to -v3 - $fish4Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_RENAME); + $fish4Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish-v2.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_RENAME) + ); $this->assertEquals( array( 'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1', @@ -150,12 +170,18 @@ class AssetStoreTest extends SapphireTest { $fish4Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/a870de278b/lovely-fish-v3.jpg', + '/assets/AssetStoreTest/directory/a870de278b/lovely-fish-v3.jpg', $backend->getAsURL($fish4Tuple['Filename'], $fish4Tuple['Hash']) ); // Test conflict use existing file - $fish5Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_USE_EXISTING); + $fish5Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_USE_EXISTING) + ); $this->assertEquals( array( 'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1', @@ -165,12 +191,18 @@ class AssetStoreTest extends SapphireTest { $fish5Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg', + '/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg', $backend->getAsURL($fish5Tuple['Filename'], $fish5Tuple['Hash']) ); // Test conflict use existing file - $fish6Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_OVERWRITE); + $fish6Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_OVERWRITE) + ); $this->assertEquals( array( 'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1', @@ -180,7 +212,7 @@ class AssetStoreTest extends SapphireTest { $fish6Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/a870de278b/lovely-fish.jpg', + '/assets/AssetStoreTest/directory/a870de278b/lovely-fish.jpg', $backend->getAsURL($fish6Tuple['Filename'], $fish6Tuple['Hash']) ); } @@ -192,44 +224,36 @@ class AssetStoreTest extends SapphireTest { $store = new AssetStoreTest_SpyStore(); $this->assertEquals( 'directory/lovely-fish.jpg', - $store->getOriginalFilename('directory/a870de278b/lovely-fish.jpg', $variant) + $store->getOriginalFilename('directory/a870de278b/lovely-fish.jpg') ); - $this->assertEmpty($variant); $this->assertEquals( 'directory/lovely-fish.jpg', - $store->getOriginalFilename('directory/a870de278b/lovely-fish__variant.jpg', $variant) + $store->getOriginalFilename('directory/a870de278b/lovely-fish__variant.jpg') ); - $this->assertEquals('variant', $variant); $this->assertEquals( 'directory/lovely_fish.jpg', - $store->getOriginalFilename('directory/a870de278b/lovely_fish__vari_ant.jpg', $variant) + $store->getOriginalFilename('directory/a870de278b/lovely_fish__vari_ant.jpg') ); - $this->assertEquals('vari_ant', $variant); $this->assertEquals( 'directory/lovely_fish.jpg', - $store->getOriginalFilename('directory/a870de278b/lovely_fish.jpg', $variant) + $store->getOriginalFilename('directory/a870de278b/lovely_fish.jpg') ); - $this->assertEmpty($variant); $this->assertEquals( 'lovely-fish.jpg', - $store->getOriginalFilename('a870de278b/lovely-fish.jpg', $variant) + $store->getOriginalFilename('a870de278b/lovely-fish.jpg') ); - $this->assertEmpty($variant); $this->assertEquals( 'lovely-fish.jpg', - $store->getOriginalFilename('a870de278b/lovely-fish__variant.jpg', $variant) + $store->getOriginalFilename('a870de278b/lovely-fish__variant.jpg') ); - $this->assertEquals('variant', $variant); $this->assertEquals( 'lovely_fish.jpg', - $store->getOriginalFilename('a870de278b/lovely_fish__vari__ant.jpg', $variant) + $store->getOriginalFilename('a870de278b/lovely_fish__vari__ant.jpg') ); - $this->assertEquals('vari__ant', $variant); $this->assertEquals( 'lovely_fish.jpg', - $store->getOriginalFilename('a870de278b/lovely_fish.jpg', $variant) + $store->getOriginalFilename('a870de278b/lovely_fish.jpg') ); - $this->assertEmpty($variant); } /** @@ -278,7 +302,6 @@ class AssetStoreTest extends SapphireTest { $this->assertEquals('file', $fishMeta['type']); $this->assertNotEmpty($fishMeta['timestamp']); - // text $puppies = 'puppies'; $puppiesTuple = $backend->setFromString($puppies, 'pets/my-puppy.txt'); @@ -313,7 +336,7 @@ class AssetStoreTest extends SapphireTest { $fish1Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/lovely-fish.jpg', + '/assets/AssetStoreTest/directory/lovely-fish.jpg', $backend->getAsURL($fish1Tuple['Filename'], $fish1Tuple['Hash']) ); @@ -321,14 +344,26 @@ class AssetStoreTest extends SapphireTest { // Since we are using legacy filenames, this should generate a new filename $fish2 = realpath(__DIR__ .'/../model/testimages/test-image-low-quality.jpg'); try { - $backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_EXCEPTION); + $backend->setFromLocalFile( + $fish2, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_EXCEPTION) + ); return $this->fail('Writing file with different sha to same location should throw exception'); } catch(Exception $ex) { // Success } // Re-attempt this file write with conflict_rename - $fish3Tuple = $backend->setFromLocalFile($fish2, 'directory/lovely-fish.jpg', null, null, AssetStore::CONFLICT_RENAME); + $fish3Tuple = $backend->setFromLocalFile( + $fish2, + 'directory/lovely-fish.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_RENAME) + ); $this->assertEquals( array( 'Hash' => '33be1b95cba0358fe54e8b13532162d52f97421c', @@ -338,12 +373,18 @@ class AssetStoreTest extends SapphireTest { $fish3Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/lovely-fish-v2.jpg', + '/assets/AssetStoreTest/directory/lovely-fish-v2.jpg', $backend->getAsURL($fish3Tuple['Filename'], $fish3Tuple['Hash']) ); // Write back original file, but with CONFLICT_EXISTING. The file should not change - $fish4Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_USE_EXISTING); + $fish4Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish-v2.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_USE_EXISTING) + ); $this->assertEquals( array( 'Hash' => '33be1b95cba0358fe54e8b13532162d52f97421c', @@ -353,12 +394,18 @@ class AssetStoreTest extends SapphireTest { $fish4Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/lovely-fish-v2.jpg', + '/assets/AssetStoreTest/directory/lovely-fish-v2.jpg', $backend->getAsURL($fish4Tuple['Filename'], $fish4Tuple['Hash']) ); // Write back original file with CONFLICT_OVERWRITE. The file sha should now be updated - $fish5Tuple = $backend->setFromLocalFile($fish1, 'directory/lovely-fish-v2.jpg', null, null, AssetStore::CONFLICT_OVERWRITE); + $fish5Tuple = $backend->setFromLocalFile( + $fish1, + 'directory/lovely-fish-v2.jpg', + null, + null, + array('conflict' => AssetStore::CONFLICT_OVERWRITE) + ); $this->assertEquals( array( 'Hash' => 'a870de278b475cb75f5d9f451439b2d378e13af1', @@ -368,7 +415,7 @@ class AssetStoreTest extends SapphireTest { $fish5Tuple ); $this->assertEquals( - '/assets/DBFileTest/directory/lovely-fish-v2.jpg', + '/assets/AssetStoreTest/directory/lovely-fish-v2.jpg', $backend->getAsURL($fish5Tuple['Filename'], $fish5Tuple['Hash']) ); } @@ -389,6 +436,74 @@ class AssetStoreTest extends SapphireTest { $this->assertEquals(AssetStore::CONFLICT_RENAME, $store->getDefaultConflictResolution(null)); $this->assertEquals(AssetStore::CONFLICT_OVERWRITE, $store->getDefaultConflictResolution('somevariant')); } + + /** + * Test protect / publish mechanisms + */ + public function testProtect() { + $backend = $this->getBackend(); + $fish = realpath(__DIR__ .'/../model/testimages/test-image-high-quality.jpg'); + $fishTuple = $backend->setFromLocalFile($fish, 'parent/lovely-fish.jpg'); + $fishVariantTuple = $backend->setFromLocalFile($fish, $fishTuple['Filename'], $fishTuple['Hash'], 'copy'); + + // Test public file storage + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg'); + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg'); + $this->assertEquals( + AssetStore::VISIBILITY_PUBLIC, + $backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash']) + ); + $this->assertEquals( + '/assets/AssetStoreTest/parent/a870de278b/lovely-fish.jpg', + $backend->getAsURL($fishTuple['Filename'], $fishTuple['Hash']) + ); + $this->assertEquals( + '/assets/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg', + $backend->getAsURL($fishVariantTuple['Filename'], $fishVariantTuple['Hash'], $fishVariantTuple['Variant']) + ); + + // Test access rights to public files cannot be revoked + $backend->revoke($fishTuple['Filename'], $fishTuple['Hash']); // can't revoke public assets + $this->assertTrue($backend->canView($fishTuple['Filename'], $fishTuple['Hash'])); + + // Test protected file storage + $backend->protect($fishTuple['Filename'], $fishTuple['Hash']); + $this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg'); + $this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg'); + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish.jpg'); + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish__copy.jpg'); + $this->assertEquals( + AssetStore::VISIBILITY_PROTECTED, + $backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash']) + ); + + // Test access rights + $backend->revoke($fishTuple['Filename'], $fishTuple['Hash']); + $this->assertFalse($backend->canView($fishTuple['Filename'], $fishTuple['Hash'])); + $backend->grant($fishTuple['Filename'], $fishTuple['Hash']); + $this->assertTrue($backend->canView($fishTuple['Filename'], $fishTuple['Hash'])); + + // Protected urls should go through asset routing mechanism + $this->assertEquals( + '/assets/parent/a870de278b/lovely-fish.jpg', + $backend->getAsURL($fishTuple['Filename'], $fishTuple['Hash']) + ); + $this->assertEquals( + '/assets/parent/a870de278b/lovely-fish__copy.jpg', + $backend->getAsURL($fishVariantTuple['Filename'], $fishVariantTuple['Hash'], $fishVariantTuple['Variant']) + ); + + // Publish reverts visibility + $backend->publish($fishTuple['Filename'], $fishTuple['Hash']); + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish.jpg'); + $this->assertFileExists(ASSETS_PATH . '/AssetStoreTest/parent/a870de278b/lovely-fish__copy.jpg'); + $this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish.jpg'); + $this->assertFileNotExists(ASSETS_PATH . '/AssetStoreTest/.protected/parent/a870de278b/lovely-fish__copy.jpg'); + $this->assertEquals( + AssetStore::VISIBILITY_PUBLIC, + $backend->getVisibility($fishTuple['Filename'], $fishTuple['Hash']) + ); + } } /** @@ -396,6 +511,14 @@ class AssetStoreTest extends SapphireTest { */ class AssetStoreTest_SpyStore extends FlysystemAssetStore { + /** + * Enable disclosure of secure assets + * + * @config + * @var int + */ + private static $denied_response_code = 403; + /** * Set to true|false to override all isSeekableStream calls * @@ -417,16 +540,23 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { */ public static function activate($basedir) { // Assign this as the new store - $adapter = new AssetAdapter(ASSETS_PATH . '/' . $basedir); - $filesystem = new Filesystem($adapter); - $filesystem->addPlugin(new FlysystemUrlPlugin()); + $publicAdapter = new PublicAssetAdapter(ASSETS_PATH . '/' . $basedir); + $publicFilesystem = new Filesystem($publicAdapter, [ + 'visibility' => AdapterInterface::VISIBILITY_PUBLIC + ]); + $protectedAdapter = new ProtectedAssetAdapter(ASSETS_PATH . '/' . $basedir . '/.protected'); + $protectedFilesystem = new Filesystem($protectedAdapter, [ + 'visibility' => AdapterInterface::VISIBILITY_PRIVATE + ]); + $backend = new AssetStoreTest_SpyStore(); - $backend->setFilesystem($filesystem); + $backend->setPublicFilesystem($publicFilesystem); + $backend->setProtectedFilesystem($protectedFilesystem); Injector::inst()->registerService($backend, 'AssetStore'); // Assign flysystem backend to generated asset handler at the same time $generated = new FlysystemGeneratedAssetHandler(); - $generated->setFilesystem($filesystem); + $generated->setFilesystem($publicFilesystem); Injector::inst()->registerService($generated, 'GeneratedAssetHandler'); Requirements::backend()->setAssetHandler($generated); @@ -439,7 +569,7 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { self::$basedir = $basedir; // Ensure basedir exists - SS_Filesystem::makeFolder(self::base_path()); + \Filesystem::makeFolder(self::base_path()); } /** @@ -461,7 +591,7 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { if(self::$basedir) { $path = self::base_path(); if(file_exists($path)) { - SS_Filesystem::removeFolder($path); + \Filesystem::removeFolder($path); } } self::$seekable_override = null; @@ -480,10 +610,18 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { if($asset instanceof File) { $asset = $asset->File; } - if($asset instanceof DBFile) { - return BASE_PATH . $asset->getSourceURL(); + // Extract filesystem used to store this object + /** @var AssetStoreTest_SpyStore $assetStore */ + $assetStore = Injector::inst()->get('AssetStore'); + $fileID = $assetStore->getFileID($asset->Filename, $asset->Hash, $asset->Variant); + $filesystem = $assetStore->getProtectedFilesystem(); + if(!$filesystem->has($fileID)) { + $filesystem = $assetStore->getPublicFilesystem(); } - return BASE_PATH . $asset->getUrl(); + /** @var Local $adapter */ + $adapter = $filesystem->getAdapter(); + return $adapter->applyPathPrefix($fileID); + } public function cleanFilename($filename) { @@ -495,8 +633,12 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { } - public function getOriginalFilename($fileID, &$variant = '') { - return parent::getOriginalFilename($fileID, $variant); + public function getOriginalFilename($fileID) { + return parent::getOriginalFilename($fileID); + } + + public function removeVariant($fileID) { + return parent::removeVariant($fileID); } public function getDefaultConflictResolution($variant) { diff --git a/tests/filesystem/FileTest.php b/tests/filesystem/FileTest.php index 5162c5b1d..01424d717 100644 --- a/tests/filesystem/FileTest.php +++ b/tests/filesystem/FileTest.php @@ -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() { diff --git a/tests/filesystem/ProtectedFileControllerTest.php b/tests/filesystem/ProtectedFileControllerTest.php new file mode 100644 index 000000000..a712c167c --- /dev/null +++ b/tests/filesystem/ProtectedFileControllerTest.php @@ -0,0 +1,201 @@ +exclude('ClassName', 'Folder') as $file) { + /** @var File $file */ + $path = AssetStoreTest_SpyStore::getLocalPath($file); + \Filesystem::makeFolder(dirname($path)); + $fh = fopen($path, "w+"); + fwrite($fh, str_repeat('x', 1000000)); + fclose($fh); + + // Create variant for each file + $this->getAssetStore()->setFromString( + str_repeat('y', 100), + $file->Filename, + $file->Hash, + 'variant' + ); + } + } + + /** + * @dataProvider getFilenames + */ + public function testIsValidFilename($name, $isValid) { + $controller = new ProtectedFileController(); + $this->assertEquals( + $isValid, + $controller->isValidFilename($name), + "Assert filename \"$name\" is " . $isValid ? "valid" : "invalid" + ); + } + + public function getFilenames() { + return array( + // Valid names + array('name.jpg', true), + array('parent/name.jpg', true), + array('parent/name', true), + array('parent\name.jpg', true), + array('parent\name', true), + array('name', true), + + // Invalid names + array('.invalid/name.jpg', false), + array('.invalid\name.jpg', false), + array('.htaccess', false), + array('test/.htaccess.jpg', false), + array('name/.jpg', false), + array('test\.htaccess.jpg', false), + array('name\.jpg', false) + ); + } + + /** + * Test that certain requests are denied + */ + public function testInvalidRequest() { + $result = $this->get('assets/.protected/file.jpg'); + $this->assertResponseEquals(400, null, $result); + } + + /** + * Test that invalid files generate 404 response + */ + public function testFileNotFound() { + $result = $this->get('assets/missing.jpg'); + $this->assertResponseEquals(404, null, $result); + } + + /** + * Check public access to assets is available at the appropriate time + */ + public function testAccessControl() { + $expectedContent = str_repeat('x', 1000000); + $variantContent = str_repeat('y', 100); + + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(200, $expectedContent, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(200, $variantContent, $result); + + // Make this file protected + $this->getAssetStore()->protect( + 'FileTest.txt', + '55b443b60176235ef09801153cca4e6da7494a0c' + ); + + // Should now return explicitly denied errors + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(403, null, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(403, null, $result); + + // Other assets remain available + $result = $this->get('assets/55b443b601/FileTest.pdf'); + $this->assertResponseEquals(200, $expectedContent, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.pdf'); + $this->assertResponseEquals(200, $variantContent, $result); + + // granting access will allow access + $this->getAssetStore()->grant( + 'FileTest.txt', + '55b443b60176235ef09801153cca4e6da7494a0c' + ); + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(200, $expectedContent, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(200, $variantContent, $result); + + // Revoking access will remove access again + $this->getAssetStore()->revoke( + 'FileTest.txt', + '55b443b60176235ef09801153cca4e6da7494a0c' + ); + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(403, null, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(403, null, $result); + + // Moving file back to public store restores access + $this->getAssetStore()->publish( + 'FileTest.txt', + '55b443b60176235ef09801153cca4e6da7494a0c' + ); + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(200, $expectedContent, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(200, $variantContent, $result); + + // Deleting the file will make the response 404 + $this->getAssetStore()->delete( + 'FileTest.txt', + '55b443b60176235ef09801153cca4e6da7494a0c' + ); + $result = $this->get('assets/55b443b601/FileTest.txt'); + $this->assertResponseEquals(404, null, $result); + $result = $this->get('assets/55b443b601/FileTest__variant.txt'); + $this->assertResponseEquals(404, null, $result); + } + + /** + * Test that access to folders is not permitted + */ + public function testFolders() { + $result = $this->get('assets/55b443b601'); + $this->assertResponseEquals(403, null, $result); + + $result = $this->get('assets/FileTest-subfolder'); + $this->assertResponseEquals(403, null, $result); + + $result = $this->get('assets'); + $this->assertResponseEquals(403, null, $result); + } + + /** + * @return AssetStore + */ + protected function getAssetStore() { + return singleton('AssetStore'); + } + + /** + * Assert that a response matches the given parameters + * + * @param int $code HTTP code + * @param string $body Body expected for 200 responses + * @param SS_HTTPResponse $response + */ + protected function assertResponseEquals($code, $body, SS_HTTPResponse $response) { + $this->assertEquals($code, $response->getStatusCode()); + if($code === 200) { + $this->assertFalse($response->isError()); + $this->assertEquals($body, $response->getBody()); + $this->assertEquals('text/plain', $response->getHeader('Content-Type')); + } else { + $this->assertTrue($response->isError()); + } + } + +} \ No newline at end of file diff --git a/tests/forms/DBFileTest.php b/tests/forms/DBFileTest.php index 2b53a7773..252968491 100644 --- a/tests/forms/DBFileTest.php +++ b/tests/forms/DBFileTest.php @@ -1,4 +1,5 @@ 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')"