Completed
Push — remove-bbcode ( d0705d )
by Sam
09:48
created

FlysystemAssetStore::getAsURL()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
298
		$fileID = $this->getFileID($filename, $hash);
299
		$protected = $this->deleteFromFilesystem($fileID, $this->getProtectedFilesystem());
300
		$public = $this->deleteFromFilesystem($fileID, $this->getPublicFilesystem());
301
		return $protected || $public;
302
	}
303
304
	/**
305
	 * Delete the given file (and any variants) in the given {@see Filesystem}
306
	 *
307
	 * @param string $fileID
308
	 * @param Filesystem $filesystem
309
	 * @return bool True if a file was deleted
310
	 */
311
	protected function deleteFromFilesystem($fileID, Filesystem $filesystem) {
312
		$deleted = false;
313
		foreach($this->findVariants($fileID, $filesystem) as $nextID) {
314
			$filesystem->delete($nextID);
315
			$deleted = true;
316
		}
317
318
		// Truncate empty dirs
319
		$this->truncateDirectory(dirname($fileID), $filesystem);
320
321
		return $deleted;
322
	}
323
324
	/**
325
	 * Clear directory if it's empty
326
	 *
327
	 * @param string $dirname Name of directory
328
	 * @param Filesystem $filesystem
329
	 */
330
	protected function truncateDirectory($dirname, Filesystem $filesystem) {
331
		if ($dirname
332
			&& ltrim(dirname($dirname), '.')
333
			&& ! Config::inst()->get(get_class($this), 'keep_empty_dirs')
334
			&& ! $filesystem->listContents($dirname)
335
		) {
336
			$filesystem->deleteDir($dirname);
337
		}
338
	}
339
340
	/**
341
	 * Returns an iterable {@see Generator} of all files / variants for the given $fileID in the given $filesystem
342
	 * This includes the empty (no) variant.
343
	 *
344
	 * @param string $fileID ID of original file to compare with.
345
	 * @param Filesystem $filesystem
346
	 * @return Generator
347
	 */
348
	protected function findVariants($fileID, Filesystem $filesystem) {
349
		$dirname = ltrim(dirname($fileID), '.');
350
		foreach($filesystem->listContents($dirname) as $next) {
351
			if($next['type'] !== 'file') {
352
				continue;
353
			}
354
			$nextID = $next['path'];
355
			// Compare given file to target, omitting variant
356
			if($fileID === $this->removeVariant($nextID)) {
357
				yield $nextID;
358
			}
359
		}
360
	}
361
362 View Code Duplication
	public function publish($filename, $hash) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
363
		$fileID = $this->getFileID($filename, $hash);
364
		$protected = $this->getProtectedFilesystem();
365
		$public = $this->getPublicFilesystem();
366
		$this->moveBetweenFilesystems($fileID, $protected, $public);
367
	}
368
369 View Code Duplication
	public function protect($filename, $hash) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
370
		$fileID = $this->getFileID($filename, $hash);
371
		$public = $this->getPublicFilesystem();
372
		$protected = $this->getProtectedFilesystem();
373
		$this->moveBetweenFilesystems($fileID, $public, $protected);
374
	}
375
376
	/**
377
	 * Move a file (and its associative variants) between filesystems
378
	 *
379
	 * @param string $fileID
380
	 * @param Filesystem $from
381
	 * @param Filesystem $to
382
	 */
383
	protected function moveBetweenFilesystems($fileID, Filesystem $from, Filesystem $to) {
384
		foreach($this->findVariants($fileID, $from) as $nextID) {
385
			// Copy via stream
386
			$stream = $from->readStream($nextID);
387
			$to->putStream($nextID, $stream);
388
			fclose($stream);
389
			$from->delete($nextID);
390
		}
391
392
		// Truncate empty dirs
393
		$this->truncateDirectory(dirname($fileID), $from);
394
	}
395
396 View Code Duplication
	public function grant($filename, $hash) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
397
		$fileID = $this->getFileID($filename, $hash);
398
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
399
		$granted[$fileID] = true;
