Completed
Push — master ( 7b2124...016ea2 )
by Damian
15s
created

FlysystemAssetStore::truncateDirectory()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 9
rs 8.8571
cc 5
eloc 6
nc 2
nop 2
1
<?php
2
3
namespace SilverStripe\Filesystem\Flysystem;
4
5
use Config;
6
use Generator;
7
use Injector;
8
use LogicException;
9
use Session;
10
use Flushable;
11
use InvalidArgumentException;
12
use League\Flysystem\Directory;
13
use League\Flysystem\Exception;
14
use League\Flysystem\Filesystem;
15
use League\Flysystem\FilesystemInterface;
16
use League\Flysystem\Util;
17
use SilverStripe\Filesystem\Storage\AssetNameGenerator;
18
use SilverStripe\Filesystem\Storage\AssetStore;
19
use SilverStripe\Filesystem\Storage\AssetStoreRouter;
20
use SS_HTTPResponse;
21
22
/**
23
 * Asset store based on flysystem Filesystem as a backend
24
 *
25
 * @package framework
26
 * @subpackage filesystem
27
 */
28
class FlysystemAssetStore implements AssetStore, AssetStoreRouter, Flushable {
29
30
	/**
31
	 * Session key to use for user grants
32
	 */
33
	const GRANTS_SESSION = 'AssetStore_Grants';
34
35
	/**
36
	 * @var Filesystem
37
	 */
38
	private $publicFilesystem = null;
39
40
	/**
41
	 * Filesystem to use for protected files
42
	 *
43
	 * @var Filesystem
44
	 */
45
	private $protectedFilesystem = null;
46
47
	/**
48
	 * Enable to use legacy filename behaviour (omits hash)
49
	 *
50
	 * Note that if using legacy filenames then duplicate files will not work.
51
	 *
52
	 * @config
53
	 * @var bool
54
	 */
55
	private static $legacy_filenames = false;
56
57
	/**
58
	 * Flag if empty folders are allowed.
59
	 * If false, empty folders are cleared up when their contents are deleted.
60
	 *
61
	 * @config
62
	 * @var bool
63
	 */
64
	private static $keep_empty_dirs = false;
65
66
	/**
67
	 * Set HTTP error code for requests to secure denied assets.
68
	 * Note that this defaults to 404 to prevent information disclosure
69
	 * of secure files
70
	 *
71
	 * @config
72
	 * @var int
73
	 */
74
	private static $denied_response_code = 404;
75
76
	/**
77
	 * Set HTTP error code to use for missing secure assets
78
	 *
79
	 * @config
80
	 * @var int
81
	 */
82
	private static $missing_response_code = 404;
83
84
	/**
85
	 * Custom headers to add to all custom file responses
86
	 *
87
	 * @config
88
	 * @var array
89
	 */
90
	private static $file_response_headers = array(
91
		'Cache-Control' => 'private'
92
	);
93
94
	/**
95
	 * Assign new flysystem backend
96
	 *
97
	 * @param Filesystem $filesystem
98
	 * @return $this
99
	 */
100
	public function setPublicFilesystem(Filesystem $filesystem) {
101
		if(!$filesystem->getAdapter() instanceof PublicAdapter) {
102
			throw new InvalidArgumentException("Configured adapter must implement PublicAdapter");
103
		}
104
		$this->publicFilesystem = $filesystem;
105
		return $this;
106
	}
107
108
	/**
109
	 * Get the currently assigned flysystem backend
110
	 *
111
	 * @return Filesystem
112
	 * @throws LogicException
113
	 */
114
	public function getPublicFilesystem() {
115
		if(!$this->publicFilesystem) {
116
			throw new LogicException("Filesystem misconfiguration error");
117
		}
118
		return $this->publicFilesystem;
119
	}
120
121
	/**
122
	 * Assign filesystem to use for non-public files
123
	 *
124
	 * @param Filesystem $filesystem
125
	 * @return $this
126
	 */
127
	public function setProtectedFilesystem(Filesystem $filesystem) {
128
		if(!$filesystem->getAdapter() instanceof ProtectedAdapter) {
129
			throw new InvalidArgumentException("Configured adapter must implement ProtectedAdapter");
130
		}
131
		$this->protectedFilesystem = $filesystem;
132
		return $this;
133
	}
134
135
	/**
136
	 * Get filesystem to use for non-public files
137
	 *
138
	 * @return Filesystem
139
	 */
140
	public function getProtectedFilesystem() {
141
		if(!$this->protectedFilesystem) {
142
			throw new Exception("Filesystem misconfiguration error");
143
		}
144
		return $this->protectedFilesystem;
145
	}
146
147
	/**
148
	 * Return the store that contains the given fileID
149
	 *
150
	 * @param string $fileID Internal file identifier
151
	 * @return Filesystem
152
	 */
153
	protected function getFilesystemFor($fileID) {
154
		if($this->getPublicFilesystem()->has($fileID)) {
155
			return $this->getPublicFilesystem();
156
		}
157
158
		if($this->getProtectedFilesystem()->has($fileID)) {
159
			return $this->getProtectedFilesystem();
160
		}
161
162
		return null;
163
	}
164
165
	public function getCapabilities() {
166
		return array(
167
			'visibility' => array(
168
				self::VISIBILITY_PUBLIC,
169
				self::VISIBILITY_PROTECTED
170
			),
171
			'conflict' => array(
172
				self::CONFLICT_EXCEPTION,
173
				self::CONFLICT_OVERWRITE,
174
				self::CONFLICT_RENAME,
175
				self::CONFLICT_USE_EXISTING
176
			)
177
		);
178
	}
179
180
	public function getVisibility($filename, $hash) {
181
		$fileID = $this->getFileID($filename, $hash);
182
		if($this->getPublicFilesystem()->has($fileID)) {
183
			return self::VISIBILITY_PUBLIC;
184
		}
185
186
		if($this->getProtectedFilesystem()->has($fileID)) {
187
			return self::VISIBILITY_PROTECTED;
188
		}
189
190
		return null;
191
	}
192
193
194
	public function getAsStream($filename, $hash, $variant = null) {
195
		$fileID = $this->getFileID($filename, $hash, $variant);
196
		return $this
197
			->getFilesystemFor($fileID)
198
			->readStream($fileID);
199
	}
200
201
	public function getAsString($filename, $hash, $variant = null) {
202
		$fileID = $this->getFileID($filename, $hash, $variant);
203
		return $this
204
			->getFilesystemFor($fileID)
205
			->read($fileID);
206
	}
207
208
	public function getAsURL($filename, $hash, $variant = null, $grant = true) {
209
		if($grant) {
210
			$this->grant($filename, $hash);
211
		}
212
		$fileID = $this->getFileID($filename, $hash, $variant);
213
214
		// Check with filesystem this asset exists in
215
		$public = $this->getPublicFilesystem();
216
		$protected = $this->getProtectedFilesystem();
217
		if($public->has($fileID) || !$protected->has($fileID)) {
218
			/** @var PublicAdapter $publicAdapter */
219
			$publicAdapter = $public->getAdapter();
220
			return $publicAdapter->getPublicUrl($fileID);
221
		} else {
222
			/** @var ProtectedAdapter $protectedAdapter */
223
			$protectedAdapter = $protected->getAdapter();
224
			return $protectedAdapter->getProtectedUrl($fileID);
225
		}
226
	}
227
228
	public function setFromLocalFile($path, $filename = null, $hash = null, $variant = null, $config = array()) {
229
		// Validate this file exists
230
		if(!file_exists($path)) {
231
			throw new InvalidArgumentException("$path does not exist");
232
		}
233
234
		// Get filename to save to
235
		if(empty($filename)) {
236
			$filename = basename($path);
237
		}
238
239
		// Callback for saving content
240
		$callback = function(Filesystem $filesystem, $fileID) use ($path) {
241
			// Read contents as string into flysystem
242
			$handle = fopen($path, 'r');
243
			if($handle === false) {
244
				throw new InvalidArgumentException("$path could not be opened for reading");
245
			}
246
			$result = $filesystem->putStream($fileID, $handle);
247
			fclose($handle);
248
			return $result;
249
		};
250
251
		// When saving original filename, generate hash
252
		if(!$variant) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $variant of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
253
			$hash = sha1_file($path);
254
		}
255
256
		// Submit to conflict check
257
		return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
258
	}
