• 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.9
/lib/FileResponse.php
1
<?php
2

3
namespace ICanBoogie\HTTP;
4

5
use ICanBoogie\DateTime;
6
use InvalidArgumentException;
7
use LogicException;
8
use SplFileInfo;
9

10
use function array_filter;
11
use function base64_encode;
12
use function fclose;
13
use function finfo_file;
14
use function finfo_open;
15
use function fopen;
16
use function function_exists;
17
use function hash_file;
18
use function stream_copy_to_stream;
19

20
use const FILEINFO_MIME_TYPE;
21

22
/**
23
 * Representation of an HTTP response delivering a file.
24
 *
25
 * @property-read SplFileInfo $file
26
 * @property-read int $modified_time
27
 * @property-read RequestRange|null $range
28
 * @property-read bool $is_modified
29
 */
30
class FileResponse extends Response
31
{
32
    /**
33
     * Specifies the `ETag` header field of the response. If it is not defined the
34
     * SHA-384 of the file is used instead.
35
     */
36
    public const OPTION_ETAG = 'etag';
37

38
    /**
39
     * Specifies the expiration date as a {@link DateTime} instance or a relative date
40
     * such as "+3 month", which maps to the `Expires` header field. The `max-age` directive of
41
     * the `Cache-Control` header field is computed from the current time. If it is not
42
     * defined {@link DEFAULT_EXPIRES} is used instead.
43
     */
44
    public const OPTION_EXPIRES = 'expires';
45

46
    /**
47
     * Specifies the filename of the file and forces download. The following header are updated:
48
     * `Content-Transfer-Encoding`, `Content-Description`, and `Content-Dispositon`.
49
     */
50
    public const OPTION_FILENAME = 'filename';
51

52
    /**
53
     * Specifies the MIME of the file, which maps to the `Content-Type` header field. If it is
54
     * not defined the MIME is guessed using `finfo::file()`.
55
     */
56
    public const OPTION_MIME = 'mime';
57

58
    public const DEFAULT_EXPIRES = '+1 month';
59
    public const DEFAULT_MIME = 'application/octet-stream';
60

61
    /**
62
     * Hashes a file using SHA-348.
63
     *
64
     * @return string A base64 string
65
     */
66
    public static function hash_file(string $pathname): string
67
    {
68
        return base64_encode(hash_file('sha384', $pathname, true));
38✔
69
    }
70

71
    private SplFileInfo $file;
72

73
    protected function get_file(): SplFileInfo
74
    {
75
        return $this->file;
1✔
76
    }
77

78
    /**
79
     * @param array<string, mixed> $options
80
     * @param Headers|array<string, mixed> $headers
81
     */
82
    public function __construct(
83
        string|SplFileInfo $file,
84
        private readonly Request $request,
85
        array $options = [],
86
        Headers|array $headers = []
87
    ) {
88
        if (!$headers instanceof Headers) {
50✔
89
            $headers = new Headers($headers);
50✔
90
        }
91

92
        $this->file = $this->ensure_file_info($file);
50✔
93
        $this->apply_options($options, $headers);
48✔
94
        $this->ensure_content_type($this->file, $headers);
48✔
95

96
        parent::__construct(function () {
48✔
97
            if (!$this->status->is_successful) {
10✔
98
                return;
2✔
99
            }
100

101
            $this->send_file($this->file);
8✔
102
        }, ResponseStatus::STATUS_OK, $headers);
48✔
103
    }
104

105
    /**
106
     * Ensures the provided file is a {@link \SplFileInfo} instance.
107
     *
108
     * @throws LogicException if the file is a directory, or does not exist.
109
     */
110
    private function ensure_file_info(mixed $file): SplFileInfo
111
    {
112
        $file = $file instanceof SplFileInfo ? $file : new SplFileInfo($file);
50✔
113

114
        if ($file->isDir()) {
50✔
115
            throw new LogicException("Expected file, got directory: $file");
1✔
116
        }
117

118
        if (!$file->isFile()) {
49✔
119
            throw new LogicException("File does not exist: $file");
1✔
120
        }
121

122
        return $file;
48✔
123
    }
124

125
    /**
126
     * @param array<string, mixed> $options
127
     */
128
    private function apply_options(array $options, Headers $headers): void
129
    {
130
        foreach (array_filter($options) as $option => $value) {
48✔
131
            switch ($option) {
132
                case self::OPTION_ETAG:
133
                    if ($headers->etag) {
9✔
UNCOV
134
                        throw new InvalidArgumentException("Can only use one of OPTION_ETAG, HEADER_ETAG.");
×
135
                    }
136

137
                    $headers->etag = $value;
9✔
138
                    break;
9✔
139

140
                case self::OPTION_EXPIRES:
141
                    $headers->expires = $value;
2✔
142
                    break;
2✔
143

144
                case self::OPTION_FILENAME:
145
                    $headers['Content-Transfer-Encoding'] = 'binary';
2✔
146
                    $headers['Content-Description'] = 'File Transfer';
2✔
147
                    $headers->content_disposition->type = 'attachment';
2✔
148
                    $headers->content_disposition->filename = $value === true ? $this->file->getFilename() : $value;
2✔
149
                    break;
2✔
150

151
                case self::OPTION_MIME:
152
                    $headers->content_type = $value;
2✔
153
                    break;
2✔
154
            }
155
        }
156

157
        $headers->etag ??= $this->make_etag();
48✔
158
    }
159

160
    /**
161
     * If the content type is empty in the headers the method tries to obtain it from
162
     * the file, if it fails {@link DEFAULT_MIME} is used as fallback.
163
     */
164
    private function ensure_content_type(SplFileInfo $file, Headers $headers): void
165
    {
166
        if ($headers->content_type->value) {
48✔
167
            return;
4✔
168
        }
169

170
        $mime = null;
44✔
171

172
        if (function_exists('finfo_file')) {
44✔
173
            $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file);
44✔
174
        }
175

176
        $headers->content_type = $mime ?? self::DEFAULT_MIME;
44✔
177
    }