400
		Session::set(self::GRANTS_SESSION, $granted);
401
	}
402
403 View Code Duplication
	public function revoke($filename, $hash) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
404
		$fileID = $this->getFileID($filename, $hash);
405
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
406
		unset($granted[$fileID]);
407
		if($granted) {
408
			Session::set(self::GRANTS_SESSION, $granted);
409
		} else {
410
			Session::clear(self::GRANTS_SESSION);
411
		}
412
	}
413
414
	public function canView($filename, $hash) {
415
		$fileID = $this->getFileID($filename, $hash);
416
		if($this->getProtectedFilesystem()->has($fileID)) {
417
			return $this->isGranted($fileID);
418
		}
419
		return true;
420
	}
421
422
	/**
423
	 * Determine if a grant exists for the given FileID
424
	 *
425
	 * @param string $fileID
426
	 * @return bool
427
	 */
428
	protected function isGranted($fileID) {
429
		// Since permissions are applied to the non-variant only,
430
		// map back to the original file before checking
431
		$originalID = $this->removeVariant($fileID);
432
		$granted = Session::get(self::GRANTS_SESSION) ?: array();
433
		return !empty($granted[$originalID]);
434
	}
435
436
	/**
437
	 * get sha1 hash from stream
438
	 *
439
	 * @param resource $stream
440
	 * @return string str1 hash
441
	 */
442
	protected function getStreamSHA1($stream) {
443
		Util::rewindStream($stream);
444
		$context = hash_init('sha1');
445
		hash_update_stream($context, $stream);
446
		return hash_final($context);
447
	}
448
449
	/**
450
	 * Get stream as a file
451
	 *
452
	 * @param resource $stream
453
	 * @return string Filename of resulting stream content
454
	 * @throws Exception
455
	 */
456
	protected function getStreamAsFile($stream) {
457
		// Get temporary file and name
458
		$file = tempnam(sys_get_temp_dir(), 'ssflysystem');
459
		$buffer = fopen($file, 'w');
460
		if (!$buffer) {
461
			throw new Exception("Could not create temporary file");
462
		}
463
464
		// Transfer from given stream
465
		Util::rewindStream($stream);
466
		stream_copy_to_stream($stream, $buffer);
467
		if (! fclose($buffer)) {
468
			throw new Exception("Could not write stream to temporary file");
469
		}
470
471
		return $file;
472
	}
473
474
	/**
475
	 * Determine if this stream is seekable
476
	 *
477
	 * @param resource $stream
478
	 * @return bool True if this stream is seekable
479
	 */
480
	protected function isSeekableStream($stream) {
481
		return Util::isSeekableStream($stream);
482
	}
483
484
	/**
485
	 * Invokes the conflict resolution scheme on the given content, and invokes a callback if
486
	 * the storage request is approved.
487
	 *
488
	 * @param callable $callback Will be invoked and passed a fileID if the file should be stored
489
	 * @param string $filename Name for the resulting file
490
	 * @param string $hash SHA1 of the original file content
491
	 * @param string $variant Variant to write
492
	 * @param array $config Write options. {@see AssetStore}
493
	 * @return array Tuple associative array (Filename, Hash, Variant)
494
	 * @throws Exception
495
	 */
496
	protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = array()) {
497
		// Set default conflict resolution
498
		if(empty($config['conflict'])) {
499
			$conflictResolution = $this->getDefaultConflictResolution($variant);
500
		} else {
501
			$conflictResolution = $config['conflict'];
502
		}
503
504
		// Validate parameters
505
		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...
506
			// As variants must follow predictable naming rules, they should not be dynamically renamed
507
			throw new InvalidArgumentException("Rename cannot be used when writing variants");
508
		}
509
		if(!$filename) {
510
			throw new InvalidArgumentException("Filename is missing");
511
		}
512
		if(!$hash) {
513
			throw new InvalidArgumentException("File hash is missing");
514
		}
515
516
		$filename = $this->cleanFilename($filename);
517
		$fileID = $this->getFileID($filename, $hash, $variant);
