• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / HTTP / 11879808822

17 Nov 2024 02:50PM UTC coverage: 91.111% (-0.7%) from 91.784%
11879808822

push

github

olvlvl
Use PHPStan 2.0

34 of 38 new or added lines in 4 files covered. (89.47%)

5 existing lines in 1 file now uncovered.

861 of 945 relevant lines covered (91.11%)

20.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.76
/lib/File.php
1
<?php
2

3
namespace ICanBoogie\HTTP;
4

5
use ICanBoogie\Accessor\AccessorTrait;
6
use ICanBoogie\FormattedString;
7
use ICanBoogie\ToArray;
8
use Throwable;
9

10
use function array_fill_keys;
11
use function array_intersect_key;
12
use function basename;
13
use function class_exists;
14
use function file_exists;
15
use function ICanBoogie\format;
16
use function is_array;
17
use function is_string;
18
use function is_uploaded_file;
19
use function move_uploaded_file;
20
use function pathinfo;
21
use function preg_match;
22
use function rename;
23
use function strtolower;
24
use function unlink;
25

26
/**
27
 * Representation of a POST file.
28
 *
29
 * @property-read string $name Name of the file.
30
 * @property-read string $type MIME type of the file.
31
 * @property-read int|false $size Size of the file.
32
 * @property-read int|null $error Error code, one of `UPLOAD_ERR_*`.
33
 * @property-read FormattedString|null $error_message A formatted message representing the error.
34
 * @property-read string $pathname Pathname of the file.
35
 * @property-read string $extension The extension of the file. If any, the dot is included e.g.
36
 * ".zip".
37
 * @property-read string $unsuffixed_name The name of the file without its extension.
38
 * @property-read bool $is_uploaded `true` if the file is uploaded, `false` otherwise.
39
 * @property-read bool $is_valid `true` if the file is valid, `false` otherwise.
40
 * See: {@see get_is_valid()}.
41
 */
