Completed
Push — 3.7 ( 81b2d8...ef0909 )
by
unknown
09:42
created

Upload   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 293
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Importance

Changes 0
Metric Value
dl 0
loc 293
rs 9.52
c 0
b 0
f 0
wmc 36
lcom 1
cbo 10

13 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A getValidator() 0 3 1
A setValidator() 0 3 1
F load() 0 100 23
A loadIntoFile() 0 4 1
A setReplaceFile() 0 3 1
A getReplaceFile() 0 3 1
A validate() 0 9 2
A getFile() 0 3 1
A setFile() 0 3 1
A clearErrors() 0 4 1
A isError() 0 3 1
A getErrors() 0 3 1
1
<?php
2
/**
3
 * Manages uploads via HTML forms processed by PHP,
4
 * uploads to Silverstripe's default upload directory,
5
 * and either creates a new or uses an existing File-object
6
 * for syncing with the database.
7
 *
8
 * <b>Validation</b>
9
 *
10
 * By default, a user can upload files without extension limitations,
11
 * which can be a security risk if the webserver is not properly secured.
12
 * Use {@link setAllowedExtensions()} to limit this list,
13
 * and ensure the "assets/" directory does not execute scripts
14
 * (see http://doc.silverstripe.org/secure-development#filesystem).
15
 * {@link File::$allowed_extensions} provides a good start for a list of "safe" extensions.
16
 *
17
 * @package framework
18
 * @subpackage filesystem
19
 *
20
 * @todo Allow for non-database uploads
21
 */