518
519
		// Check conflict resolution scheme
520
		$resolvedID = $this->resolveConflicts($conflictResolution, $fileID);
521
		if($resolvedID !== false) {
522
			// Check if source file already exists on the filesystem
523
			$mainID = $this->getFileID($filename, $hash);
524
			$filesystem = $this->getFilesystemFor($mainID);
525
526
			// If writing a new file use the correct visibility
527
			if(!$filesystem) {
528
				// Default to public store unless requesting protected store
529
				if(isset($config['visibility']) && $config['visibility'] === self::VISIBILITY_PROTECTED) {
530
					$filesystem = $this->getProtectedFilesystem();
531
				} else {
532
					$filesystem = $this->getPublicFilesystem();
533
				}
534
			}
535
536
			// Submit and validate result
537
			$result = $callback($filesystem, $resolvedID);
538
			if(!$result) {
539
				throw new Exception("Could not save {$filename}");
540
			}
541
542
			// in case conflict resolution renamed the file, return the renamed
543
			$filename = $this->getOriginalFilename($resolvedID);
544
545
		} elseif(empty($variant)) {
546
			// If deferring to the existing file, return the sha of the existing file,
547
			// unless we are writing a variant (which has the same hash value as its original file)
548
			$stream = $this
549
				->getFilesystemFor($fileID)
550
				->readStream($fileID);
551
			$hash = $this->getStreamSHA1($stream);
552
		}
553
554
		return array(
555
			'Filename' => $filename,
556
			'Hash' => $hash,
557
			'Variant' => $variant
558
		);
559
	}
560
561
	/**
562
	 * Choose a default conflict resolution
563
	 *
564
	 * @param string $variant
565
	 * @return string
566
	 */
567
	protected function getDefaultConflictResolution($variant) {
568
		// If using new naming scheme (segment by hash) it's normally safe to overwrite files.
569
		// Variants are also normally safe to overwrite, since lazy-generation is implemented at a higher level.
570
		$legacy = $this->useLegacyFilenames();
571
		if(!$legacy || $variant) {
572
			return AssetStore::CONFLICT_OVERWRITE;
573
		}
574
575
		// Legacy behaviour is to rename
576
		return AssetStore::CONFLICT_RENAME;
577
	}
578
579
	/**
580
	 * Determine if legacy filenames should be used. These do not have hash path parts.
581
	 *
582
	 * @return bool
583
	 */
584
	protected function useLegacyFilenames() {
585
		return Config::inst()->get(get_class($this), 'legacy_filenames');
586
	}
587
588 View Code Duplication
	public function getMetadata($filename, $hash, $variant = null) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
589
		$fileID = $this->getFileID($filename, $hash, $variant);
590
		$filesystem = $this->getFilesystemFor($fileID);
591
		if($filesystem) {
592
			return $filesystem->getMetadata($fileID);
593
		}
594
		return null;
595
	}
596
597 View Code Duplication
	public function getMimeType($filename, $hash, $variant = null) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
598
		$fileID = $this->getFileID($filename, $hash, $variant);
599
		$filesystem = $this->getFilesystemFor($fileID);
600
		if($filesystem) {
601
			return $filesystem->getMimetype($fileID);
602
		}
603
		return null;
604
	}
605
606
	public function exists($filename, $hash, $variant = null) {
607
		$fileID = $this->getFileID($filename, $hash, $variant);
608
		$filesystem = $this->getFilesystemFor($fileID);
609
		return !empty($filesystem);
610
	}
611
612
	/**
613
	 * Determine the path that should be written to, given the conflict resolution scheme
614
	 *
615
	 * @param string $conflictResolution
616
	 * @param string $fileID
617
	 * @return string|false Safe filename to write to. If false, then don't write, and use existing file.
618
	 * @throws Exception
619
	 */
