silverstripe-framework/filesystem/AssetControlExtension.php
Damian Mooyman 510c556739 API File has Versioned extension
API Improved support for versioned DataObject
API GridField extensions for versioned dataobjects
API De-couple File and ErrorPage
API File::handle_shortcode now respects canView()
API AssetControlExtension is now able to delete, publish, or protect files
API Upload now protects new assets by default
2016-02-23 13:46:28 +13:00

298 lines
8.3 KiB
PHP

<?php
namespace SilverStripe\Filesystem;
use DataObject;
use Injector;
use Member;
use Versioned;
use SilverStripe\Filesystem\Storage\AssetStore;
/**
* This class provides the necessary business logic to ensure that any assets attached
* to a record are safely deleted, published, or protected during certain operations.
*
* This class will respect the canView() of each object, and will use it to determine
* whether or not public users can access attached assets. Public and live records
* will have their assets promoted to the public store.
*
* Assets which exist only on non-live stages will be protected.
*
* Assets which are no longer referenced will be flushed via explicit delete calls
* to the underlying filesystem.
*
* @property DataObject|Versioned $owner A {@see DataObject}, potentially decorated with {@see Versioned} extension.
*/
class AssetControlExtension extends \DataExtension
{
/**
* When archiving versioned dataobjects, should assets be archived with them?
* If false, assets will be deleted when the dataobject is archived.
* If true, assets will be instead moved to the protected store, and can be
* restored when the dataobject is restored from archive.
*
* Note that this does not affect the archiving of the actual database record in any way,
* only the physical file.
*
* Unversioned dataobjects will ignore this option and always delete attached
* assets on deletion.
*
* @config
* @var bool
*/
private static $keep_archived_assets = false;
/**
* Ensure that deletes records remove their underlying file assets, without affecting
* other staged records.
*/
public function onAfterDelete()
{
// Prepare blank manipulation
$manipulations = new AssetManipulationList();
// Add all assets for deletion
$this->addAssetsFromRecord($manipulations, $this->owner, AssetManipulationList::STATE_DELETED);
// Whitelist assets that exist in other stages
$this->addAssetsFromOtherStages($manipulations);
// Apply visibility rules based on the final manipulation
$this->processManipulation($manipulations);
}
/**
* Ensure that changes to records flush overwritten files, and update the visibility
* of other assets.
*/
public function onBeforeWrite()
{
// Prepare blank manipulation
$manipulations = new AssetManipulationList();
// Mark overwritten object as deleted
if($this->owner->isInDB()) {
$priorRecord = DataObject::get(get_class($this->owner))->byID($this->owner->ID);
if($priorRecord) {
$this->addAssetsFromRecord($manipulations, $priorRecord, AssetManipulationList::STATE_DELETED);
}
}
// Add assets from new record with the correct visibility rules
$state = $this->getRecordState($this->owner);
$this->addAssetsFromRecord($manipulations, $this->owner, $state);
// Whitelist assets that exist in other stages
$this->addAssetsFromOtherStages($manipulations);
// Apply visibility rules based on the final manipulation
$this->processManipulation($manipulations);
}
/**
* Check default state of this record
*
* @param DataObject $record
* @return string One of AssetManipulationList::STATE_* constants
*/
protected function getRecordState($record) {
if($this->isVersioned()) {
// Check stage this record belongs to
$stage = $record->getSourceQueryParam('Versioned.stage') ?: Versioned::current_stage();
// Non-live stages are automatically non-public
if($stage !== Versioned::get_live_stage()) {
return AssetManipulationList::STATE_PROTECTED;
}
}
// Check if canView permits anonymous viewers
return $record->canView(Member::create())
? AssetManipulationList::STATE_PUBLIC
: AssetManipulationList::STATE_PROTECTED;
}
/**
* Given a set of asset manipulations, trigger any necessary publish, protect, or
* delete actions on each asset.
*
* @param AssetManipulationList $manipulations
*/
protected function processManipulation(AssetManipulationList $manipulations)
{
// When deleting from stage then check if we should archive assets
$archive = $this->owner->config()->keep_archived_assets;
// Publish assets
$this->publishAll($manipulations->getPublicAssets());
// Protect assets
$this->protectAll($manipulations->getProtectedAssets());
// Check deletion policy
$deletedAssets = $manipulations->getDeletedAssets();
if ($archive && $this->isVersioned()) {
// Archived assets are kept protected
$this->protectAll($deletedAssets);
} else {
// Otherwise remove all assets
$this->deleteAll($deletedAssets);
}
}
/**
* Checks all stages other than the current stage, and check the visibility
* of assets attached to those records.
*
* @param AssetManipulationList $manipulation Set of manipulations to add assets to
*/
protected function addAssetsFromOtherStages(AssetManipulationList $manipulation)
{
// Skip unversioned or unsaved assets
if(!$this->isVersioned() || !$this->owner->isInDB()) {
return;
}
// Unauthenticated member to use for checking visibility
$baseClass = \ClassInfo::baseDataClass($this->owner);
$filter = array("\"{$baseClass}\".\"ID\"" => $this->owner->ID);
$stages = $this->owner->getVersionedStages(); // {@see Versioned::getVersionedStages}
foreach ($stages as $stage) {
// Skip current stage; These should be handled explicitly
if($stage === Versioned::current_stage()) {
continue;
}
// Check if record exists in this stage
$record = Versioned::get_one_by_stage($baseClass, $stage, $filter);
if (!$record) {
continue;
}
// Check visibility of this record, and record all attached assets
$state = $this->getRecordState($record);
$this->addAssetsFromRecord($manipulation, $record, $state);
}
}
/**
* Given a record, add all assets it contains to the given manipulation.
* State can be declared for this record, otherwise the underlying DataObject
* will be queried for canView() to see if those assets are public
*
* @param AssetManipulationList $manipulation Set of manipulations to add assets to
* @param DataObject $record Record
* @param string $state One of AssetManipulationList::STATE_* constant values.
*/
protected function addAssetsFromRecord(AssetManipulationList $manipulation, DataObject $record, $state)
{
// Find all assets attached to this record
$assets = $this->findAssets($record);
if (empty($assets)) {
return;
}
// Add all assets to this stage
foreach ($assets as $asset) {
$manipulation->addAsset($asset, $state);
}
}
/**
* 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 {@see Versioned) extension rules should be applied to this object
*
* @return bool
*/
protected function isVersioned()
{
return $this->owner->has_extension('Versioned') && class_exists('Versioned');
}
/**
* Delete all assets in the tuple list
*
* @param array $assets
*/
protected function deleteAll($assets)
{
if (empty($assets)) {
return;
}
$store = $this->getAssetStore();
foreach ($assets as $asset) {
$store->delete($asset['Filename'], $asset['Hash']);
}
}
/**
* Move all assets in the list to the public store
*
* @param array $assets
*/
protected function publishAll($assets)
{
if (empty($assets)) {
return;
}
$store = $this->getAssetStore();
foreach ($assets as $asset) {
$store->publish($asset['Filename'], $asset['Hash']);
}
}
/**
* Move all assets in the list to the protected store
*
* @param array $assets
*/
protected function protectAll($assets)
{
if (empty($assets)) {
return;
}
$store = $this->getAssetStore();
foreach ($assets as $asset) {
$store->protect($asset['Filename'], $asset['Hash']);
}
}
/**
* @return AssetStore
*/
protected function getAssetStore()
{
return Injector::inst()->get('AssetStore');
}
}