178

179
    /**
180
     * Changes the status to `Status::NOT_MODIFIED` if the request's Cache-Control has
181
     * 'no-cache' and `is_modified` is false.
182
     */
183
    public function __invoke(): void
184
    {
185
        $range = $this->range;
19✔
186

187
        if ($range) {
19✔
188
            if (!$range->is_satisfiable) {
10✔
189
                $this->status = ResponseStatus::STATUS_REQUESTED_RANGE_NOT_SATISFIABLE;
1✔
190
            } elseif (!$range->is_total) {
9✔
191
                $this->status = ResponseStatus::STATUS_PARTIAL_CONTENT;
6✔
192
            }
193
        }
194

195
        if ($this->request->headers->cache_control->cacheable != 'no-cache' && !$this->is_modified) {
19✔
196
            $this->status = ResponseStatus::STATUS_NOT_MODIFIED;
2✔
197
        }
198

199
        parent::__invoke();
19✔
200
    }
201

202
    /**
203
     * The following headers are always modified:
204
     *
205
     * - `Cache-Control`: sets _cacheable_ to _public_.
206
     * - `Expires`: is set to "+1 month".
207
     *
208
     * If the status code is `Stauts::NOT_MODIFIED` the following headers are unset:
209
     *
210
     * - `Content-Type`
211
     * - `Content-Length`
212
     *
213
     * Otherwise, the following header is set:
214
     *
215
     * - `Content-Type`:
216
     *
217
     * @inheritdoc
218
     */
219
    protected function finalize(Headers &$headers, &$body): void
220
    {
221
        parent::finalize($headers, $body);
23✔
222

223
        $status = $this->status->code;
23✔
224
        $expires = $this->expires;
23✔
225

226
        $headers->expires = $expires;
23✔
227
        $headers->cache_control->cacheable = 'public';
23✔
228
        $headers->cache_control->max_age = $expires->timestamp - DateTime::now()->timestamp;
23✔
229

230
        if ($status === ResponseStatus::STATUS_NOT_MODIFIED) {
23✔
231
            $this->finalize_for_not_modified($headers);
3✔
232

233
            return;
3✔
234
        }
235

236
        if ($status === ResponseStatus::STATUS_PARTIAL_CONTENT) {
20✔
237
            $this->finalize_for_partial_content($headers);
6✔
238

239
            return;
6✔
240
        }
241

242
        $this->finalize_for_other($headers);
14✔
243
    }
244

245
    /**
246
     * Finalizes the response for `Status::NOT_MODIFIED`.
247
     */
248
    private function finalize_for_not_modified(Headers &$headers): void