620
	protected function resolveConflicts($conflictResolution, $fileID) {
621
		// If overwrite is requested, simply put
622
		if($conflictResolution === AssetStore::CONFLICT_OVERWRITE) {
623
			return $fileID;
624
		}
625
626
		// Otherwise, check if this exists
627
		$exists = $this->getFilesystemFor($fileID);
628
		if(!$exists) {
629
			return $fileID;
630
		}
631
632
		// Flysystem defaults to use_existing
633
		switch($conflictResolution) {
634
			// Throw tantrum
635
			case static::CONFLICT_EXCEPTION: {
636
				throw new InvalidArgumentException("File already exists at path {$fileID}");
637
			}
638
639
			// Rename
640
			case static::CONFLICT_RENAME: {
641
				foreach($this->fileGeneratorFor($fileID) as $candidate) {
642
					if(!$this->getFilesystemFor($candidate)) {
643
						return $candidate;
644
					}
645
				}
646
647
				throw new InvalidArgumentException("File could not be renamed with path {$fileID}");
648
			}
649
650
			// Use existing file
651
			case static::CONFLICT_USE_EXISTING:
652
			default: {
653
				return false;
654
			}
655
		}
656
	}
657
658
	/**
659
	 * Get an asset renamer for the given filename.
660
	 *
661
	 * @param string $fileID Adapter specific identifier for this file/version
662
	 * @return AssetNameGenerator
663
	 */
664
	protected function fileGeneratorFor($fileID){
665
		return Injector::inst()->createWithArgs('AssetNameGenerator', array($fileID));
666
	}
667
668
	/**
669
	 * Performs filename cleanup before sending it back.
670
	 *
671
	 * This name should not contain hash or variants.
672
	 *
673
	 * @param string $filename
674
	 * @return string
675
	 */
676
	protected function cleanFilename($filename) {
677
		// Since we use double underscore to delimit variants, eradicate them from filename
678
		return preg_replace('/_{2,}/', '_', $filename);
679
	}
680
681
	/**
682
	 * Given a FileID, map this back to the original filename, trimming variant and hash
683
	 *
684
	 * @param string $fileID Adapter specific identifier for this file/version
685
	 * @return string Filename for this file, omitting hash and variant
686
	 */
687
	protected function getOriginalFilename($fileID) {
688
		// Remove variant
689
		$originalID = $this->removeVariant($fileID);
690
691
		// Remove hash (unless using legacy filenames, without hash)
692
		if($this->useLegacyFilenames()) {
693
			return $originalID;
694
		} else {
695
			return preg_replace(
696
				'/(?<hash>[a-zA-Z0-9]{10}\\/)(?<name>[^\\/]+)$/',
697
				'$2',
698
				$originalID
699
			);
700
		}
701
	}
702
703
	/**
704
	 * Remove variant from a fileID
705
	 *
706
	 * @param string $fileID
707
	 * @return string FileID without variant
708
	 */
709
	protected function removeVariant($fileID) {
710
		// Check variant
711
		if (preg_match('/^(?<before>((?<!__).)+)__(?<variant>[^\\.]+)(?<after>.*)$/', $fileID, $matches)) {
712
			return $matches['before'] . $matches['after'];
713
		}
714
		// There is no variant, so return original value
715
		return $fileID;
716
	}
717
718
	/**
719
	 * Map file tuple (hash, name, variant) to a filename to be used by flysystem
720
	 *
721
	 * The resulting file will look something like my/directory/EA775CB4D4/filename__variant.jpg
722
	 *
723
	 * @param string $filename Name of file
724
	 * @param string $hash Hash of original file
725
	 * @param string $variant (if given)
726
	 * @return string Adapter specific identifier for this file/version
727
	 */
728
	protected function getFileID($filename, $hash, $variant = null) {
729
		// Since we use double underscore to delimit variants, eradicate them from filename
730
		$filename = $this->cleanFilename($filename);
731
		$name = basename($filename);
732
733
		// Split extension
734
		$extension = null;
735
		if(($pos = strpos($name, '.')) !== false) {
736
			$extension = substr($name, $pos);
737
			$name = substr($name, 0, $pos);
738
		}
739
740
		// Unless in legacy mode, inject hash just prior to the filename
741
		if($this->useLegacyFilenames()) {
742
			$fileID = $name;
743
		} else {
744
			$fileID = substr($hash, 0, 10) . '/' . $name;
745
		}
746
747
		// Add directory
748
		$dirname = ltrim(dirname($filename), '.');
749
		if($dirname) {
750
			$fileID = $dirname . '/' . $fileID;
751
		}
752
753
		// Add variant
754
		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...
755
			$fileID .= '__' . $variant;
756
		}