22
class Upload extends Controller {
23
24
	private static $allowed_actions = array(
25
		'index',
26
		'load'
27
	);
28
29
	/**
30
	 * A File object
31
	 *
32
	 * @var File
33
	 */
34
	protected $file;
35
36
	/**
37
	 * Validator for this upload field
38
	 *
39
	 * @var Upload_Validator
40
	 */
41
	protected $validator;
42
43
	/**
44
	 * Information about the temporary file produced
45
	 * by the PHP-runtime.
46
	 *
47
	 * @var array
48
	 */
49
	protected $tmpFile;
50
51
	/**
52
	 * Replace an existing file rather than renaming the new one.
53
	 *
54
	 * @var boolean
55
	 */
56
	protected $replaceFile;
57
58
	/**
59
	 * Processing errors that can be evaluated,
60
	 * e.g. by Form-validation.
61
	 *
62
	 * @var array
63
	 */
64
	protected $errors = array();
65
66
	/**
67
	 * A foldername relative to /assets,
68
	 * where all uploaded files are stored by default.
69
	 *
70
	 * @config
71
	 * @var string
72
	 */
73
	private static $uploads_folder = "Uploads";
74
75
	/**
76
	 * A prefix for the version number added to an uploaded file
77
	 * when a file with the same name already exists.
78
	 * Example using no prefix: IMG001.jpg becomes IMG2.jpg
79
	 * Example using '-v' prefix: IMG001.jpg becomes IMG001-v2.jpg
80
	 *
81
	 * @config
82
	 * @var string
83
	 */
84
	private static $version_prefix = ''; // a default value will be introduced in SS4.0
85
86
	public function __construct() {
87
		parent::__construct();
88
		$this->validator = Injector::inst()->create('Upload_Validator');
89
		$this->replaceFile = self::config()->replaceFile;
0 ignored issues
show
Documentation introduced by
The property replaceFile does not exist on object<Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
90
	}
91
92
	/**
93
	 * Get current validator
94
	 *
95
	 * @return Upload_Validator
96
	 */
97
	public function getValidator() {
98
		return $this->validator;
99
	}
100
101
	/**
102
	 * Set a different instance than {@link Upload_Validator}
103
	 * for this upload session.
104
	 *
105
	 * @param object $validator
106
	 */
107
	public function setValidator($validator) {
108
		$this->validator = $validator;
109
	}
110
111
	/**
112
	 * Save an file passed from a form post into this object.
113
	 * File names are filtered through {@link FileNameFilter}, see class documentation
114
	 * on how to influence this behaviour.
115
	 *
116
	 * @param $tmpFile array Indexed array that PHP generated for every file it uploads.
117
	 * @param $folderPath string Folder path relative to /assets
118
	 * @return Boolean|string Either success or error-message.
119
	 */
120
	public function load($tmpFile, $folderPath = false) {
121
		$this->clearErrors();
122
123
		if(!$folderPath) $folderPath = $this->config()->uploads_folder;
0 ignored issues
show
Documentation introduced by
The property uploads_folder does not exist on object<Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
124
125
		if(!is_array($tmpFile)) {
126
			user_error("Upload::load() Not passed an array.  Most likely, the form hasn't got the right enctype",
127
				E_USER_ERROR);
128
		}
129
130
		if(!$tmpFile['size']) {
131
			$this->errors[] = _t('File.NOFILESIZE', 'File size is zero bytes.');
132
			return false;
133
		}
134
135
		$valid = $this->validate($tmpFile);
136
		if(!$valid) return false;
137
138
		// @TODO This puts a HUGE limitation on files especially when lots
139
		// have been uploaded.
140
		$base = Director::baseFolder();
141
		$parentFolder = Folder::find_or_make($folderPath);
142
143
		// Generate default filename
144
		$nameFilter = FileNameFilter::create();
145
		$file = $nameFilter->filter($tmpFile['name']);
146
		$fileName = basename($file);
147
148
		$relativeFolderPath = $parentFolder
149
				? $parentFolder->getRelativePath()
150
				: ASSETS_DIR . '/';
151
		$relativeFilePath = $relativeFolderPath . $fileName;
152
153
		// Create a new file record (or try to retrieve an existing one)
154
		if(!$this->file) {
155
			$fileClass = File::get_class_for_file_extension(pathinfo($tmpFile['name'], PATHINFO_EXTENSION));
156
			$this->file = new $fileClass();
157
		}
158
		if(!$this->file->ID && $this->replaceFile) {
159
			$fileClass = $this->file->class;
160
			$file = File::get()
161
				->filter(array(
162
					'ClassName' => $fileClass,
163
					'Name' => $fileName,
164
					'ParentID' => $parentFolder ? $parentFolder->ID : 0
165
				))->First();
166
			if($file) {
167
				$this->file = $file;
168
			}
169
		}
170
171
		// if filename already exists, version the filename (e.g. test.gif to test2.gif, test2.gif to test3.gif)
172
		if(!$this->replaceFile) {
173
			$fileSuffixArray = explode('.', $fileName);
174
			$fileTitle = array_shift($fileSuffixArray);
175
			$fileSuffix = !empty($fileSuffixArray)
176
					? '.' . implode('.', $fileSuffixArray)
177
					: null;
178
179
			// make sure files retain valid extensions
180
			$oldFilePath = $relativeFilePath;
181
			$relativeFilePath = $relativeFolderPath . $fileTitle . $fileSuffix;
182
			if($oldFilePath !== $relativeFilePath) {
183
				user_error("Couldn't fix $relativeFilePath", E_USER_ERROR);
184
			}
185
			while(file_exists("$base/$relativeFilePath")) {
186
				$i = isset($i) ? ($i+1) : 2;
187
				$oldFilePath = $relativeFilePath;
188
189
				$prefix = $this->config()->version_prefix;
0 ignored issues
show
Documentation introduced by
The property version_prefix does not exist on object<Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
190
				$pattern = '/' . preg_quote($prefix) . '([0-9]+$)/';
191
				if(preg_match($pattern, $fileTitle, $matches)) {
192
					$fileTitle = preg_replace($pattern, $prefix . ($matches[1] + 1), $fileTitle);
193
				} else {
194
					$fileTitle .= $prefix . $i;
195
				}
196
				$relativeFilePath = $relativeFolderPath . $fileTitle . $fileSuffix;
197
198
				if($oldFilePath == $relativeFilePath && $i > 2) {
199
					user_error("Couldn't fix $relativeFilePath with $i tries", E_USER_ERROR);
200
				}
201
			}
202
		} else {
203
			//reset the ownerID to the current member when replacing files
204
			$this->file->OwnerID = (Member::currentUser() ? Member::currentUser()->ID : 0);
205
		}
206
207
		if(file_exists($tmpFile['tmp_name']) && copy($tmpFile['tmp_name'], "$base/$relativeFilePath")) {
208
			$this->file->ParentID = $parentFolder ? $parentFolder->ID : 0;
209
			// This is to prevent it from trying to rename the file
210
			$this->file->Name = basename($relativeFilePath);
211
			$this->file->write();
212
			$this->file->onAfterUpload();
213
			$this->extend('onAfterLoad', $this->file, $tmpFile);   //to allow extensions to e.g. create a version after an upload
214
			return true;
215
		} else {
216
			$this->errors[] = _t('File.NOFILESIZE', 'File size is zero bytes.');
217
			return false;
218
		}
219
	}
220
221
	/**
222
	 * Load temporary PHP-upload into File-object.
223
	 *
224
	 * @param array $tmpFile
225
	 * @param File $file
226
	 * @return Boolean
227
	 */
228
	public function loadIntoFile($tmpFile, $file, $folderPath = false) {
229
		$this->file = $file;
230
		return $this->load($tmpFile, $folderPath);
231
	}
232
233
	/**
234
	 * @return Boolean
235
	 */
236
	public function setReplaceFile($bool) {
237
		$this->replaceFile = $bool;
238
	}
239
240
	/**
241
	 * @return Boolean
242
	 */
243
	public function getReplaceFile() {
244
		return $this->replaceFile;
245
	}
246
247
	/**
248
	 * Container for all validation on the file
249
	 * (e.g. size and extension restrictions).
250
	 * Is NOT connected to the {Validator} classes,
251
	 * please have a look at {FileField->validate()}
252
	 * for an example implementation of external validation.
253
	 *
254
	 * @param array $tmpFile
255
	 * @return boolean
256
	 */
257
	public function validate($tmpFile) {
258
		$validator = $this->validator;
259
		$validator->setTmpFile($tmpFile);
260
		$isValid = $validator->validate();
261
		if($validator->getErrors()) {
262
			$this->errors = array_merge($this->errors, $validator->getErrors());
263
		}
264
		return $isValid;
265
	}
266
267
	/**
268
	 * Get file-object, either generated from {load()},
269
	 * or manually set.
270
	 *
271
	 * @return File
272
	 */
273
	public function getFile() {
274
		return $this->file;
275
	}
276
277
	/**
278
	 * Set a file-object (similiar to {loadIntoFile()})
279
	 *
280
	 * @param File $file
281
	 */
282
	public function setFile($file) {
283
		$this->file = $file;
284
	}
285
286
	/**
287
	 * Clear out all errors (mostly set by {loadUploaded()})
288
	 * including the validator's errors
289
	 */
290
	public function clearErrors() {
291
		$this->errors = array();
292
		$this->validator->clearErrors();
293
	}
294
295
	/**
296
	 * Determines wether previous operations caused an error.
297
	 *
298
	 * @return boolean
299
	 */
300
	public function isError() {
301
		return (count($this->errors));
302
	}
303
304
	/**
305
	 * Return all errors that occurred while processing so far
306
	 * (mostly set by {loadUploaded()})
307
	 *
308
	 * @return array
309
	 */
310
	public function getErrors() {
311
		return $this->errors;
312
	}
313
314
}
315
316
/**
317
 * @package framework
318
 * @subpackage filesystem
319
 */
