Passed
Push — main ( 99c066...305d80 )
by Dimitri
04:43
created

ResponseEmitter::flush()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 4
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 8
ccs 0
cts 2
cp 0
crap 12
rs 10
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Http;
13
14
use GuzzleHttp\Psr7\LimitStream;
15
use Psr\Http\Message\ResponseInterface;
16
17
/**
18
 * Émetteur de réponse
19
 *
20
 * Émet une réponse à l'API du serveur PHP.
21
 *
22
 * Cet émetteur offre quelques changements par rapport aux émetteurs proposés par
23
 * diactors :
24
 *
25
 * - Les cookies sont émis en utilisant setcookie() pour ne pas entrer en conflit avec ext/session
26
 * - Pour les serveurs fastcgi avec PHP-FPM, session_write_close() est appelé simplement
27
 * avant fastcgi_finish_request() pour s'assurer que les données de session sont enregistrées
28
 * correctement (en particulier sur les backends de session plus lents).
29
 *
30
 * @credit      CakePHP 4.0 (Cake\Http\ResponseEmitter)
31
 */
32
class ResponseEmitter
33
{
34
    public function emit(ResponseInterface $response, int $maxBufferLength = 8192)
35
    {
36
        $file = $line = null;
37
        if (headers_sent($file, $line)) {
38
            $message = "Unable to emit headers. Headers sent in file={$file} line={$line}";
39
            if (on_dev()) {
40
                trigger_error($message, E_USER_WARNING);
41
            }
42
43
            logger()->warning($message);
44
        }
45
46
        $this->emitStatusLine($response);
47
        $this->emitHeaders($response);
48
        $this->flush();
49
50
        $range = $this->parseContentRange($response->getHeaderLine('Content-Range'));
51
        if (is_array($range)) {
52
            $this->emitBodyRange($range, $response, $maxBufferLength);
53
        } else {
54
            $this->emitBody($response, $maxBufferLength);
55
        }
56
57
        if (function_exists('fastcgi_finish_request')) {
58
            session_write_close();
59
            fastcgi_finish_request();
60
        }
61
    }
62
63
    /**
64
     * Émettre des en-têtes de réponse.
65
     *
66
     * Boucle à travers chaque en-tête, émettant chacun ; si la valeur d'en-tête
67
     * est un tableau avec plusieurs valeurs, garantit que chacune est envoyée
68
     * de manière à créer des en-têtes agrégés (au lieu de remplacer
69
     * la précédente).
70
     *
71
     * @return void
72
     */
73
    public function emitHeaders(ResponseInterface $response)
74
    {
75
        $cookies = [];
76
        if (method_exists($response, 'getCookies')) {
77
            $cookies = call_user_func([$response, 'getCookies']);
78
        }
79
80
        foreach ($response->getHeaders() as $name => $values) {
81
            if (strtolower($name) === 'set-cookie') {
82
                $cookies = array_merge($cookies, $values);
83
84
                continue;
85
            }
86
            $first = true;
87
88
            foreach ($values as $value) {
89
                header(sprintf(
90
                    '%s: %s',
91
                    $name,
92
                    $value
93
                ), $first);
94
                $first = false;
95
            }
96
        }
97
98
        $this->emitCookies($cookies);
99
    }
100
101
    /**
102
     * Emet le corps de la requête
103
     *
104
     * @param int $maxBufferLength La taille du bloc à émettre
105
     *
106
     * @return void
107
     */
108
    protected function emitBody(ResponseInterface $response, int $maxBufferLength)
109
    {
110
        if (in_array($response->getStatusCode(), [204, 304], true)) {
111
            return;
112
        }
113 2
        $body = $response->getBody();
114
115
        if (! $body->isSeekable()) {
116
            echo $body;
117
118
            return;
119
        }
120
121 2
        $body->rewind();
122
123
        while (! $body->eof()) {
124 2
            echo $body->read($maxBufferLength);
125
        }
126
    }
127
128
    /**
129
     * Émettre une plage du corps du message.
130
     *
131
     * @param array $range           La plage de données à émettre
132
     * @param int   $maxBufferLength La taille du bloc à émettre
133
     *
134
     * @return void
135
     */
136
    protected function emitBodyRange(array $range, ResponseInterface $response, int $maxBufferLength)
137
    {
138
        [$unit, $first, $last, $length] = $range;
139
140
        $body = $response->getBody();
141
142
        if (! $body->isSeekable()) {
143
            $contents = $body->getContents();
144
            echo substr($contents, $first, $last - $first + 1);
145
146
            return;
147
        }
148
149
        $body = new LimitStream($body, -1, $first);
150
        $body->rewind();
151
        $pos    = 0;
152
        $length = $last - $first + 1;
153
154
        while (! $body->eof() && $pos < $length) {
155
            if (($pos + $maxBufferLength) > $length) {
156
                echo $body->read($length - $pos);
157
                break;
158
            }
159
160
            echo $body->read($maxBufferLength);
161
            $pos = $body->tell();
162
        }
163
    }
164
165
    /**
166
     * Émettre la ligne d'état.
167
     *
168
     * Émet la ligne d'état en utilisant la version du protocole et le code d'état de
169
     * la réponse; si une expression de raison est disponible, elle est également émise.
170
     *
171
     * @return void
172
     */
173
    protected function emitStatusLine(ResponseInterface $response)
174
    {
175
        $reasonPhrase = $response->getReasonPhrase();
176
        header(sprintf(
177
            'HTTP/%s %d%s',
178
            $response->getProtocolVersion(),
179
            $response->getStatusCode(),
180
            ($reasonPhrase ? ' ' . $reasonPhrase : '')
181
        ));
182
    }
183
184
    /**
185
     * émettre des cookies en utilisant setcookie()
186
     *
187
     * @param array $cookies Un tableau d'en-têtes Set-Cookie.
188
     *
189
     * @return void
190
     */
191
    protected function emitCookies(array $cookies)
192
    {
193
        foreach ($cookies as $cookie) {
194
            if (is_array($cookie)) {
195
                setcookie(
196
                    $cookie['name'],
197
                    $cookie['value'],
198
                    $cookie['expires'],
199
                    $cookie['path'],
200
                    $cookie['domain'],
201
                    $cookie['secure'],
202
                    $cookie['httponly']
203
                );
204
205
                continue;
206
            }
207
208
            if (str_contains($cookie, '";"')) {
209
                $cookie = str_replace('";"', '{__cookie_replace__}', $cookie);
210
                $parts  = str_replace('{__cookie_replace__}', '";"', explode(';', $cookie));
211
            } else {
212
                $parts = preg_split('/\;[ \t]*/', $cookie);
213
            }
214
215
            [$name, $value] = explode('=', array_shift($parts), 2);
216
            $data           = [
217
                'name'     => urldecode($name),
218
                'value'    => urldecode($value),
219
                'expires'  => 0,
220
                'path'     => '',
221
                'domain'   => '',
222
                'secure'   => false,
223
                'httponly' => false,
224
            ];
225
226
            foreach ($parts as $part) {
227
                if (str_contains($part, '=')) {
228
                    [$key, $value] = explode('=', $part);
229
                } else {
230
                    $key   = $part;
231
                    $value = true;
232
                }
233
234
                $key        = strtolower($key);
235
                $data[$key] = $value;
236
            }
237
            if (! empty($data['expires'])) {
238
                $data['expires'] = strtotime($data['expires']);
239
            }
240
            setcookie(
241
                $data['name'],
242
                $data['value'],
243
                $data['expires'],
244
                $data['path'],
245
                $data['domain'],
246
                $data['secure'],
247
                $data['httponly']
248
            );
249
        }
250
    }
251
252
    /**
253
     * Boucle à travers le tampon de sortie, en vidant chacun, avant d'émettre
254
     * la réponse.
255
     *
256
     * @param int|null $maxBufferLevel Vide jusqu'à ce niveau de tampon.
257
     *
258
     * @return void
259
     */
260
    protected function flush(?int $maxBufferLevel = null)
261
    {
262
        if (null === $maxBufferLevel) {
263
            $maxBufferLevel = ob_get_level();
264
        }
265
266
        while (ob_get_level() > $maxBufferLevel) {
267
            ob_end_flush();
268
        }
269
    }
270
271
    /**
272
     * Analyser l'en-tête de la plage de contenu
273
     * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
274
     *
275
     * @param string $header L'en-tête Content-Range à analyser.
276
     *
277
     * @return array|false [unité, premier, dernier, longueur] ; renvoie faux si non
278
     *                     une plage de contenu ou une plage de contenu non valide est fournie
279
     */
280
    protected function parseContentRange(string $header)
281
    {
282
        if (preg_match('/(?P<unit>[\w]+)\s+(?P<first>\d+)-(?P<last>\d+)\/(?P<length>\d+|\*)/', $header, $matches)) {
283
            return [
284
                $matches['unit'],
285
                (int) $matches['first'],
286
                (int) $matches['last'],
287
                $matches['length'] === '*' ? '*' : (int) $matches['length'],
288
            ];
289
        }
290
291
        return false;
292
    }
293
}
294