757
758
		// Add extension
759
		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...
760
			$fileID .= $extension;
761
		}
762
763
		return $fileID;
764
	}
765
766
	/**
767
	 * Ensure each adapter re-generates its own server configuration files
768
	 */
769
	public static function flush() {
770
		// Ensure that this instance is constructed on flush, thus forcing
771
		// bootstrapping of necessary .htaccess / web.config files
772
		$instance = singleton('AssetStore');
773
		if ($instance instanceof FlysystemAssetStore) {
774
			$publicAdapter = $instance->getPublicFilesystem()->getAdapter();
775
			if($publicAdapter instanceof AssetAdapter) {
776
				$publicAdapter->flush();
777
			}
778
			$protectedAdapter = $instance->getProtectedFilesystem()->getAdapter();
779
			if($protectedAdapter instanceof AssetAdapter) {
780
				$protectedAdapter->flush();
781
			}
782
		}
783
	}
784
785
	public function getResponseFor($asset) {
786
		// Check if file exists
787
		$filesystem = $this->getFilesystemFor($asset);
788
		if(!$filesystem) {
789
			return $this->createMissingResponse();
790
		}
791
792
		// Block directory access
793
		if($filesystem->get($asset) instanceof Directory) {
0 ignored issues
show
Bug introduced by
The class League\Flysystem\Directory does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
794
			return $this->createDeniedResponse();
795
		}
796
797
		// Deny if file is protected and denied
798
		if($filesystem === $this->getProtectedFilesystem() && !$this->isGranted($asset)) {
799
			return $this->createDeniedResponse();
800
		}
801
802
		// Serve up file response
803
		return $this->createResponseFor($filesystem, $asset);
804
	}
805
806
	/**
807
	 * Generate an {@see HTTPResponse} for the given file from the source filesystem
808
	 * @param FilesystemInterface $flysystem
809
	 * @param string $fileID
810
	 * @return HTTPResponse
811
	 */
812
	protected function createResponseFor(FilesystemInterface $flysystem, $fileID) {
813
		// Build response body
814
		// @todo: gzip / buffer response?
815
		$body = $flysystem->read($fileID);
816
		$mime = $flysystem->getMimetype($fileID);
817
		$response = new HTTPResponse($body, 200);
818
819
		// Add headers
820
		$response->addHeader('Content-Type', $mime);
821
		$headers = Config::inst()->get(get_class($this), 'file_response_headers');
822
		foreach($headers as $header => $value) {
823
			$response->addHeader($header, $value);
824
		}
825
		return $response;
826
	}
827
828
	/**
829
	 * Generate a response for requests to a denied protected file
830
	 *
831
	 * @return HTTPResponse
832
	 */
833
	protected function createDeniedResponse() {
834
		$code = (int)Config::inst()->get(get_class($this), 'denied_response_code');
835
		return $this->createErrorResponse($code);
836
	}
837
838
	/**
839
	 * Generate a response for missing file requests
840
	 *
841
	 * @return HTTPResponse
842
	 */
843
	protected function createMissingResponse() {
844
		$code = (int)Config::inst()->get(get_class($this), 'missing_response_code');
845
		return $this->createErrorResponse($code);
846
	}
847
848
	/**
849
	 * Create a response with the given error code
850
	 *
851
	 * @param int $code
852
	 * @return HTTPResponse
853
	 */
854
	protected function createErrorResponse($code) {
855
		$response = new HTTPResponse('', $code);
856
857
		// Show message in dev
858
		if(!Director::isLive()) {
859
			$response->setBody($response->getStatusDescription());
860
		}
861
862
		return $response;
863
	}
864
}
865