259
260
	public function setFromString($data, $filename, $hash = null, $variant = null, $config = array()) {
261
		// Callback for saving content
262
		$callback = function(Filesystem $filesystem, $fileID) use ($data) {
263
			return $filesystem->put($fileID, $data);
264
		};
265
266
		// When saving original filename, generate hash
267
		if(!$variant) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $variant of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
268
			$hash = sha1($data);
269
		}
270
271
		// Submit to conflict check
272
		return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
273
	}
274
275
	public function setFromStream($stream, $filename, $hash = null, $variant = null, $config = array()) {
276
		// If the stream isn't rewindable, write to a temporary filename
277
		if(!$this->isSeekableStream($stream)) {
278
			$path = $this->getStreamAsFile($stream);
279
			$result = $this->setFromLocalFile($path, $filename, $hash, $variant, $config);
280
			unlink($path);
281
			return $result;
282
		}
283
284
		// Callback for saving content
285
		$callback = function(Filesystem $filesystem, $fileID) use ($stream) {
286
			return $filesystem->putStream($fileID, $stream);
287
		};
288
289
		// When saving original filename, generate hash
290
		if(!$variant) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $variant of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
291
			$hash = $this->getStreamSHA1($stream);
292
		}
293
294
		// Submit to conflict check
295
		return $this->writeWithCallback($callback, $filename, $hash, $variant, $config);
