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 int 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