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