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

ICanBoogie / HTTP / 11638532625

02 Nov 2024 01:03AM UTC coverage: 91.676%. Remained the same
11638532625

push

github

olvlvl
Support PHP 8.4+

3 of 3 new or added lines in 2 files covered. (100.0%)

4 existing lines in 3 files now uncovered.

837 of 913 relevant lines covered (91.68%)

19.45 hits per line

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

98.0
/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 string $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: {@link 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 {@link 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 {@link $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 {@link $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
     * @return FormattedString|null
205
     */
206
    protected function get_error_message()
207
    {
208
        switch ($this->error) {
13✔
209
            case UPLOAD_ERR_OK:
13✔
210
                return null;
3✔
211

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

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

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

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

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

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

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

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

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

255
    private ?string $pathname = null;
256

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

262
    protected function __construct(array $properties)
263
    {
264
        foreach ($properties as $property => $value) {
65✔
265
            $this->$property = $value;
65✔
266
        }
267

268
        if (!$this->name && $this->pathname) {
65✔
269
            $this->name = basename($this->pathname);
53✔
270
        }
271

272
        if (empty($this->type)) {
65✔
273
            unset($this->type);
64✔
274
        }
275

276
        if (empty($this->size)) {
65✔
277
            unset($this->size);
63✔
278
        }
279
    }
280

281
    /**
282
     * Returns an array representation of the instance.
283
     *
284
     * The following properties are exported:
285
     *
286
     * - {@link $name}
287
     * - {@link $unsuffixed_name}
288
     * - {@link $extension}
289
     * - {@link $type}
290
     * - {@link $size}
291
     * - {@link $pathname}
292
     * - {@link $error}
293
     * - {@link $error_message}
294
     */
295
    public function to_array(): array
296
    {
297
        $error_message = $this->error_message;
3✔
298

299
        if ($error_message !== null) {
3✔
300
            $error_message = (string) $error_message;
1✔
301
        }
302

303
        return [
3✔
304

305
            'name' => $this->name,
3✔
306
            'unsuffixed_name' => $this->unsuffixed_name,
3✔
307
            'extension' => $this->extension,
3✔
308
            'type' => $this->type,
3✔
309
            'size' => $this->size,
3✔
310
            'pathname' => $this->pathname,
3✔
311
            'error' => $this->error,
3✔
312
            'error_message' => $error_message,
3✔
313

314
        ];
3✔
315
    }
316

317
    /**
318
     * Returns the extension of the file, if any.
319
     *
320
     * **Note:** The extension includes the dot e.g. ".zip". The extension is always in lower case.
321
     */
322
    protected function get_extension(): ?string
323
    {
324
        if (!$this->name) {
22✔
325
            return null;
1✔
326
        }
327

328
        $extension = pathinfo($this->name, PATHINFO_EXTENSION);
21✔
329

330
        if (!$extension) {
21✔
331
            return null;
2✔
332
        }
333

334
        return '.' . strtolower($extension);
19✔
335
    }
336

337
    /**
338
     * Checks if a file is uploaded.
339
     */
340
    protected function get_is_uploaded(): bool
341
    {
342
        return $this->tmp_name && is_uploaded_file($this->tmp_name);
3✔
343
    }
344

345
    /**
346
     * Checks if the file matches a MIME class, a MIME type, or a file extension.
347
     *
348
     * @param array|string|null $type The type can be a MIME class (e.g. "image"),
349
     * a MIME type (e.g. "image/png"), or an extensions (e.g. ".zip"). An array can be used to
350
     * check if a file matches multiple type e.g. `[ "image", ".mp3" ]`, which matches any type
351
     * of image or files with the ".mp3" extension.
352
     *
353
     * @return bool `true` if the file matches (or `$type` is empty), `false` otherwise.
354
     */
355
    public function match(array|string|null $type): bool
356
    {
357
        if (!$type) {
17✔
358
            return true;
4✔
359
        }
360

361
        if (is_array($type)) {
13✔
362
            return $this->match_multiple($type);
7✔
363
        }
364

365
        if ($type[0] === '.') {
13✔
366
            return $type === $this->extension;
9✔
367
        }
368

369
        if (!str_contains($type, '/')) {
8✔
370
            return (bool) preg_match('#^' . \preg_quote($type) . '/#', $this->type);
4✔
371
        }
372

373
        return $type === $this->type;
4✔
374
    }
375

376
    /**
377
     * Checks if the file matches one of the types in the list.
378
     *
379
     * @param array $type_list
380
     *
381
     * @return bool `true` if the file matches, `false` otherwise.
382
     */
383
    private function match_multiple(array $type_list): bool
384
    {
385
        foreach ($type_list as $type) {
7✔
386
            if ($this->match($type)) {
7✔
387
                return true;
4✔
388
            }
389
        }
390

391
        return false;
3✔
392
    }
393

394
    /**
395
     * Moves the file.
396
     *
397
     * @param string $destination Pathname to the destination file.
398
     * @param bool $overwrite Use {@link MOVE_OVERWRITE} to delete the destination before the file
399
     * is moved. Defaults to {@link MOVE_NO_OVERWRITE}.
400
     *
401
     * @throws Throwable if the file failed to be moved.
402
     */
403
    public function move(string $destination, bool $overwrite = self::MOVE_NO_OVERWRITE): void
404
    {
405
        if (file_exists($destination)) {
3✔
406
            if (!$overwrite) {
2✔
407
                throw new \Exception("The destination file already exists: $destination.");
1✔
408
            }
409

410
            unlink($destination);
1✔
411
        }
412

413
        if ($this->pathname) {
2✔
414
            if (!rename($this->pathname, $destination)) {
2✔
UNCOV
415
                throw new \Exception(
×
UNCOV
416
                    "Unable to move file to destination: $destination."
×
417
                );  // @codeCoverageIgnore
418
            }
419
        }// @codeCoverageIgnoreStart
420
        elseif (!move_uploaded_file($this->tmp_name, $destination)) {
421
            throw new \Exception("Unable to move file to destination: $destination.");
422
        }
423
        // @codeCoverageIgnoreEnd
424

425
        $this->pathname = $destination;
2✔
426
    }
427
}
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