296
	}
297
298
	public function delete($filename, $hash) {
299
		$fileID = $this->getFileID($filename, $hash);
300
		$protected = $this->deleteFromFilesystem($fileID, $this->getProtectedFilesystem());
301
		$public = $this->deleteFromFilesystem($fileID, $this->getPublicFilesystem());
302
		return $protected || $public;
303
	}
304
305
	/**
306
	 * Delete the given file (and any variants) in the given {@see Filesystem}
307
	 *
308
	 * @param string $fileID
309
	 * @param Filesystem $filesystem
310
	 * @return bool True if a file was deleted
311
	 */
312
	protected function deleteFromFilesystem($fileID, Filesystem $filesystem) {
313
		$deleted = false;
314
		foreach($this->findVariants($fileID, $filesystem) as $nextID) {
315
			$filesystem->delete($nextID);
316
			$deleted = true;
317
		}
318
319
		// Truncate empty dirs
320
		$this->truncateDirectory(dirname($fileID), $filesystem);
321
322
		return $deleted;
323
	}
324
325
	/**
326
	 * Clear directory if it's empty
327
	 *
328
	 * @param string $dirname Name of directory
329
	 * @param Filesystem $filesystem
330
	 */
331
	protected function truncateDirectory($dirname, Filesystem $filesystem) {
332
		if ($dirname
333
			&& ltrim(dirname($dirname), '.')
334
			&& ! Config::inst()->get(get_class($this), 'keep_empty_dirs')
335
			&& ! $filesystem->listContents($dirname)
336
		) {
337
			$filesystem->deleteDir($dirname);
338
		}
339
	}
340
341
	/**
342
	 * Returns an iterable {@see Generator} of all files / variants for the given $fileID in the given $filesystem
343
	 * This includes the empty (no) variant.
344
	 *
345
	 * @param string $fileID ID of original file to compare with.
346
	 * @param Filesystem $filesystem
347
	 * @return Generator
348
	 */
349
	protected function findVariants($fileID, Filesystem $filesystem) {
350
		$dirname = ltrim(dirname($fileID), '.');
351
		foreach($filesystem->listContents($dirname) as $next) {
352
			if($next['type'] !== 'file') {
353
				continue;
354
			}
355
			$nextID = $next['path'];
356
			// Compare given file to target, omitting variant
357
			if($fileID === $this->removeVariant($nextID)) {
358
				yield $nextID;
359
			}
360
		}
361
	}
362
363
	public function publish($filename, $hash) {
364
		$fileID = $this->getFileID($filename, $hash);
365
		$protected = $this->getProtectedFilesystem();
366
		$public = $this->getPublicFilesystem();
367
		$this->moveBetweenFilesystems($fileID, $protected, $public);
368
	}
369
370
	public function protect($filename, $hash) {
371
		$fileID = $this->getFileID($filename, $hash);
372
		$public = $this->getPublicFilesystem();
373
		$protected = $this->getProtectedFilesystem();
374
		$this->moveBetweenFilesystems($fileID, $public, $protected);
375
	}
376
377
	/**
378
	 * Move a file (and its associative variants) between filesystems
379
	 *
380
	 * @param string $fileID
381
	 * @param Filesystem $from
382
	 * @param Filesystem $to
383
	 */
384
	protected function moveBetweenFilesystems($fileID, Filesystem $from, Filesystem $to) {
385
		foreach($this->findVariants($fileID, $from) as $nextID) {
386
			// Copy via stream
387
			$stream = $from->readStream($nextID);
388
			$to->putStream($nextID, $stream);
0 ignored issues
show
Security Bug introduced by
It seems like $stream defined by $from->readStream($nextID) on line 387 can also be of type false; however, League\Flysystem\Filesystem::putStream() does only seem to accept resource, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
389
			fclose($stream);
390
			$from->delete($nextID);
391
		}
392
393
		// Truncate empty dirs
394
		$this->truncateDirectory(dirname($fileID), $from);
395
	}
396
397
	public function grant($filename, $hash) {
398
		$fileID = $this->getFileID($filename, $hash);
399
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
400
		$granted[$fileID] = true;
401
		Session::set(self::GRANTS_SESSION, $granted);
402
	}
403
404
	public function revoke($filename, $hash) {
405
		$fileID = $this->getFileID($filename, $hash);
406
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
407
		unset($granted[$fileID]);
408
		if($granted) {
409
			Session::set(self::GRANTS_SESSION, $granted);
410
		} else {
411
			Session::clear(self::GRANTS_SESSION);
412
		}
413
	}
414
415
	public function canView($filename, $hash) {
416
		$fileID = $this->getFileID($filename, $hash);
417
		if($this->getProtectedFilesystem()->has($fileID)) {
418
			return $this->isGranted($fileID);
419
		}
420
		return true;
421
	}
422
423
	/**
424
	 * Determine if a grant exists for the given FileID
425
	 *
426
	 * @param string $fileID
427
	 * @return bool
428
	 */
429
	protected function isGranted($fileID) {
430
		// Since permissions are applied to the non-variant only,
431
		// map back to the original file before checking
432
		$originalID = $this->removeVariant($fileID);
433
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
434
		return !empty($granted[$originalID]);
435
	}
436
437
	/**
438
	 * get sha1 hash from stream
439
	 *
440
	 * @param resource $stream
441
	 * @return string str1 hash
442
	 */
443
	protected function getStreamSHA1($stream) {
444
		Util::rewindStream($stream);
445
		$context = hash_init('sha1');
446
		hash_update_stream($context, $stream);
447
		return hash_final($context);
448
	}
449
450
	/**
451
	 * Get stream as a file
452
	 *
453
	 * @param resource $stream
454
	 * @return string Filename of resulting stream content
455
	 * @throws Exception
456
	 */
457
	protected function getStreamAsFile($stream) {
458
		// Get temporary file and name
459
		$file = tempnam(sys_get_temp_dir(), 'ssflysystem');
460
		$buffer = fopen($file, 'w');
461
		if (!$buffer) {
462
			throw new Exception("Could not create temporary file");
463
		}
464
465
		// Transfer from given stream
466
		Util::rewindStream($stream);
467
		stream_copy_to_stream($stream, $buffer);
468
		if (! fclose($buffer)) {
469
			throw new Exception("Could not write stream to temporary file");
470
		}
471
472
		return $file;
473
	}
474
475
	/**
476
	 * Determine if this stream is seekable
477
	 *
478
	 * @param resource $stream
479
	 * @return bool True if this stream is seekable
480
	 */
481
	protected function isSeekableStream($stream) {
482
		return Util::isSeekableStream($stream);
483
	}
484
485
	/**
486
	 * Invokes the conflict resolution scheme on the given content, and invokes a callback if
487
	 * the storage request is approved.
488
	 *
489
	 * @param callable $callback Will be invoked and passed a fileID if the file should be stored
490
	 * @param string $filename Name for the resulting file
491
	 * @param string $hash SHA1 of the original file content
492
	 * @param string $variant Variant to write
493
	 * @param array $config Write options. {@see AssetStore}
494
	 * @return array Tuple associative array (Filename, Hash, Variant)
495
	 * @throws Exception
496
	 */
497
	protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = array()) {
498
		// Set default conflict resolution
499
		if(empty($config['conflict'])) {
500
			$conflictResolution = $this->getDefaultConflictResolution($variant);
501
		} else {
502
			$conflictResolution = $config['conflict'];
503
		}
504
505
		// Validate parameters
506
		if($variant && $conflictResolution === AssetStore::CONFLICT_RENAME) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $variant of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
507
			// As variants must follow predictable naming rules, they should not be dynamically renamed
508
			throw new InvalidArgumentException("Rename cannot be used when writing variants");
509
		}
