Completed
Push — master ( 2fdc96...4f1f24 )
by Damian
12:09
created

AssetControlExtension::processManipulation()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 20
rs 9.4285
c 1
b 0
f 0
cc 3
eloc 9
nc 2
nop 1
1
<?php
2
3
namespace SilverStripe\Filesystem;
4
5
use DataObject;
6
use Injector;
7
use Member;
8
use Versioned;
9
use SilverStripe\Filesystem\Storage\AssetStore;
10
11
/**
12
 * This class provides the necessary business logic to ensure that any assets attached
13
 * to a record are safely deleted, published, or protected during certain operations.
14
 *
15
 * This class will respect the canView() of each object, and will use it to determine
16
 * whether or not public users can access attached assets. Public and live records
17
 * will have their assets promoted to the public store.
18
 *
19
 * Assets which exist only on non-live stages will be protected.
20
 *
21
 * Assets which are no longer referenced will be flushed via explicit delete calls
22
 * to the underlying filesystem.
23
 *
24
 * @property DataObject|Versioned $owner A {@see DataObject}, potentially decorated with {@see Versioned} extension.
25
 */
26
class AssetControlExtension extends \DataExtension
27
{
28
29
	/**
30
	 * When archiving versioned dataobjects, should assets be archived with them?
31
	 * If false, assets will be deleted when the dataobject is archived.
32
	 * If true, assets will be instead moved to the protected store, and can be
33
	 * restored when the dataobject is restored from archive.
34
	 *
35
	 * Note that this does not affect the archiving of the actual database record in any way,
36
	 * only the physical file.
37
	 *
38
	 * Unversioned dataobjects will ignore this option and always delete attached
39
	 * assets on deletion.
40
	 *
41
	 * @config
42
	 * @var bool
43
	 */
44
	private static $keep_archived_assets = false;
45
46
	/**
47
	 * Ensure that deletes records remove their underlying file assets, without affecting
48
	 * other staged records.
49
	 */
50
	public function onAfterDelete()
51
	{
52
		// Prepare blank manipulation
53
		$manipulations = new AssetManipulationList();
54
55
		// Add all assets for deletion
56
		$this->addAssetsFromRecord($manipulations, $this->owner, AssetManipulationList::STATE_DELETED);
0 ignored issues
show
Bug introduced by
It seems like $this->owner can also be of type object<Versioned>; however, SilverStripe\Filesystem\...::addAssetsFromRecord() does only seem to accept object<DataObject>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
57
58
		// Whitelist assets that exist in other stages
59
		$this->addAssetsFromOtherStages($manipulations);
60
61
		// Apply visibility rules based on the final manipulation
62
		$this->processManipulation($manipulations);
63
	}
64
65
	/**
66
	 * Ensure that changes to records flush overwritten files, and update the visibility
67
	 * of other assets.
68
	 */
69
	public function onBeforeWrite()
70
	{
71
		// Prepare blank manipulation
72
		$manipulations = new AssetManipulationList();
73
74
		// Mark overwritten object as deleted
75
		if($this->owner->isInDB()) {
0 ignored issues
show
Bug introduced by
The method isInDB does only exist in DataObject, but not in Versioned.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
76
			$priorRecord = DataObject::get(get_class($this->owner))->byID($this->owner->ID);
77
			if($priorRecord) {
78
				$this->addAssetsFromRecord($manipulations, $priorRecord, AssetManipulationList::STATE_DELETED);
79
			}
80
		}
81
82
		// Add assets from new record with the correct visibility rules
83
		$state = $this->getRecordState($this->owner);
0 ignored issues
show
Bug introduced by
It seems like $this->owner can also be of type object<Versioned>; however, SilverStripe\Filesystem\...nsion::getRecordState() does only seem to accept object<DataObject>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
84
		$this->addAssetsFromRecord($manipulations, $this->owner, $state);
0 ignored issues
show
Bug introduced by
It seems like $this->owner can also be of type object<Versioned>; however, SilverStripe\Filesystem\...::addAssetsFromRecord() does only seem to accept object<DataObject>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
85
86
		// Whitelist assets that exist in other stages
87
		$this->addAssetsFromOtherStages($manipulations);
88
89
		// Apply visibility rules based on the final manipulation
90
		$this->processManipulation($manipulations);
91
	}
92
93
	/**
94
	 * Check default state of this record
95
	 *
96
	 * @param DataObject $record
97
	 * @return string One of AssetManipulationList::STATE_* constants
98
	 */
99
	protected function getRecordState($record) {
100
		if($this->isVersioned()) {
101
			// Check stage this record belongs to
102
			$stage = $record->getSourceQueryParam('Versioned.stage') ?: Versioned::current_stage();
103
104
			// Non-live stages are automatically non-public
105
			if($stage !== Versioned::get_live_stage()) {
106
				return AssetManipulationList::STATE_PROTECTED;
107
			}
108
		}
109
110
		// Check if canView permits anonymous viewers
111
		return $record->canView(Member::create())
112
			? AssetManipulationList::STATE_PUBLIC
113
			: AssetManipulationList::STATE_PROTECTED;
114
	}
115
116
	/**
117
	 * Given a set of asset manipulations, trigger any necessary publish, protect, or
118
	 * delete actions on each asset.
119
	 *
120
	 * @param AssetManipulationList $manipulations
121
	 */
122
	protected function processManipulation(AssetManipulationList $manipulations)
123
	{
124
		// When deleting from stage then check if we should archive assets
125
		$archive = $this->owner->config()->keep_archived_assets;
0 ignored issues
show
Bug introduced by
The method config does only exist in DataObject, but not in Versioned.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
126
		// Publish assets
127
		$this->publishAll($manipulations->getPublicAssets());
128
129
		// Protect assets
130
		$this->protectAll($manipulations->getProtectedAssets());
131
132
		// Check deletion policy
133
		$deletedAssets = $manipulations->getDeletedAssets();
134
		if ($archive && $this->isVersioned()) {
135
			// Archived assets are kept protected
136
			$this->protectAll($deletedAssets);
137
		} else {
138
			// Otherwise remove all assets
139
			$this->deleteAll($deletedAssets);
140
		}
141
	}
142
143
	/**
144
	 * Checks all stages other than the current stage, and check the visibility
145
	 * of assets attached to those records.
146
	 *
147
	 * @param AssetManipulationList $manipulation Set of manipulations to add assets to
148
	 */
149
	protected function addAssetsFromOtherStages(AssetManipulationList $manipulation)
150
	{
151
		// Skip unversioned or unsaved assets
152
		if(!$this->isVersioned() || !$this->owner->isInDB()) {
0 ignored issues
show
Bug introduced by
The method isInDB does only exist in DataObject, but not in Versioned.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
153
			return;
154
		}
155
156
		// Unauthenticated member to use for checking visibility
157
		$baseClass = \ClassInfo::baseDataClass($this->owner);
158
		$filter = array("\"{$baseClass}\".\"ID\"" => $this->owner->ID);
159
		$stages = $this->owner->getVersionedStages(); // {@see Versioned::getVersionedStages}
0 ignored issues
show
Bug introduced by
The method getVersionedStages does only exist in Versioned, but not in DataObject.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
160
		foreach ($stages as $stage) {
161
			// Skip current stage; These should be handled explicitly
162
			if($stage === Versioned::current_stage()) {
163
				continue;
164
			}
165
166
			// Check if record exists in this stage
167
			$record = Versioned::get_one_by_stage($baseClass, $stage, $filter);
168
			if (!$record) {
169
				continue;
170
			}
171
172
			// Check visibility of this record, and record all attached assets
173
			$state = $this->getRecordState($record);
174
			$this->addAssetsFromRecord($manipulation, $record, $state);
175
		}
176
	}
177
178
	/**
179
	 * Given a record, add all assets it contains to the given manipulation.
180
	 * State can be declared for this record, otherwise the underlying DataObject
181
	 * will be queried for canView() to see if those assets are public
182
	 *
183
	 * @param AssetManipulationList $manipulation Set of manipulations to add assets to
184
	 * @param DataObject $record Record
185
	 * @param string $state One of AssetManipulationList::STATE_* constant values.
186
	 */
187
	protected function addAssetsFromRecord(AssetManipulationList $manipulation, DataObject $record, $state)
188
	{
189
		// Find all assets attached to this record
190
		$assets = $this->findAssets($record);
191
		if (empty($assets)) {
192
			return;
193
		}
194
195
		// Add all assets to this stage
196
		foreach ($assets as $asset) {
197
			$manipulation->addAsset($asset, $state);
198
		}
199
	}
200
201
	/**
202
	 * Return a list of all tuples attached to this dataobject
203
	 * Note: Variants are excluded
204
	 *
205
	 * @param DataObject $record to search
206
	 * @return array
207
	 */
208
	protected function findAssets(DataObject $record)
209
	{
210
		// Search for dbfile instances
211
		$files = array();
212
		foreach ($record->db() as $field => $db) {
0 ignored issues
show
Bug introduced by
The expression $record->db() of type array|string|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
213
			// Extract assets from this database field
214
			list($dbClass) = explode('(', $db);
215
			if (!is_a($dbClass, 'DBFile', true)) {
216
				continue;
217
			}
218
219
			// Omit variant and merge with set
220
			$next = $record->dbObject($field)->getValue();
221
			unset($next['Variant']);
222
			if ($next) {
223
				$files[] = $next;
224
			}
225
		}
226
227
		// De-dupe
228
		return array_map("unserialize", array_unique(array_map("serialize", $files)));
229
	}
230
231
	/**
232
	 * Determine if {@see Versioned) extension rules should be applied to this object
233
	 *
234
	 * @return bool
235
	 */
236
	protected function isVersioned()
237
	{
238
		return $this->owner->has_extension('Versioned') && class_exists('Versioned');
0 ignored issues
show
Bug introduced by
The method has_extension does only exist in DataObject, but not in Versioned.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
239
	}
240
241
	/**
242
	 * Delete all assets in the tuple list
243
	 *
244
	 * @param array $assets
245
	 */
246
	protected function deleteAll($assets)
247
	{
248
		if (empty($assets)) {
249
			return;
250
		}
251
		$store = $this->getAssetStore();
252
		foreach ($assets as $asset) {
253
			$store->delete($asset['Filename'], $asset['Hash']);
254
		}
255
	}
256
257
	/**
258
	 * Move all assets in the list to the public store
259
	 *
260
	 * @param array $assets
261
	 */
262
	protected function publishAll($assets)
263
	{
264
		if (empty($assets)) {
265
			return;
266
		}
267
268
		$store = $this->getAssetStore();
269
		foreach ($assets as $asset) {
270
			$store->publish($asset['Filename'], $asset['Hash']);
271
		}
272
	}
273
274
	/**
275
	 * Move all assets in the list to the protected store
276
	 *
277
	 * @param array $assets
278
	 */
279
	protected function protectAll($assets)
280
	{
281
		if (empty($assets)) {
282
			return;
283
		}
284
		$store = $this->getAssetStore();
285
		foreach ($assets as $asset) {
286
			$store->protect($asset['Filename'], $asset['Hash']);
287
		}
288
	}
289
290
	/**
291
	 * @return AssetStore
292
	 */
293
	protected function getAssetStore()
294
	{
295
		return Injector::inst()->get('AssetStore');
296
	}
297
}
298