1 <?php
  2 
  3 /**
  4  * HTTP1.1 Client Cache Features.
  5  *
  6  * @package redaxo\core
  7  */
  8 class rex_response
  9 {
 10     const HTTP_OK = '200 OK';
 11     const HTTP_PARTIAL_CONTENT = '206 Partial Content';
 12     const HTTP_MOVED_PERMANENTLY = '301 Moved Permanently';
 13     const HTTP_NOT_MODIFIED = '304 Not Modified';
 14     const HTTP_MOVED_TEMPORARILY = '307 Temporary Redirect';
 15     const HTTP_NOT_FOUND = '404 Not Found';
 16     const HTTP_FORBIDDEN = '403 Forbidden';
 17     const HTTP_UNAUTHORIZED = '401 Unauthorized';
 18     const HTTP_RANGE_NOT_SATISFIABLE = '416 Range Not Satisfiable';
 19     const HTTP_INTERNAL_ERROR = '500 Internal Server Error';
 20     const HTTP_SERVICE_UNAVAILABLE = '503 Service Unavailable';
 21 
 22     private static $httpStatus = self::HTTP_OK;
 23     private static $sentLastModified = false;
 24     private static $sentEtag = false;
 25     private static $sentContentType = false;
 26     private static $sentCacheControl = false;
 27     private static $additionalHeaders = [];
 28     private static $preloadFiles = [];
 29 
 30     /**
 31      * Sets the HTTP Status code.
 32      *
 33      * @param int $httpStatus
 34      *
 35      * @throws InvalidArgumentException
 36      */
 37     public static function setStatus($httpStatus)
 38     {
 39         if (strpos($httpStatus, "\n") !== false) {
 40             throw new InvalidArgumentException('Illegal http-status "' . $httpStatus . '", contains newlines');
 41         }
 42 
 43         self::$httpStatus = $httpStatus;
 44     }
 45 
 46     /**
 47      * Returns the HTTP Status code.
 48      *
 49      * @return string
 50      */
 51     public static function getStatus()
 52     {
 53         return self::$httpStatus;
 54     }
 55 
 56     /**
 57      * Set a http response header. A existing header with the same name will be overridden.
 58      *
 59      * @param string $name
 60      * @param string $value
 61      */
 62     public static function setHeader($name, $value)
 63     {
 64         self::$additionalHeaders[$name] = $value;
 65     }
 66 
 67     private static function sendAdditionalHeaders()
 68     {
 69         foreach (self::$additionalHeaders as $name => $value) {
 70             header($name .': ' . $value);
 71         }
 72     }
 73 
 74     /**
 75      * Set a file to be preload via http link header.
 76      *
 77      * @param string $file
 78      * @param string $type
 79      * @param string $mimeType
 80      */
 81     public static function preload($file, $type, $mimeType)
 82     {
 83         self::$preloadFiles[] = [
 84             'file' => $file,
 85             'type' => $type,
 86             'mimeType' => $mimeType,
 87         ];
 88     }
 89 
 90     private static function sendPreloadHeaders()
 91     {
 92         foreach (self::$preloadFiles as $preloadFile) {
 93             header('Link: <' . $preloadFile['file'] . '>; rel=preload; as=' . $preloadFile['type'] . '; type="' . $preloadFile['mimeType'].'"; crossorigin; nopush', false);
 94         }
 95     }
 96 
 97     private static function sendServerTimingHeaders()
 98     {
 99         // see https://w3c.github.io/server-timing/#the-server-timing-header-field
100         foreach (rex_timer::$serverTimings as $label => $durationMs) {
101             $label = preg_replace('{[^!#$%&\'*+-\.\^_`|~\w]}i', '_', $label);
102             header('Server-Timing: '. $label .';dur='. number_format($durationMs, 3, '.', ''), false);
103         }
104     }
105 
106     /**
107      * Redirects to a URL.
108      *
109      * NOTE: Execution will stop within this method!
110      *
111      * @param string $url URL
112      *
113      * @throws InvalidArgumentException
114      */
115     public static function sendRedirect($url)
116     {
117         if (strpos($url, "\n") !== false) {
118             throw new InvalidArgumentException('Illegal redirect url "' . $url . '", contains newlines');
119         }
120 
121         self::cleanOutputBuffers();
122         self::sendAdditionalHeaders();
123         self::sendPreloadHeaders();
124         self::sendServerTimingHeaders();
125 
126         header('HTTP/1.1 ' . self::$httpStatus);
127         header('Location: ' . $url);
128         exit;
129     }
130 
131     /**
132      * Sends a file to client.
133      *
134      * @param string      $file               File path
135      * @param string      $contentType        Content type
136      * @param string      $contentDisposition Content disposition ("inline" or "attachment")
137      * @param null|string $filename           Custom Filename
138      */
139     public static function sendFile($file, $contentType, $contentDisposition = 'inline', $filename = null)
140     {
141         self::cleanOutputBuffers();
142 
143         if (!file_exists($file)) {
144             header('HTTP/1.1 ' . self::HTTP_NOT_FOUND);
145             exit;
146         }
147 
148         // prevent session locking while sending huge files
149         session_write_close();
150 
151         if (!$filename) {
152             $filename = basename($file);
153         }
154 
155         self::sendContentType($contentType);
156         header('Content-Disposition: ' . $contentDisposition . '; filename="' . $filename . '"');
157 
158         self::sendLastModified(filemtime($file));
159 
160         header('HTTP/1.1 ' . self::$httpStatus);
161         if (!self::$sentCacheControl) {
162             self::sendCacheControl('max-age=3600, must-revalidate, proxy-revalidate, private');
163         }
164 
165         // content length schicken, damit der browser einen ladebalken anzeigen kann
166         if (!ini_get('zlib.output_compression')) {
167             header('Content-Length: ' . filesize($file));
168         }
169 
170         self::sendAdditionalHeaders();
171         self::sendPreloadHeaders();
172         self::sendServerTimingHeaders();
173 
174         // dependency ramsey/http-range requires PHP >=5.6
175         if (PHP_VERSION_ID >= 50600) {
176             header('Accept-Ranges: bytes');
177             $rangeHeader = rex_request::server('HTTP_RANGE', 'string', null);
178             if ($rangeHeader) {
179                 try {
180                     $filesize = filesize($file);
181                     $unitFactory = new \Ramsey\Http\Range\UnitFactory();
182                     $ranges = $unitFactory->getUnit(trim($rangeHeader), $filesize)->getRanges();
183                     $handle = fopen($file, 'r');
184                     if (is_resource($handle)) {
185                         foreach ($ranges as $range) {
186                             header('HTTP/1.1 ' . self::HTTP_PARTIAL_CONTENT);
187                             header('Content-Length: ' . $range->getLength());
188                             header('Content-Range: bytes ' . $range->getStart() . '-' . $range->getEnd() . '/' . $filesize);
189 
190                             // Don't output more bytes as requested
191                             // default chunk size is usually 8192 bytes
192                             $chunkSize = $range->getLength() > 8192 ? 8192 : $range->getLength();
193 
194                             fseek($handle, $range->getStart());
195                             while (ftell($handle) < $range->getEnd()) {
196                                 echo fread($handle, $chunkSize);
197                             }
198                         }
199                         fclose($handle);
200                     } else {
201                         // Send Error if file couldn't be read
202                         header('HTTP/1.1 ' . self::HTTP_INTERNAL_ERROR);
203                     }
204                 } catch (\Ramsey\Http\Range\Exception\HttpRangeException $exception) {
205                     header('HTTP/1.1 ' . self::HTTP_RANGE_NOT_SATISFIABLE);
206                 }
207                 return;
208             }
209         }
210 
211         readfile($file);
212     }
213 
214     /**
215      * Sends a resource to the client.
216      *
217      * @param string      $content            Content
218      * @param null|string $contentType        Content type
219      * @param null|int    $lastModified       HTTP Last-Modified Timestamp
220      * @param null|string $etag               HTTP Cachekey to identify the cache
221      * @param null|string $contentDisposition Content disposition ("inline" or "attachment")
222      * @param null|string $filename           Filename
223      */
224     public static function sendResource($content, $contentType = null, $lastModified = null, $etag = null, $contentDisposition = null, $filename = null)
225     {
226         if ($contentDisposition) {
227             header('Content-Disposition: ' . $contentDisposition . '; filename="' . $filename . '"');
228         }
229 
230         self::sendCacheControl('max-age=3600, must-revalidate, proxy-revalidate, private');
231         self::sendContent($content, $contentType, $lastModified, $etag);
232     }
233 
234     /**
235      * Sends a page to client.
236      *
237      * The page content can be modified by the Extension Point OUTPUT_FILTER
238      *
239      * @param string $content      Content of page
240      * @param int    $lastModified HTTP Last-Modified Timestamp
241      */
242     public static function sendPage($content, $lastModified = null)
243     {
244         // ----- EXTENSION POINT
245         $content = rex_extension::registerPoint(new rex_extension_point('OUTPUT_FILTER', $content));
246 
247         $hasShutdownExtension = rex_extension::isRegistered('RESPONSE_SHUTDOWN');
248         if ($hasShutdownExtension) {
249             header('Connection: close');
250         }
251 
252         self::sendContent($content, null, $lastModified);
253 
254         // ----- EXTENSION POINT - (read only)
255         if ($hasShutdownExtension) {
256             // unlock session
257             session_write_close();
258 
259             rex_extension::registerPoint(new rex_extension_point('RESPONSE_SHUTDOWN', $content, [], true));
260         }
261     }
262 
263     /**
264      * Sends content to the client.
265      *
266      * @param string $content      Content
267      * @param string $contentType  Content type
268      * @param int    $lastModified HTTP Last-Modified Timestamp
269      * @param string $etag         HTTP Cachekey to identify the cache
270      */
271     public static function sendContent($content, $contentType = null, $lastModified = null, $etag = null)
272     {
273         if (!self::$sentContentType) {
274             self::sendContentType($contentType);
275         }
276         if (!self::$sentCacheControl) {
277             self::sendCacheControl();
278         }
279 
280         $environment = rex::isBackend() ? 'backend' : 'frontend';
281 
282         if (
283             self::$httpStatus == self::HTTP_OK &&
284             // Safari incorrectly caches 304s as empty pages, so don't serve it 304s
285             // http://tech.vg.no/2013/10/02/ios7-bug-shows-white-page-when-getting-304-not-modified-from-server/
286             // https://bugs.webkit.org/show_bug.cgi?id=32829
287             (!empty($_SERVER['HTTP_USER_AGENT']) && (false === strpos($_SERVER['HTTP_USER_AGENT'], 'Safari') || false !== strpos($_SERVER['HTTP_USER_AGENT'], 'Chrome')))
288         ) {
289             // ----- Last-Modified
290             if (!self::$sentLastModified
291                 && (rex::getProperty('use_last_modified') === true || rex::getProperty('use_last_modified') === $environment)
292             ) {
293                 self::sendLastModified($lastModified);
294             }
295 
296             // ----- ETAG
297             if (!self::$sentEtag
298                 && (rex::getProperty('use_etag') === true || rex::getProperty('use_etag') === $environment)
299             ) {
300                 self::sendEtag($etag ?: self::md5($content));
301             }
302         }
303 
304         // ----- GZIP
305         if (rex::getProperty('use_gzip') === true || rex::getProperty('use_gzip') === $environment) {
306             $content = self::sendGzip($content);
307         }
308 
309         self::cleanOutputBuffers();
310 
311         header('HTTP/1.1 ' . self::$httpStatus);
312 
313         // content length schicken, damit der browser einen ladebalken anzeigen kann
314         header('Content-Length: ' . rex_string::size($content));
315 
316         self::sendAdditionalHeaders();
317         self::sendPreloadHeaders();
318         self::sendServerTimingHeaders();
319 
320         echo $content;
321 
322         if (function_exists('fastcgi_finish_request')) {
323             fastcgi_finish_request();
324         }
325     }
326 
327     /**
328      * Cleans all output buffers.
329      */
330     public static function cleanOutputBuffers()
331     {
332         while (ob_get_level()) {
333             ob_end_clean();
334         }
335     }
336 
337     /**
338      * Sends the content type header.
339      *
340      * @param string $contentType
341      */
342     public static function sendContentType($contentType = null)
343     {
344         header('Content-Type: ' . ($contentType ?: 'text/html; charset=utf-8'));
345         self::$sentContentType = true;
346     }
347 
348     /**
349      * Sends the cache control header.
350      */
351     public static function sendCacheControl($cacheControl = 'must-revalidate, proxy-revalidate, private, no-cache, max-age=0')
352     {
353         header('Cache-Control: ' . $cacheControl);
354         self::$sentCacheControl = true;
355     }
356 
357     /**
358      * Checks if content has changed by the last modified timestamp.
359      *
360      * HTTP_IF_MODIFIED_SINCE feature
361      *
362      * @param int $lastModified HTTP Last-Modified Timestamp
363      */
364     public static function sendLastModified($lastModified = null)
365     {
366         if (!$lastModified) {
367             $lastModified = time();
368         }
369 
370         $lastModified = gmdate('D, d M Y H:i:s T', (float) $lastModified);
371 
372         // Sende Last-Modification time
373         header('Last-Modified: ' . $lastModified);
374 
375         // Last-Modified Timestamp gefunden
376         // => den Browser anweisen, den Cache zu verwenden
377         if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] == $lastModified) {
378             self::cleanOutputBuffers();
379 
380             header('HTTP/1.1 ' . self::HTTP_NOT_MODIFIED);
381             exit;
382         }
383         self::$sentLastModified = true;
384     }
385 
386     /**
387      * Checks if content has changed by the etag cachekey.
388      *
389      * HTTP_IF_NONE_MATCH feature
390      *
391      * @param string $cacheKey HTTP Cachekey to identify the cache
392      */
393     public static function sendEtag($cacheKey)
394     {
395         // Laut HTTP Spec muss der Etag in " sein
396         $cacheKey = '"' . $cacheKey . '"';
397 
398         // Sende CacheKey als ETag
399         header('ETag: ' . $cacheKey);
400 
401         // CacheKey gefunden
402         // => den Browser anweisen, den Cache zu verwenden
403         if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == $cacheKey) {
404             self::cleanOutputBuffers();
405 
406             header('HTTP/1.1 ' . self::HTTP_NOT_MODIFIED);
407             exit;
408         }
409         self::$sentEtag = true;
410     }
411 
412     /**
413      * Encodes the content with GZIP/X-GZIP if the browser supports one of them.
414      *
415      * HTTP_ACCEPT_ENCODING feature
416      *
417      * @param string $content Content
418      *
419      * @return string
420      */
421     protected static function sendGzip($content)
422     {
423         $enc = '';
424         $encodings = [];
425         $supportsGzip = false;
426 
427         // Check if it supports gzip
428         if (isset($_SERVER['HTTP_ACCEPT_ENCODING'])) {
429             $encodings = explode(',', strtolower(preg_replace('/\s+/', '', $_SERVER['HTTP_ACCEPT_ENCODING'])));
430         }
431 
432         if ((in_array('gzip', $encodings) || in_array('x-gzip', $encodings) || isset($_SERVER['---------------']))
433             && function_exists('ob_gzhandler')
434             && !ini_get('zlib.output_compression')
435         ) {
436             $enc = in_array('x-gzip', $encodings) ? 'x-gzip' : 'gzip';
437             $supportsGzip = true;
438         }
439 
440         if ($supportsGzip) {
441             header('Content-Encoding: ' . $enc);
442             $content = gzencode($content, 9, FORCE_GZIP);
443         }
444 
445         return $content;
446     }
447 
448     // method inspired by https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/Cookie.php
449 
450     /**
451      * @param string      $name    The name of the cookie
452      * @param string|null $value   The value of the cookie, a empty value to delete the cookie.
453      * @param array       $options Different cookie Options. Supported keys are:
454      *                             "expires" int|string|\DateTimeInterface The time the cookie expires
455      *                             "path" string                           The path on the server in which the cookie will be available on
456      *                             "domain" string|null                    The domain that the cookie is available to
457      *                             "secure" bool                           Whether the cookie should only be transmitted over a secure HTTPS connection from the client
458      *                             "httponly" bool                         Whether the cookie will be made accessible only through the HTTP protocol
459      *                             "samesite" string|null                  Whether the cookie will be available for cross-site requests
460      *                             "raw" bool                              Whether the cookie value should be sent with no url encoding
461      *
462      * @throws \InvalidArgumentException
463      */
464     public static function sendCookie($name, $value, array $options = [])
465     {
466         $expire = isset($options['expires']) ? $options['expires'] : 0;
467         $path = isset($options['path']) ? $options['path'] : '/';
468         $domain = isset($options['domain']) ? $options['domain'] : null;
469         $secure = isset($options['secure']) ? $options['secure'] : false;
470         $httpOnly = isset($options['httponly']) ? $options['httponly'] : true;
471         $sameSite = isset($options['samesite']) ? $options['samesite'] : null;
472         $raw = isset($options['raw']) ? $options['raw'] : false;
473 
474         // from PHP source code
475         if (preg_match("/[=,; \t\r\n\013\014]/", $name)) {
476             throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $name));
477         }
478         if (empty($name)) {
479             throw new \InvalidArgumentException('The cookie name cannot be empty.');
480         }
481         // convert expiration time to a Unix timestamp
482         if ($expire instanceof \DateTimeInterface) {
483             $expire = $expire->format('U');
484         } elseif (!is_numeric($expire)) {
485             $expire = strtotime($expire);
486             if (false === $expire) {
487                 throw new \InvalidArgumentException('The cookie expiration time is not valid.');
488             }
489         }
490 
491         $expire = 0 < $expire ? (int) $expire : 0;
492         $maxAge = $expire - time();
493         $maxAge = 0 >= $maxAge ? 0 : $maxAge;
494         $path = empty($path) ? '/' : $path;
495 
496         if (null !== $sameSite) {
497             $sameSite = strtolower($sameSite);
498         }
499         if (!in_array($sameSite, ['lax', 'strict', null], true)) {
500             throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.');
501         }
502 
503         $str = 'Set-Cookie: '. ($raw ? $name : urlencode($name)).'=';
504         if ('' === (string) $value) {
505             $str .= 'deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; Max-Age=0';
506         } else {
507             $str .= $raw ? $value : rawurlencode($value);
508             if (0 !== $expire) {
509                 $str .= '; expires='.gmdate('D, d-M-Y H:i:s T', $expire).'; Max-Age='.$maxAge;
510             }
511         }
512         if ($path) {
513             $str .= '; path='.$path;
514         }
515         if ($domain) {
516             $str .= '; domain='.$domain;
517         }
518         if ($secure) {
519             $str .= '; secure';
520         }
521         if ($httpOnly) {
522             $str .= '; httponly';
523         }
524         if ($sameSite) {
525             $str .= '; samesite='.$sameSite;
526         }
527 
528         header($str, false);
529     }
530 
531     /**
532      * Creates the md5 checksum for the content.
533      *
534      * Dynamic content surrounded by `<!--DYN-->…<!--/DYN-->` is ignored.
535      *
536      * @param string $content
537      *
538      * @return string
539      */
540     private static function md5($content)
541     {
542         return md5(preg_replace('@<!--DYN-->.*<!--/DYN-->@U', '', $content));
543     }
544 
545     public static function enforceHttps()
546     {
547         if (!rex_request::isHttps()) {
548             self::setStatus(self::HTTP_MOVED_PERMANENTLY);
549             self::sendRedirect('https://'.$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']);
550         }
551     }
552 }
553