510
		if(!$filename) {
511
			throw new InvalidArgumentException("Filename is missing");
512
		}
513
		if(!$hash) {
514
			throw new InvalidArgumentException("File hash is missing");
515
		}
516
517
		$filename = $this->cleanFilename($filename);
518
		$fileID = $this->getFileID($filename, $hash, $variant);
519
520
		// Check conflict resolution scheme
521
		$resolvedID = $this->resolveConflicts($conflictResolution, $fileID);
522
		if($resolvedID !== false) {
523
			// Check if source file already exists on the filesystem
524
			$mainID = $this->getFileID($filename, $hash);
525
			$filesystem = $this->getFilesystemFor($mainID);
526
527
			// If writing a new file use the correct visibility
528
			if(!$filesystem) {
529
				// Default to public store unless requesting protected store
530
				if(isset($config['visibility']) && $config['visibility'] === self::VISIBILITY_PROTECTED) {
531
					$filesystem = $this->getProtectedFilesystem();
532
				} else {
533
					$filesystem = $this->getPublicFilesystem();
534
				}
535
			}
536
537
			// Submit and validate result
538
			$result = $callback($filesystem, $resolvedID);
539
			if(!$result) {
540
				throw new Exception("Could not save {$filename}");
541
			}
542
543
			// in case conflict resolution renamed the file, return the renamed
544
			$filename = $this->getOriginalFilename($resolvedID);
545
546
		} elseif(empty($variant)) {
547
			// If deferring to the existing file, return the sha of the existing file,
548
			// unless we are writing a variant (which has the same hash value as its original file)
549
			$stream = $this
550
				->getFilesystemFor($fileID)
551
				->readStream($fileID);
552
			$hash = $this->getStreamSHA1($stream);
553
		}
554
555
		return array(
556
			'Filename' => $filename,
557
			'Hash' => $hash,
558
			'Variant' => $variant
559
		);
560
	}
561
562
	/**
563
	 * Choose a default conflict resolution
564
	 *
565
	 * @param string $variant
566
	 * @return string
567
	 */
568
	protected function getDefaultConflictResolution($variant) {
569
		// If using new naming scheme (segment by hash) it's normally safe to overwrite files.
570
		// Variants are also normally safe to overwrite, since lazy-generation is implemented at a higher level.
571
		$legacy = $this->useLegacyFilenames();
572
		if(!$legacy || $variant) {
573
			return AssetStore::CONFLICT_OVERWRITE;
574
		}
575
576
		// Legacy behaviour is to rename
577
		return AssetStore::CONFLICT_RENAME;
578
	}
579
580
	/**
581
	 * Determine if legacy filenames should be used. These do not have hash path parts.
582
	 *
583
	 * @return bool
584
	 */
585
	protected function useLegacyFilenames() {
586
		return Config::inst()->get(get_class($this), 'legacy_filenames');
587
	}
588
589
	public function getMetadata($filename, $hash, $variant = null) {
590
		$fileID = $this->getFileID($filename, $hash, $variant);
591
		$filesystem = $this->getFilesystemFor($fileID);
592
		if($filesystem) {
593
			return $filesystem->getMetadata($fileID);
594
		}
595
		return null;
596
	}
597
598
	public function getMimeType($filename, $hash, $variant = null) {
599
		$fileID = $this->getFileID($filename, $hash, $variant);
600
		$filesystem = $this->getFilesystemFor($fileID);
601
		if($filesystem) {
602
			return $filesystem->getMimetype($fileID);
603
		}
604
		return null;
605
	}
606
607
	public function exists($filename, $hash, $variant = null) {
608
		$fileID = $this->getFileID($filename, $hash, $variant);
609
		$filesystem = $this->getFilesystemFor($fileID);
610
		return !empty($filesystem);
611
	}
612
613
	/**
614
	 * Determine the path that should be written to, given the conflict resolution scheme
615
	 *
616
	 * @param string $conflictResolution
617
	 * @param string $fileID
618
	 * @return string|false Safe filename to write to. If false, then don't write, and use existing file.
619
	 * @throws Exception
620
	 */