320
class Upload_Validator {
321
322
	/**
323
	* Contains a list of the max file sizes shared by
324
	* all upload fields. This is then duplicated into the
325
	* "allowedMaxFileSize" instance property on construct.
326
	*
327
	* @config
328
	* @var array
329
	*/
330
	private static $default_max_file_size = array();
331
332
	/**
333
	 * Information about the temporary file produced
334
	 * by the PHP-runtime.
335
	 *
336
	 * @var array
337
	 */
338
	protected $tmpFile;
339
340
	protected $errors = array();
341
342
	/**
343
	 * Restrict filesize for either all filetypes
344
	 * or a specific extension, with extension-name
345
	 * as array-key and the size-restriction in bytes as array-value.
346
	 *
347
	 * @var array
348
	 */
349
	public $allowedMaxFileSize = array();
350
351
	/**
352
	 * @var array Collection of extensions.
353
	 * Extension-names are treated case-insensitive.
354
	 *
355
	 * Example:
356
	 * <code>
357
	 * 	array("jpg","GIF")
358
	 * </code>
359
	 */
360
	public $allowedExtensions = array();
361
362
	/**
363
	 * Return all errors that occurred while validating
364
	 * the temporary file.
365
	 *
366
	 * @return array
367
	 */
368
	public function getErrors() {
369
		return $this->errors;
370
	}
371
372
	/**
373
	 * Clear out all errors
374
	 */
375
	public function clearErrors() {
376
		$this->errors = array();
377
	}
378
379
	/**
380
	 * Set information about temporary file produced by PHP.
381
	 * @param array $tmpFile
382
	 */
383
	public function setTmpFile($tmpFile) {
384
		$this->tmpFile = $tmpFile;
385
	}
386
387
	/**
388
	 * Get maximum file size for all or specified file extension.
389
	 *
390
	 * @param string $ext
391
	 * @return int Filesize in bytes
392
	 */
393
	public function getAllowedMaxFileSize($ext = null) {
394
395
		// Check if there is any defined instance max file sizes
396
		if (empty($this->allowedMaxFileSize)) {
397
			// Set default max file sizes if there isn't
398
			$fileSize = Config::inst()->get('Upload_Validator', 'default_max_file_size');
399
			if (!empty($fileSize)) {
400
				$this->setAllowedMaxFileSize($fileSize);
401
			} else {
402
				// When no default is present, use maximum set by PHP
403
				$maxUpload = File::ini2bytes(ini_get('upload_max_filesize'));
404
				$maxPost = File::ini2bytes(ini_get('post_max_size'));
405
				$this->setAllowedMaxFileSize(min($maxUpload, $maxPost));
406
			}
407
		}
408
409
		$ext = strtolower($ext);
410
		if ($ext) {
411
			if (isset($this->allowedMaxFileSize[$ext])) {
412
				return $this->allowedMaxFileSize[$ext];
413
			}
414
415
			$category = File::get_app_category($ext);
416
			if ($category && isset($this->allowedMaxFileSize['[' . $category . ']'])) {
417
				return $this->allowedMaxFileSize['[' . $category . ']'];
418
			}
419
		}
420
421
		return (isset($this->allowedMaxFileSize['*'])) ? $this->allowedMaxFileSize['*'] : false;
422
	}
423
424
	/**
425
	 * Set filesize maximums (in bytes or INI format).
426
	 * Automatically converts extensions to lowercase
427
	 * for easier matching.
428
	 *
429
	 * Example:
430
	 * <code>
431
	 * array('*' => 200, 'jpg' => 1000, '[doc]' => '5m')
432
	 * </code>
433
	 *
434
	 * @param array|int $rules
435
	 */
436
	public function setAllowedMaxFileSize($rules) {
437
		if(is_array($rules) && count($rules)) {
438
			// make sure all extensions are lowercase
439
			$rules = array_change_key_case($rules, CASE_LOWER);
440
			$finalRules = array();
441
			$tmpSize = 0;
442
443
			foreach ($rules as $rule => $value) {
444
				if (is_numeric($value)) {
445
					$tmpSize = $value;
446
				} else {
447
					$tmpSize = File::ini2bytes($value);
448
				}
449
450
				$finalRules[$rule] = (int)$tmpSize;
451
			}
452
453
			$this->allowedMaxFileSize = $finalRules;
454
		} elseif(is_string($rules)) {
455
			$this->allowedMaxFileSize['*'] = File::ini2bytes($rules);
456
		} elseif((int) $rules > 0) {
457
			$this->allowedMaxFileSize['*'] = (int)$rules;
458
		}
459
	}
460
461
	/**
462
	 * @return array
463
	 */
464
	public function getAllowedExtensions() {
465
		return $this->allowedExtensions;
466
	}
467
468
	/**
469
	 * Limit allowed file extensions. Empty by default, allowing all extensions.
470
	 * To allow files without an extension, use an empty string.
471
	 * See {@link File::$allowed_extensions} to get a good standard set of
472
	 * extensions that are typically not harmful in a webserver context.
473
	 * See {@link setAllowedMaxFileSize()} to limit file size by extension.
474
	 *
475
	 * @param array $rules List of extensions
476
	 */
477
	public function setAllowedExtensions($rules) {
478
		if(!is_array($rules)) return false;
479
480
		// make sure all rules are lowercase
481
		foreach($rules as &$rule) $rule = strtolower($rule);
482
483
		$this->allowedExtensions = $rules;
484
	}
485
486
	/**
487
	 * Determines if the bytesize of an uploaded
488
	 * file is valid - can be defined on an
489
	 * extension-by-extension basis in {@link $allowedMaxFileSize}
490
	 *
491
	 * @return boolean
492
	 */
493
	public function isValidSize() {
494
		$pathInfo = pathinfo($this->tmpFile['name']);
495
		$extension = isset($pathInfo['extension']) ? strtolower($pathInfo['extension']) : null;
496
		$maxSize = $this->getAllowedMaxFileSize($extension);
497
		return (!$this->tmpFile['size'] || !$maxSize || (int) $this->tmpFile['size'] < $maxSize);
498
	}
499
500
	/**
501
	 * Determines if the temporary file has a valid extension
502
	 * An empty string in the validation map indicates files without an extension.
503
	 * @return boolean
504
	 */
505
	public function isValidExtension() {
506
		$pathInfo = pathinfo($this->tmpFile['name']);
507
508
		// Special case for filenames without an extension
509
		if(!isset($pathInfo['extension'])) {
510
			return in_array('', $this->allowedExtensions, true);
511
		} else {
512
			return (!count($this->allowedExtensions)
513
				|| in_array(strtolower($pathInfo['extension']), $this->allowedExtensions));
514
		}
515
	}
516
517
	/**
518
	 * Run through the rules for this validator checking against
519
	 * the temporary file set by {@link setTmpFile()} to see if
520
	 * the file is deemed valid or not.
521
	 *
522
	 * @return boolean
523
	 */
524
	public function validate() {
525
		// we don't validate for empty upload fields yet
526
		if(!isset($this->tmpFile['name']) || empty($this->tmpFile['name'])) return true;
527
528
		$isRunningTests = (class_exists('SapphireTest', false) && SapphireTest::is_running_test());
529
		if(isset($this->tmpFile['tmp_name']) && !is_uploaded_file($this->tmpFile['tmp_name']) && !$isRunningTests) {
530
			$this->errors[] = _t('File.NOVALIDUPLOAD', 'File is not a valid upload');
531
			return false;
532
		}
533
534
		$pathInfo = pathinfo($this->tmpFile['name']);
535
		// filesize validation
536
		if(!$this->isValidSize()) {
537
			$ext = (isset($pathInfo['extension'])) ? $pathInfo['extension'] : '';
538
			$arg = File::format_size($this->getAllowedMaxFileSize($ext));
539
			$this->errors[] = _t(
540
				'File.TOOLARGE',
541
				'File size is too large, maximum {size} allowed',
542
				'Argument 1: File size (e.g. 1MB)',
543
				array('size' => $arg)
544
			);
545
			return false;
546
		}
547
548
		// extension validation
549
		if(!$this->isValidExtension()) {
550
			$this->errors[] = _t(
551
				'File.INVALIDEXTENSION',
552
				'Extension is not allowed (valid: {extensions})',
553
				'Argument 1: Comma-separated list of valid extensions',
554
				array('extensions' => wordwrap(implode(', ', $this->allowedExtensions)))
555
			);
556
			return false;
557
		}
558
559
		return true;
560
	}
561
562
}
563