42
class File implements ToArray, FileOptions
43
{
44
    /**
45
     * @uses get_name
46
     * @uses get_unsuffixed_name
47
     * @uses get_type
48
     * @uses get_size
49
     * @uses get_error
50
     * @uses get_error_message
51
     * @uses get_is_valid
52
     * @uses get_pathname
53
     * @uses get_extension
54
     * @uses get_is_uploaded
55
     */
56
    use AccessorTrait;
57

58
    public const MOVE_OVERWRITE = true;
59
    public const MOVE_NO_OVERWRITE = false;
60

61
    private const INITIAL_PROPERTIES = [
62

63
        self::OPTION_NAME,
64
        self::OPTION_TYPE,
65
        self::OPTION_SIZE,
66
        self::OPTION_TMP_NAME,
67
        self::OPTION_ERROR,
68
        self::OPTION_PATHNAME,
69

70
    ];
71

72
    /**
73
     * Creates a {@see File} instance.
74
     *
75
     * @param array|string $properties_or_name An array of properties or a file identifier.
76
     */
77
    public static function from(array|string $properties_or_name): File
78
    {
79
        $properties = [];
65✔
80

81
        if (is_string($properties_or_name)) {
65✔
82
            $properties = $_FILES[$properties_or_name]
2✔
83
                ?? [ self::OPTION_NAME => basename($properties_or_name) ];
2✔
84
        } elseif (is_array($properties_or_name)) {
63✔
85
            $properties = $properties_or_name;
63✔
86
        }
87

88
        $properties = self::filter_initial_properties($properties);
65✔
89

90
        return new self($properties);
65✔
91
    }
92

93
    /**
94
     * Keeps only initial properties.
95
     *
96
     * @param array $properties
97
     *
98
     * @return array
99
     */
100
    private static function filter_initial_properties(array $properties): array
101
    {
102
        return array_intersect_key($properties, array_fill_keys(self::INITIAL_PROPERTIES, true));
65✔
103
    }
104

105
    /**
106
     * Format a string.
107
     *
108
     * @param string $format The format of the string.
109
     * @param array $args The arguments.
110
     *
111
     * @return FormattedString|string
112
     */
113
    private static function format(
114
        string $format,
115
        array $args = [],
116
    ): string|FormattedString {
117
        if (class_exists(FormattedString::class)) {
10✔
118
            return new FormattedString($format, $args);
10✔
119
        }
120

121
        return format($format, $args); // @codeCoverageIgnore
122
    }
123

124
    /*
125
     * Instance
126
     */
127

128
    /**
129
     * Name of the file.
130
     *
131
     * @var string|null
132
     */
133
    private ?string $name = null;
134

135
    protected function get_name(): ?string
136
    {
137
        return $this->name;
3✔
138
    }
139

140
    protected function get_unsuffixed_name(): ?string
141
    {
142
        return $this->name ? basename($this->name, $this->extension) : null;
5✔
143
    }
144

145
    private $type;
146

147
    /**
148
     * Returns the type of the file.
149
     *
150
     * If the {@see $type} property was not defined during construct, the type
151
     * is guessed from the name or the pathname of the file.
152
     *
153
     * @return string|null The MIME type of the file, or `null` if it cannot be determined.
154
     */
155
    protected function get_type(): ?string
156
    {
157
        if (!empty($this->type)) {
15✔
158
            return $this->type;
1✔
159
        }
160

161
        if (!$this->pathname && !$this->tmp_name) {
14✔
162
            return null;
2✔
163
        }
164

165
        return FileInfo::resolve_type($this->pathname ?: $this->tmp_name);
12✔
166
    }
167

168
    private $size;
169

170
    /**
171
     * Returns the size of the file.
172
     *
173
     * If the {@see $size} property was not defined during construct, the size
174
     * is guessed using the pathname of the file. If the pathname is not available the method
175
     * returns `null`.
176
     *
177
     * @return int|false The size of the file or `false` if it cannot be determined.
178
     */
179
    protected function get_size()
180
    {
181
        if (!empty($this->size)) {
6✔
182
            return $this->size;
1✔
183
        }
184

185
        if ($this->pathname) {
5✔
186
            return \filesize($this->pathname);
3✔
187
        }
188

189
        return false;
2✔
190
    }
191

192
    private $tmp_name;
193

194
    private ?int $error = null;
195

196
    protected function get_error(): ?int
197
    {
198
        return $this->error;
12✔
199
    }
200

201
    /**
202
     * Returns the message associated with the error.
203
     */
204
    protected function get_error_message(): ?FormattedString
205
    {
206
        switch ($this->error) {
13✔
207
            case UPLOAD_ERR_OK:
13✔
208
                return null;
3✔
209

210
            case UPLOAD_ERR_INI_SIZE:
10✔
211
                return $this->format("Maximum file size is :size Mb", [
1✔
212
                    ':size' => (int)ini_get('upload_max_filesize'),
1✔
213
                ]);
1✔
214

215
            case UPLOAD_ERR_FORM_SIZE:
9✔
216
                return $this->format("Maximum file size is :size Mb", [
1✔
217
                    ':size' => 'MAX_FILE_SIZE',
1✔
218
                ]);
1✔
219

220
            case UPLOAD_ERR_PARTIAL:
8✔
221
                return $this->format("The uploaded file was only partially uploaded.");
1✔
222

223
            case UPLOAD_ERR_NO_FILE:
7✔
224
                return $this->format("No file was uploaded.");
2✔
225

226
            case UPLOAD_ERR_NO_TMP_DIR:
5✔
227
                return $this->format("Missing a temporary folder.");
1✔
228

229
            case UPLOAD_ERR_CANT_WRITE:
4✔
230
                return $this->format("Failed to write file to disk.");
2✔
231

232
            case UPLOAD_ERR_EXTENSION:
2✔
233
                return $this->format("A PHP extension stopped the file upload.");
1✔
234

235
            default:
236
                return $this->format("An error has occurred.");
1✔
237
        }
238
    }
239

240
    /**
241
     * Whether the file is valid.
242
     *
243
     * A file is considered valid if it has no error code, if it has a size,
244
     * if it has either a temporary name or a pathname and that the file actually exists.
245
     */
246
    protected function get_is_valid(): bool
247
    {
248
        return !$this->error
3✔
249
            && $this->size
3✔
250
            && ($this->tmp_name || ($this->pathname && file_exists($this->pathname)));
3✔
251
    }
252

253
    private ?string $pathname = null;
254

255
    protected function get_pathname(): ?string
256
    {
257
        return $this->pathname ?? $this->tmp_name;
6✔
258
    }
259

260
    private function __construct(array $properties)
261
    {
262
        foreach ($properties as $property => $value) {
65✔
263
            switch ($property) {
264
                case self::OPTION_NAME:
65✔
265
                    $this->name = $value;
2✔
266
                    break;
2✔
267
                case self::OPTION_TYPE:
63✔
268
                    $this->type = $value;
1✔
269
                    break;
1✔
270
                case self::OPTION_SIZE:
63✔
271
                    $this->size = $value;
2✔
272
                    break;
2✔
273
                case self::OPTION_TMP_NAME:
63✔
NEW
274
                    $this->tmp_name = $value;
×
NEW
275
                    break;
×
276
                case self::OPTION_ERROR:
63✔
277
                    $this->error = $value;
20✔
278
                    break;
20✔
279
                case self::OPTION_PATHNAME:
53✔
280
                    $this->pathname = $value;
53✔
281
                    break;
53✔
282
                default:
NEW
283
                    throw new \InvalidArgumentException("Unknown property: $property");
×
284
            }
285
        }
286

287
        if (!$this->name && $this->pathname) {
65✔
288
            $this->name = basename($this->pathname);
53✔
289
        }
290

291
        if (empty($this->type)) {
65✔
292
            unset($this->type);
64✔
293
        }
294

295
        if (empty($this->size)) {
65✔
296
            unset($this->size);
63✔
297
        }
298
    }
299

300
    /**
301
     * Returns an array representation of the instance.
302
     *
303
     * The following properties are exported:
304
     *
305
     * - {@see $name}
306
     * - {@see $unsuffixed_name}
307
     * - {@see $extension}
308
     * - {@see $type}
309
     * - {@see $size}
310
     * - {@see $pathname}
311
     * - {@see $error}
312
     * - {@see $error_message}
313
     */
314
    public function to_array(): array
315
    {
316
        $error_message = $this->error_message;
3✔
317

318
        if ($error_message !== null) {
3✔
319
            $error_message = (string)$error_message;
1✔
320
        }
321

322
        return [
3✔
323

324
            'name' => $this->name,
3✔
325
            'unsuffixed_name' => $this->unsuffixed_name,
3✔
326
            'extension' => $this->extension,
3✔
327
            'type' => $this->type,
3✔
328
            'size' => $this->size,
3✔
329
            'pathname' => $this->pathname,
3✔
330
            'error' => $this->error,
3✔
331
            'error_message' => $error_message,
3✔
332

333
        ];
3✔
334
    }
335

336
    /**
337
     * Returns the extension of the file, if any.
338
     *
339
     * **Note**: The extension includes the dot e.g. ".zip". The extension is always in lower case.
340
     */
341
    protected function get_extension(): ?string
342
    {
343
        if (!$this->name) {
22✔
344
            return null;
1✔
345
        }
346

347
        $extension = pathinfo($this->name, PATHINFO_EXTENSION);
21✔
348

349
        if (!$extension) {
21✔
350
            return null;
2✔
351
        }
352

353
        return '.' . strtolower($extension);
19✔
354
    }
355

356
    /**
357
     * Checks if a file is uploaded.
358
     */
359
    protected function get_is_uploaded(): bool
360
    {
361
        return $this->tmp_name && is_uploaded_file($this->tmp_name);
3✔
362
    }
363

364
    /**
365
     * Checks if the file matches a MIME class, a MIME type, or a file extension.
366
     *
367
     * @param array|string|null $type The type can be a MIME class (e.g. "image"),
368
     * a MIME type (e.g. "image/png"), or an extensions (e.g. ".zip"). An array can be used to
369
     * check if a file matches multiple type e.g. `[ "image", ".mp3" ]`, which matches any type
370
     * of image or files with the ".mp3" extension.
371
     *
372
     * @return bool `true` if the file matches (or `$type` is empty), `false` otherwise.
373
     */
374
    public function match(array|string|null $type): bool
375
    {
376
        if (!$type) {
17✔
377
            return true;
4✔
378
        }
379

380
        if (is_array($type)) {
13✔
381
            return $this->match_multiple($type);
7✔
382
        }
383

384
        if ($type[0] === '.') {
13✔
385
            return $type === $this->extension;
9✔
386
        }
387

388
        if (!str_contains($type, '/')) {
8✔
389
            return (bool)preg_match('#^' . \preg_quote($type) . '/#', $this->type);
4✔
390
        }
391

392
        return $type === $this->type;
4✔
393
    }
394

395
    /**
396
     * Checks if the file matches one of the types in the list.
397
     *
398
     * @param array $type_list
399
     *
400
     * @return bool `true` if the file matches, `false` otherwise.
401
     */
402
    private function match_multiple(array $type_list): bool
403
    {
404
        foreach ($type_list as $type) {
7✔
405
            if ($this->match($type)) {
7✔
406
                return true;
4✔
407
            }
408
        }
409

410
        return false;
3✔
411
    }
412

413
    /**
414
     * Moves the file.
415
     *
416
     * @param string $destination Pathname to the destination file.
417
     * @param bool $overwrite Use {@see MOVE_OVERWRITE} to delete the destination before the file
418
     * is moved. Defaults to {@see MOVE_NO_OVERWRITE}.
419
     *
420
     * @throws Throwable if the file failed to be moved.
421
     */
422
    public function move(string $destination, bool $overwrite = self::MOVE_NO_OVERWRITE): void
423
    {
424
        if (file_exists($destination)) {
3✔
425
            if (!$overwrite) {
2✔
426
                throw new \Exception("The destination file already exists: $destination.");
1✔
427
            }
428

429
            unlink($destination);
1✔
430
        }
431

432
        if ($this->pathname) {
2✔
433
            if (!rename($this->pathname, $destination)) {
2✔
434
                throw new \Exception(
×
NEW
435
                    "Unable to move file to destination: $destination.",
×
436
                );  // @codeCoverageIgnore
437
            }
438
        }// @codeCoverageIgnoreStart
439
        elseif (!move_uploaded_file($this->tmp_name, $destination)) {
440
            throw new \Exception("Unable to move file to destination: $destination.");
441
        }
442
        // @codeCoverageIgnoreEnd
443

444
        $this->pathname = $destination;
2✔
445
    }
446
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc