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\RequestInterface; 23use Psr\Http\Message\ResponseInterface; 24use Psr\Http\Message\ServerRequestInterface; 25use Psr\Http\Message\StreamFactoryInterface; 26use Psr\Http\Server\MiddlewareInterface; 27use Psr\Http\Server\RequestHandlerInterface; 28 29use function extension_loaded; 30use function gzdeflate; 31use function gzencode; 32use function in_array; 33use function str_contains; 34use function strstr; 35use function strtolower; 36use function strtr; 37 38/** 39 * Middleware to compress (gzip or deflate) a response. 40 */ 41class CompressResponse implements MiddlewareInterface 42{ 43 // Non-text responses that will benefit from compression. 44 protected const array MIME_TYPES = [ 45 'application/javascript', 46 'application/json', 47 'application/pdf', 48 'application/vnd.geo+json', 49 'application/xml', 50 'image/svg+xml', 51 ]; 52 53 protected StreamFactoryInterface $stream_factory; 54 55 /** 56 * @param StreamFactoryInterface $stream_factory 57 */ 58 public function __construct(StreamFactoryInterface $stream_factory) 59 { 60 $this->stream_factory = $stream_factory; 61 } 62 63 /** 64 * @param ServerRequestInterface $request 65 * @param RequestHandlerInterface $handler 66 * 67 * @return ResponseInterface 68 */ 69 public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 70 { 71 $response = $handler->handle($request); 72 73 $method = $this->compressionMethod($request); 74 75 if ($method !== null && $this->isCompressible($response)) { 76 $content = (string) $response->getBody(); 77 78 switch ($method) { 79 case 'deflate': 80 $content = gzdeflate($content); 81 break; 82 83 case 'gzip': 84 $content = gzencode($content); 85 break; 86 } 87 88 if ($content === false) { 89 return $response; 90 } 91 92 $stream = $this->stream_factory->createStream($content); 93 94 return $response 95 ->withBody($stream) 96 ->withHeader('content-encoding', $method) 97 ->withHeader('vary', 'accept-encoding'); 98 } 99 100 return $response; 101 } 102 103 protected function compressionMethod(RequestInterface $request): string|null 104 { 105 $accept_encoding = strtolower($request->getHeaderLine('accept-encoding')); 106 $zlib_available = extension_loaded('zlib'); 107 108 if ($zlib_available) { 109 if (str_contains($accept_encoding, 'gzip')) { 110 return 'gzip'; 111 } 112 113 if (str_contains($accept_encoding, 'deflate')) { 114 return 'deflate'; 115 } 116 } 117 118 return null; 119 } 120 121 /** 122 * @param ResponseInterface $response 123 * 124 * @return bool 125 */ 126 protected function isCompressible(ResponseInterface $response): bool 127 { 128 // Already encoded? 129 if ($response->hasHeader('content-encoding')) { 130 return false; 131 } 132 133 $content_type = $response->getHeaderLine('content-type'); 134 $content_type = strtr($content_type, [' ' => '']); 135 $content_type = strstr($content_type, ';', true) ?: $content_type; 136 $content_type = strtolower($content_type); 137 138 if (str_starts_with($content_type, 'text/')) { 139 return true; 140 } 141 142 return in_array($content_type, static::MIME_TYPES, true); 143 } 144} 145