621
	protected function resolveConflicts($conflictResolution, $fileID) {
622
		// If overwrite is requested, simply put
623
		if($conflictResolution === AssetStore::CONFLICT_OVERWRITE) {
624
			return $fileID;
625
		}
626
627
		// Otherwise, check if this exists
628
		$exists = $this->getFilesystemFor($fileID);
629
		if(!$exists) {
630
			return $fileID;
631
		}
632
633
		// Flysystem defaults to use_existing
634
		switch($conflictResolution) {
635
			// Throw tantrum
636
			case static::CONFLICT_EXCEPTION: {
0 ignored issues
show
Coding Style introduced by
CASE statements must be defined using a colon

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
637
				throw new InvalidArgumentException("File already exists at path {$fileID}");
638
			}
639
640
			// Rename
641
			case static::CONFLICT_RENAME: {
0 ignored issues
show
Coding Style introduced by
CASE statements must be defined using a colon

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
642
				foreach($this->fileGeneratorFor($fileID) as $candidate) {
643
					if(!$this->getFilesystemFor($candidate)) {
644
						return $candidate;
645
					}
646
				}
647
648
				throw new InvalidArgumentException("File could not be renamed with path {$fileID}");
649
			}
650
651
			// Use existing file
652
			case static::CONFLICT_USE_EXISTING:
653
			default: {
0 ignored issues
show
Coding Style introduced by
DEFAULT statements must be defined using a colon

As per the PSR-2 coding standard, default statements should not be wrapped in curly braces.

switch ($expr) {
    default: { //wrong
        doSomething();
        break;
    }
}

switch ($expr) {
    default: //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
654
				return false;
655
			}
656
		}
657
	}
658
659
	/**
660
	 * Get an asset renamer for the given filename.
661
	 *
662
	 * @param string $fileID Adapter specific identifier for this file/version
663
	 * @return AssetNameGenerator
664
	 */
665
	protected function fileGeneratorFor($fileID){
666
		return Injector::inst()->createWithArgs('AssetNameGenerator', array($fileID));
667
	}
668
669
	/**
670
	 * Performs filename cleanup before sending it back.
671
	 *
672
	 * This name should not contain hash or variants.
673
	 *
674
	 * @param string $filename
675
	 * @return string
676
	 */
677
	protected function cleanFilename($filename) {
678
		// Since we use double underscore to delimit variants, eradicate them from filename
679
		return preg_replace('/_{2,}/', '_', $filename);
680
	}
681
682
	/**
683
	 * Given a FileID, map this back to the original filename, trimming variant and hash
684
	 *
685
	 * @param string $fileID Adapter specific identifier for this file/version
686
	 * @return string Filename for this file, omitting hash and variant
687
	 */
688
	protected function getOriginalFilename($fileID) {
689
		// Remove variant
690
		$originalID = $this->removeVariant($fileID);
691
692
		// Remove hash (unless using legacy filenames, without hash)
693
		if($this->useLegacyFilenames()) {
694
			return $originalID;
695
		} else {
696
			return preg_replace(
697
				'/(?<hash>[a-zA-Z0-9]{10}\\/)(?<name>[^\\/]+)$/',
698
				'$2',
699
				$originalID
700
			);
701
		}
702
	}
703
704
	/**
705
	 * Remove variant from a fileID
706
	 *
707
	 * @param string $fileID
708
	 * @return string FileID without variant
709
	 */
710
	protected function removeVariant($fileID) {
711
		// Check variant
712
		if (preg_match('/^(?<before>((?<!__).)+)__(?<variant>[^\\.]+)(?<after>.*)$/', $fileID, $matches)) {
713
			return $matches['before'] . $matches['after'];
714
		}
715
		// There is no variant, so return original value
716
		return $fileID;
717
	}
718
719
	/**
720
	 * Map file tuple (hash, name, variant) to a filename to be used by flysystem
721
	 *
722
	 * The resulting file will look something like my/directory/EA775CB4D4/filename__variant.jpg
723
	 *
724
	 * @param string $filename Name of file
725
	 * @param string $hash Hash of original file
726
	 * @param string $variant (if given)
727
	 * @return string Adapter specific identifier for this file/version
728
	 */
729
	protected function getFileID($filename, $hash, $variant = null) {
730
		// Since we use double underscore to delimit variants, eradicate them from filename
731
		$filename = $this->cleanFilename($filename);
732
		$name = basename($filename);
733
734
		// Split extension
735
		$extension = null;
736
		if(($pos = strpos($name, '.')) !== false) {
737
			$extension = substr($name, $pos);
738
			$name = substr($name, 0, $pos);
739
		}
740
741
		// Unless in legacy mode, inject hash just prior to the filename
742
		if($this->useLegacyFilenames()) {
743
			$fileID = $name;
744
		} else {
745
			$fileID = substr($hash, 0, 10) . '/' . $name;
746
		}
747
748
		// Add directory
749
		$dirname = ltrim(dirname($filename), '.');
750
		if($dirname) {
751
			$fileID = $dirname . '/' . $fileID;
752
		}
753
754
		// Add variant
755
		if($variant) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $variant of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
756
			$fileID .= '__' . $variant;
757
		}
758
759
		// Add extension
760
		if($extension) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extension of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
761
			$fileID .= $extension;
762
		}
