xref: /webtrees/app/Http/Middleware/EmitResponse.php (revision 8d018ab2fe3b6881cab8124d97e1a69e0be79d32)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\Middleware;
21
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use Psr\Http\Server\MiddlewareInterface;
25use Psr\Http\Server\RequestHandlerInterface;
26use RuntimeException;
27
28use function connection_status;
29use function fastcgi_finish_request;
30use function function_exists;
31use function header;
32use function header_remove;
33use function headers_sent;
34use function http_response_code;
35use function ob_get_length;
36use function ob_get_level;
37use function sprintf;
38
39use const CONNECTION_NORMAL;
40
41/**
42 * Middleware to emit the response - send it back to the webserver.
43 */
44class EmitResponse implements MiddlewareInterface
45{
46    // Stream the output in chunks.
47    private const CHUNK_SIZE = 65536;
48
49    /**
50     * @param ServerRequestInterface  $request
51     * @param RequestHandlerInterface $handler
52     *
53     * @return ResponseInterface
54     */
55    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
56    {
57        $response = $handler->handle($request);
58
59        $this->assertHeadersNotEmitted();
60        $this->removeDefaultPhpHeaders();
61
62        // Unless webtrees set a cache-control header, assume the page cannot be cached
63        if (!$response->hasHeader('cache-control')) {
64            $response = $response->withHeader('cache-control', 'no-store');
65        }
66
67        $this->assertBodyNotEmitted();
68        $this->emitStatusLine($response);
69        $this->emitHeaders($response);
70        $this->emitBody($response);
71        $this->closeConnection();
72
73        return $response;
74    }
75
76    /**
77     * Remove the default PHP header.
78     *
79     * @return void
80     */
81    private function removeDefaultPhpHeaders(): void
82    {
83        header_remove('X-Powered-By');
84        header_remove('cache-control');
85        header_remove('Expires');
86        header_remove('Pragma');
87    }
88
89    /**
90     * @return void
91     * @throws RuntimeException
92     */
93    private function assertHeadersNotEmitted(): void
94    {
95        if (headers_sent($file, $line)) {
96            $message = sprintf('Headers already sent at %s:%d', $file, $line);
97
98            throw new RuntimeException($message);
99        }
100    }
101
102    /**
103     * @return void
104     * @throws RuntimeException
105     */
106    private function assertBodyNotEmitted(): void
107    {
108        if (ob_get_level() > 0 && ob_get_length() > 0) {
109            // The output probably contains an error message.
110            $output = ob_get_clean();
111
112            throw new RuntimeException('Output already started: ' . $output);
113        }
114    }
115
116    /**
117     * @param ResponseInterface $response
118     */
119    private function emitStatusLine(ResponseInterface $response): void
120    {
121        http_response_code($response->getStatusCode());
122
123        header(sprintf(
124            'HTTP/%s %d %s',
125            $response->getProtocolVersion(),
126            $response->getStatusCode(),
127            $response->getReasonPhrase()
128        ));
129    }
130
131    /**
132     * @param ResponseInterface $response
133     */
134    private function emitHeaders(ResponseInterface $response): void
135    {
136        foreach ($response->getHeaders() as $name => $values) {
137            foreach ($values as $value) {
138                header(
139                    sprintf('%s: %s', $name, $value),
140                    false,
141                    $response->getStatusCode()
142                );
143            }
144        }
145    }
146
147    /**
148     * @param ResponseInterface $response
149     *
150     * @return void
151     */
152    private function emitBody(ResponseInterface $response): void
153    {
154        $body = $response->getBody();
155
156        if ($body->isSeekable()) {
157            $body->rewind();
158        }
159
160        while (!$body->eof() && connection_status() === CONNECTION_NORMAL) {
161            echo $body->read(self::CHUNK_SIZE);
162        }
163    }
164
165    /**
166     * @return void
167     */
168    private function closeConnection(): void
169    {
170        if (function_exists('fastcgi_finish_request')) {
171            fastcgi_finish_request();
172        }
173    }
174}
175