249
    {
250
        $headers->content_length = null;
3✔
251
    }
252

253
    /**
254
     * Finalizes the response for `Status::PARTIAL_CONTENT`.
255
     */
256
    private function finalize_for_partial_content(Headers &$headers): void
257
    {
258
        $range = $this->range;
6✔
259

260
        $headers->last_modified = $this->modified_time;
6✔
261
        $headers['Content-Range'] = (string) $range;
6✔
262
        $headers->content_length = $range->length;
6✔
263
    }
264

265
    /**
266
     * Finalizes the response for status other than `Status::NOT_MODIFIED` or
267
     * `Status::PARTIAL_CONTENT`.
268
     */
269
    private function finalize_for_other(Headers &$headers): void
270
    {
271
        $headers->last_modified = $this->modified_time;
14✔
272

273
        if (!$headers['Accept-Ranges']) {
14✔
274
            $request = $this->request;
14✔
275

276
            $headers['Accept-Ranges'] = $request->method->is_get() || $request->method->is_head() ? 'bytes' : 'none';
14✔
277
        }
278

279
        $headers->content_length = $this->file->getSize();
14✔
280
    }
281

282
    /**
283
     * Sends the file.
284
     *
285
     * @param SplFileInfo $file
286
     *
287
     * @codeCoverageIgnore
288
     */
289
    protected function send_file(SplFileInfo $file): void
290
    {
291
        [ $max_length, $offset ] = $this->resolve_max_length_and_offset();
292

293
        $out = fopen('php://output', 'wb');
294
        $source = fopen($file->getPathname(), 'rb');
295

296
        stream_copy_to_stream($source, $out, $max_length, $offset);
297

298
        fclose($out);
299
        fclose($source);
300
    }
301

302
    /**
303
     * Resolves `max_length` and `offset` parameters for stream copy.
304
     *
305
     * @return array{ 0: int, 1: int }
306
     */
307
    private function resolve_max_length_and_offset(): array
308
    {
309
        $range = $this->range;
6✔
310

311
        if ($range && $range->max_length) {
6✔
312
            return [ $range->max_length, $range->offset ];
5✔
313
        }
314

315
        return [ -1, 0 ];
1✔
316
    }
317

318
    /**
319
     * Returns a SHA-384 of the file.
320
     */
321
    private function make_etag(): string
322
    {
323
        return self::hash_file($this->file->getPathname());
38✔
324
    }
325

326
    /**
327
     * If the date returned by the parent is empty the method returns a date created from
328
     * {@link DEFAULT_EXPIRES}.
329
     */
330
    protected function get_expires(): Headers\Date
331
    {
332
        $expires = parent::get_expires();
28✔
333

334
        if (!$expires->is_empty) {
28✔
335
            return $expires;
4✔
336
        }
337

338
        return Headers\Date::from(self::DEFAULT_EXPIRES);
24✔
339
    }
340

341
    /**
342
     * Returns the timestamp at which the file was last modified.
343
     */
344
    protected function get_modified_time(): false|int
345
    {
346
        return $this->file->getMTime();
23✔
347
    }
348

349
    /**
350
     * Whether the file as been modified since the last response.
351
     *
352
     * The file is considered modified if one of the following conditions is met:
353
     *
354
     * - The `If-Modified-Since` request header is empty.
355
     * - The `If-Modified-Since` value is inferior to `$modified_time`.
356
     * - The `If-None-Match` value doesn't match `$etag`.
357
     */
358
    protected function get_is_modified(): bool
359
    {
360
        $headers = $this->request->headers;
17✔
361

362
        // HTTP/1.1
363

364
        if ((string) $headers[Headers::HEADER_IF_NONE_MATCH] !== $this->headers->etag) {
17✔
365
            return true;
15✔
366
        }
367

368
        // HTTP/1.0
369

370
        $if_modified_since = $headers->if_modified_since;
2✔
371

372
        return $if_modified_since->is_empty || $if_modified_since->timestamp < $this->modified_time;
2✔
373
    }
374

375
    private ?RequestRange $range_;
376

377
    protected function get_range(): ?RequestRange
378
    {
379
        return $this->range_ ??= RequestRange::from(
14✔
380
            $this->request->headers,
14✔
381
            $this->file->getSize(),
14✔
382
            $this->headers->etag
14✔
383
        );
14✔
384
    }
385
}
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