1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie\HTTP;
13
14 use ICanBoogie\Accessor\AccessorTrait;
15 use ICanBoogie\ToArray;
16
17 /**
18 * Representation of a POST file.
19 *
20 * @property-read string $name Name of the file.
21 * @property-read string $type MIME type of the file.
22 * @property-read string $size Size of the file.
23 * @property-read string $error Error code, one of `UPLOAD_ERR_*`.
24 * @property-read string $error_message A formatted message representing the error.
25 * @property-read string $pathname Pathname of the file.
26 * @property-read string $extension The extension of the file. If any, the dot is included e.g.
27 * ".zip".
28 * @property-read string $unsuffixed_name The name of the file without its extension.
29 * @property-read bool $is_uploaded `true` if the file is uploaded, `false` otherwise.
30 * @property-read bool $is_valid `true` if the file is valid, `false` otherwise.
31 * See: {@link is_valid()}.
32 */
33 class File implements ToArray
34 {
35 use AccessorTrait;
36
37 const MOVE_OVERWRITE = true;
38 const MOVE_NO_OVERWRITE = false;
39
40 /**
41 * Creates a {@link File} instance.
42 *
43 * @param array|string $properties_or_name An array of properties or a file identifier.
44 *
45 * @return File
46 */
47 static public function from($properties_or_name)
48 {
49 $properties = [];
50
51 if (is_string($properties_or_name))
52 {
53 $properties = isset($_FILES[$properties_or_name])
54 ? $_FILES[$properties_or_name]
55 : [ 'name' => basename($properties_or_name) ];
56 }
57 else if (is_array($properties_or_name))
58 {
59 $properties = $properties_or_name;
60 }
61
62 $properties = self::filter_initial_properties($properties);
63
64 return new static($properties);
65 }
66
67 /**
68 * Keeps only initial properties.
69 *
70 * @param array $properties
71 *
72 * @return array
73 */
74 static private function filter_initial_properties(array $properties)
75 {
76 static $initial_properties = [ 'name', 'type', 'size', 'tmp_name', 'error', 'pathname' ];
77
78 return array_intersect_key($properties, array_combine($initial_properties, $initial_properties));
79 }
80
81 /**
82 * Format a string.
83 *
84 * @param string $format The format of the string.
85 * @param array $args The arguments.
86 * @param array $options Some options.
87 *
88 * @return \ICanBoogie\FormattedString|\ICanBoogie\I18n\FormattedString|string
89 */
90 static private function format($format, array $args = [], array $options = [])
91 {
92 if (class_exists('ICanBoogie\I18n\FormattedString', true))
93 {
94 return new \ICanBoogie\I18n\FormattedString($format, $args, $options); // @codeCoverageIgnore
95 }
96
97 if (class_exists('ICanBoogie\FormattedString', true))
98 {
99 return new \ICanBoogie\FormattedString($format, $args, $options);
100 }
101
102 return \ICanBoogie\format($format, $args); // @codeCoverageIgnore
103 }
104
105 /*
106 * Instance
107 */
108
109 /**
110 * Name of the file.
111 *
112 * @var string
113 */
114 protected $name;
115
116 /**
117 * Returns the name of the file.
118 *
119 * @return string
120 */
121 protected function get_name()
122 {
123 return $this->name;
124 }
125
126 /**
127 * Returns the name of the file, without its extension.
128 *
129 * @return string
130 */
131 protected function get_unsuffixed_name()
132 {
133 return $this->name ? basename($this->name, $this->extension) : null;
134 }
135
136 protected $type;
137
138 /**
139 * Returns the type of the file.
140 *
141 * If the {@link $type} property was not defined during construct, the type
142 * is guessed from the name or the pathname of the file.
143 *
144 * @return string|null The MIME type of the file, or `null` if it cannot be determined.
145 */
146 protected function get_type()
147 {
148 if (!empty($this->type))
149 {
150 return $this->type;
151 }
152
153 if (!$this->pathname && !$this->tmp_name)
154 {
155 return null;
156 }
157
158 return FileInfo::resolve_type($this->pathname ?: $this->tmp_name);
159 }
160
161 protected $size;
162
163 /**
164 * Returns the size of the file.
165 *
166 * If the {@link $size} property was not defined during construct, the size
167 * is guessed using the pathname of the file. If the pathname is not available the method
168 * returns `null`.
169 *
170 * @return int|false The size of the file or `false` if it cannot be determined.
171 */
172 protected function get_size()
173 {
174 if (!empty($this->size))
175 {
176 return $this->size;
177 }
178
179 if ($this->pathname)
180 {
181 return filesize($this->pathname);
182 }
183
184 return null;
185 }
186
187 protected $tmp_name;
188
189 protected $error;
190
191 /**
192 * Whether the file is valid.
193 *
194 * A file is considered valid if it has no error code, if it has a size,
195 * if it has either a temporary name or a pathname and that the file actually exists.
196 *
197 * @return boolean `true` if the file is valid, `false` otherwise.
198 */
199 protected function get_is_valid()
200 {
201 return !$this->error
202 && $this->size
203 && ($this->tmp_name || ($this->pathname && file_exists($this->pathname)));
204 }
205
206 protected $pathname;
207
208 /**
209 * Returns the pathname of the file.
210 *
211 * **Note:** If the {@link $pathname} property is empty, the {@link $tmp_name} property
212 * is returned.
213 *
214 * @return string
215 */
216 protected function get_pathname()
217 {
218 return $this->pathname ?: $this->tmp_name;
219 }
220
221 protected function __construct(array $properties)
222 {
223 foreach ($properties as $property => $value)
224 {
225 $this->$property = $value;
226 }
227
228 if (!$this->name && $this->pathname)
229 {
230 $this->name = basename($this->pathname);
231 }
232
233 if (empty($this->type))
234 {
235 unset($this->type);
236 }
237
238 if (empty($this->size))
239 {
240 unset($this->size);
241 }
242 }
243
244 /**
245 * Returns an array representation of the instance.
246 *
247 * The following properties are exported:
248 *
249 * - {@link $name}
250 * - {@link $unsuffixed_name}
251 * - {@link $extension}
252 * - {@link $type}
253 * - {@link $size}
254 * - {@link $pathname}
255 * - {@link $error}
256 * - {@link $error_message}
257 *
258 * @return array
259 */
260 public function to_array()
261 {
262 $error_message = $this->error_message;
263
264 if ($error_message !== null)
265 {
266 $error_message = (string) $error_message;
267 }
268
269 return [
270
271 'name' => $this->name,
272 'unsuffixed_name' => $this->unsuffixed_name,
273 'extension' => $this->extension,
274 'type' => $this->type,
275 'size' => $this->size,
276 'pathname' => $this->pathname,
277 'error' => $this->error,
278 'error_message' => $error_message
279
280 ];
281 }
282
283 /**
284 * Returns the error code.
285 *
286 * @return string
287 */
288 protected function get_error()
289 {
290 return $this->error;
291 }
292
293 /**
294 * Returns the message associated with the error.
295 *
296 * @return \ICanBoogie\I18n\FormattedString|\ICanBoogie\FormattedString|string|null
297 */
298 protected function get_error_message()
299 {
300 switch ($this->error)
301 {
302 case UPLOAD_ERR_OK:
303
304 return null;
305
306 case UPLOAD_ERR_INI_SIZE:
307
308 return $this->format("Maximum file size is :size Mb", [ ':size' => (int) ini_get('upload_max_filesize') ]);
309
310 case UPLOAD_ERR_FORM_SIZE:
311
312 return $this->format("Maximum file size is :size Mb", [ ':size' => 'MAX_FILE_SIZE' ]);
313
314 case UPLOAD_ERR_PARTIAL:
315
316 return $this->format("The uploaded file was only partially uploaded.");
317
318 case UPLOAD_ERR_NO_FILE:
319
320 return $this->format("No file was uploaded.");
321
322 case UPLOAD_ERR_NO_TMP_DIR:
323
324 return $this->format("Missing a temporary folder.");
325
326 case UPLOAD_ERR_CANT_WRITE:
327
328 return $this->format("Failed to write file to disk.");
329
330 case UPLOAD_ERR_EXTENSION:
331
332 return $this->format("A PHP extension stopped the file upload.");
333
334 default:
335
336 return $this->format("An error has occurred.");
337 }
338 }
339
340 /**
341 * Returns the extension of the file, if any.
342 *
343 * **Note:** The extension includes the dot e.g. ".zip". The extension is always in lower case.
344 *
345 * @return string|null
346 */
347 protected function get_extension()
348 {
349 $extension = pathinfo($this->name, PATHINFO_EXTENSION);
350
351 if (!$extension)
352 {
353 return null;
354 }
355
356 return '.' . strtolower($extension);
357 }
358
359 /**
360 * Checks if a file is uploaded.
361 *
362 * @return boolean `true` if the file is uploaded, `false` otherwise.
363 */
364 protected function get_is_uploaded()
365 {
366 return $this->tmp_name && is_uploaded_file($this->tmp_name);
367 }
368
369 /**
370 * Checks if the file matches a MIME class, a MIME type, or a file extension.
371 *
372 * @param string|array $type The type can be a MIME class (e.g. "image"),
373 * a MIME type (e.g. "image/png"), or an extensions (e.g. ".zip"). An array can be used to
374 * check if a file matches multiple type e.g. `[ "image", ".mp3" ]`, which matches any type
375 * of image or files with the ".mp3" extension.
376 *
377 * @return bool `true` if the file matches (or `$type` is empty), `false` otherwise.
378 */
379 public function match($type)
380 {
381 if (!$type)
382 {
383 return true;
384 }
385
386 if (is_array($type))
387 {
388 return $this->match_multiple($type);
389 }
390
391 if ($type{0} === '.')
392 {
393 return $type === $this->extension;
394 }
395
396 if (strpos($type, '/') === false)
397 {
398 return (bool) preg_match('#^' . preg_quote($type) . '/#', $this->type);
399 }
400
401 return $type === $this->type;
402 }
403
404 /**
405 * Checks if the file matches one of the types in the list.
406 *
407 * @param array $type_list
408 *
409 * @return bool `true` if the file matches, `false` otherwise.
410 */
411 private function match_multiple(array $type_list)
412 {
413 foreach ($type_list as $type)
414 {
415 if ($this->match($type))
416 {
417 return true;
418 }
419 }
420
421 return false;
422 }
423
424 /**
425 * Moves the file.
426 *
427 * @param string $destination Pathname to the destination file.
428 * @param bool $overwrite Use {@link MOVE_OVERWRITE} to delete the destination before the file
429 * is moved. Defaults to {@link MOVE_NO_OVERWRITE}.
430 *
431 * @throws \Exception if the file failed to be moved.
432 */
433 public function move($destination, $overwrite = self::MOVE_NO_OVERWRITE)
434 {
435 if (file_exists($destination))
436 {
437 if (!$overwrite)
438 {
439 throw new \Exception("The destination file already exists: $destination.");
440 }
441
442 unlink($destination);
443 }
444
445 if ($this->pathname)
446 {
447 if (!rename($this->pathname, $destination))
448 {
449 throw new \Exception("Unable to move file to destination: $destination."); // @codeCoverageIgnore
450 }
451 }
452 else
453 {
454 if (!move_uploaded_file($this->tmp_name, $destination))
455 {
456 throw new \Exception("Unable to move file to destination: $destination.");
457 }
458 }
459
460 $this->pathname = $destination;
461 }
462 }
463