763
764
		return $fileID;
765
	}
766
767
	/**
768
	 * Ensure each adapter re-generates its own server configuration files
769
	 */
770
	public static function flush() {
771
		// Ensure that this instance is constructed on flush, thus forcing
772
		// bootstrapping of necessary .htaccess / web.config files
773
		$instance = singleton('AssetStore');
774
		if ($instance instanceof FlysystemAssetStore) {
775
			$publicAdapter = $instance->getPublicFilesystem()->getAdapter();
776
			if($publicAdapter instanceof AssetAdapter) {
777
				$publicAdapter->flush();
778
			}
779
			$protectedAdapter = $instance->getProtectedFilesystem()->getAdapter();
780
			if($protectedAdapter instanceof AssetAdapter) {
781
				$protectedAdapter->flush();
782
			}
783
		}
784
	}
785
786
	public function getResponseFor($asset) {
787
		// Check if file exists
788
		$filesystem = $this->getFilesystemFor($asset);
789
		if(!$filesystem) {
790
			return $this->createMissingResponse();
791
		}
792
793
		// Block directory access
794
		if($filesystem->get($asset) instanceof Directory) {
795
			return $this->createDeniedResponse();
796
		}
797
798
		// Deny if file is protected and denied
799
		if($filesystem === $this->getProtectedFilesystem() && !$this->isGranted($asset)) {
800
			return $this->createDeniedResponse();
801
		}
802
803
		// Serve up file response
804
		return $this->createResponseFor($filesystem, $asset);
805
	}
806
807
	/**
808
	 * Generate an {@see SS_HTTPResponse} for the given file from the source filesystem
809
	 * @param FilesystemInterface $flysystem
810
	 * @param string $fileID
811
	 * @return SS_HTTPResponse
812
	 */
813
	protected function createResponseFor(FilesystemInterface $flysystem, $fileID) {
814
		// Build response body
815
		// @todo: gzip / buffer response?
816
		$body = $flysystem->read($fileID);
817
		$mime = $flysystem->getMimetype($fileID);
818
		$response = new SS_HTTPResponse($body, 200);
819
820
		// Add headers
821
		$response->addHeader('Content-Type', $mime);
0 ignored issues
show
Security Bug introduced by
It seems like $mime defined by $flysystem->getMimetype($fileID) on line 817 can also be of type false; however, SS_HTTPResponse::addHeader() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
822
		$headers = Config::inst()->get(get_class($this), 'file_response_headers');
823
		foreach($headers as $header => $value) {
0 ignored issues
show
Bug introduced by
The expression $headers of type array|integer|double|string|boolean 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...
824
			$response->addHeader($header, $value);
825
		}
826
		return $response;
827
	}
828
829
	/**
830
	 * Generate a response for requests to a denied protected file
831
	 *
832
	 * @return SS_HTTPResponse
833
	 */
834
	protected function createDeniedResponse() {
835
		$code = (int)Config::inst()->get(get_class($this), 'denied_response_code');
836
		return $this->createErrorResponse($code);
837
	}
838
839
	/**
840
	 * Generate a response for missing file requests
841
	 *
842
	 * @return SS_HTTPResponse
843
	 */
844
	protected function createMissingResponse() {
845
		$code = (int)Config::inst()->get(get_class($this), 'missing_response_code');
846
		return $this->createErrorResponse($code);
847
	}
848
849
	/**
850
	 * Create a response with the given error code
851
	 *
852
	 * @param int $code
853
	 * @return SS_HTTPResponse
854
	 */
855
	protected function createErrorResponse($code) {
856
		$response = new SS_HTTPResponse('', $code);
857
858
		// Show message in dev
859
		if(!\Director::isLive()) {
860
			$response->setBody($response->getStatusDescription());
861
		}
862
863
		return $response;
864
	}
865
}
866