You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
208 lines
5.9 KiB
208 lines
5.9 KiB
<?php
|
|
|
|
/*
|
|
+-----------------------------------------------------------------------+
|
|
| Roundcube Webmail IMAP Client |
|
|
| |
|
|
| Copyright (C) The Roundcube Dev Team |
|
|
| |
|
|
| Licensed under the GNU General Public License version 3 or |
|
|
| any later version with exceptions for skins & plugins. |
|
|
| See the README file for a full license statement. |
|
|
| |
|
|
| PURPOSE: |
|
|
| This is the public entry point for HTTP requests regarding static |
|
|
| content. |
|
|
+-----------------------------------------------------------------------+
|
|
| Author: Aleksander Machniak <alec@alec.pl> |
|
|
+-----------------------------------------------------------------------+
|
|
*/
|
|
|
|
/**
|
|
* @const array Supported file types/extensions. If file type is not on the list
|
|
* it will have to be served by custom code in your plugin.
|
|
*/
|
|
const SUPPORTED_TYPES = [
|
|
'avif' => 'image/avif',
|
|
'css' => 'text/css',
|
|
'gif' => 'image/gif',
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'html' => 'text/html',
|
|
'ico' => 'image/x-icon',
|
|
'js' => 'text/javascript',
|
|
'json' => 'application/json',
|
|
'less' => 'text/less',
|
|
'mp3' => 'audio/mpeg',
|
|
'png' => 'image/png',
|
|
'pdf' => 'application/pdf',
|
|
'svg' => 'image/svg+xml',
|
|
'tiff' => 'image/tiff',
|
|
'wav' => 'audio/wav',
|
|
'webp' => 'image/webp',
|
|
'woff' => 'font/woff',
|
|
'woff2' => 'font/woff2',
|
|
];
|
|
|
|
/**
|
|
* @const array Path prefixes to look for the requested files
|
|
*/
|
|
const ALLOWED_PATHS = [
|
|
'installer/',
|
|
'plugins/',
|
|
'program/',
|
|
'skins/',
|
|
];
|
|
|
|
define('INSTALL_PATH', realpath(__DIR__ . '/..') . '/');
|
|
|
|
$path = validateStaticFile($_SERVER['PATH_INFO']);
|
|
|
|
if (!$path) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
|
|
serveStaticFile($path);
|
|
|
|
/**
|
|
* Validate that the file exists and can be served
|
|
*
|
|
* @param string $path File location
|
|
*
|
|
* @return ?string Verified and resolved file location
|
|
*/
|
|
function validateStaticFile(string $path): ?string
|
|
{
|
|
$path = trim($path, "/ \t\r\n");
|
|
|
|
// Remove query params from the path (e.g. cache buster)
|
|
$path = preg_replace('/[?&].*$/', '', $path);
|
|
|
|
// Potential hack attempts, don't allow ".."
|
|
if (strpos($path, '..') !== false) {
|
|
return null;
|
|
}
|
|
|
|
$ext = pathinfo($path, \PATHINFO_EXTENSION);
|
|
|
|
// Only supported file types
|
|
if (empty($ext) || !isset(SUPPORTED_TYPES[strtolower($ext)])) {
|
|
return null;
|
|
}
|
|
|
|
// Ignore some sensitive files
|
|
if (preg_match('/(README.*|CHANGELOG.*|SECURITY.*|meta\.json|composer\..*)/', $path)) {
|
|
return null;
|
|
}
|
|
|
|
$found = false;
|
|
foreach (ALLOWED_PATHS as $prefix) {
|
|
if (str_starts_with($path, $prefix) && !preg_match('~skins/.+/templates/~', $path)) {
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$found) {
|
|
return null;
|
|
}
|
|
|
|
$path = realpath(INSTALL_PATH . $path);
|
|
|
|
if ($path === false) {
|
|
return null;
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Serve a file.
|
|
*
|
|
* @param string $path File location
|
|
*/
|
|
function serveStaticFile($path): void
|
|
{
|
|
$lastModifiedTime = filemtime($path);
|
|
|
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s \G\M\T', $lastModifiedTime));
|
|
if (!empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModifiedTime) {
|
|
http_response_code(304); // "Not Modified"
|
|
return;
|
|
}
|
|
|
|
$size = filesize($path);
|
|
$fp = fopen($path, 'r');
|
|
$range = [0, $size - 1];
|
|
|
|
if (isset($_SERVER['HTTP_RANGE'])) {
|
|
// $valid = preg_match('^bytes=\d*-\d*(,\d*-\d*)*$', $_SERVER['HTTP_RANGE']);
|
|
if (!str_starts_with($_SERVER['HTTP_RANGE'], 'bytes=')) {
|
|
http_response_code(416); // "Range Not Satisfiable"
|
|
header('Content-Range: bytes */' . $size); // Required in 416.
|
|
return;
|
|
}
|
|
|
|
$ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
|
|
$range = explode('-', $ranges[0]); // TODO: only support the first range now.
|
|
|
|
if ($range[0] === '') {
|
|
$range[0] = 0;
|
|
}
|
|
if ($range[1] === '') {
|
|
$range[1] = $size - 1;
|
|
}
|
|
|
|
if ($range[0] >= 0 && ($range[1] <= $size - 1) && $range[0] <= $range[1]) {
|
|
http_response_code(206); // "Partial Content"
|
|
header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
|
|
} else {
|
|
http_response_code(416); // "Range Not Satisfiable"
|
|
header('Content-Range: bytes */' . $size);
|
|
return;
|
|
}
|
|
}
|
|
|
|
$contentLength = $range[1] - $range[0] + 1;
|
|
$ext = pathinfo($path, \PATHINFO_EXTENSION);
|
|
|
|
$headers = [
|
|
'Accept-Ranges' => 'bytes',
|
|
'Content-Length' => $contentLength,
|
|
'Content-Type' => SUPPORTED_TYPES[strtolower($ext)],
|
|
// 'Content-Disposition: attachment; filename="xxxxx"',
|
|
'Cache-Control' => 'public, max-age=604800',
|
|
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 30 * 86400),
|
|
];
|
|
|
|
foreach ($headers as $k => $v) {
|
|
header("{$k}: {$v}", true);
|
|
}
|
|
|
|
if ($range[0] > 0) {
|
|
fseek($fp, $range[0]);
|
|
}
|
|
|
|
$sentSize = 0;
|
|
|
|
while (!feof($fp) && (connection_status() === \CONNECTION_NORMAL)) {
|
|
$readingSize = $contentLength - $sentSize;
|
|
$readingSize = min($readingSize, 512 * 1024);
|
|
|
|
if ($readingSize <= 0) {
|
|
break;
|
|
}
|
|
|
|
$data = fread($fp, $readingSize);
|
|
if ($data === false) {
|
|
break;
|
|
}
|
|
|
|
$sentSize += strlen($data);
|
|
echo $data;
|
|
flush();
|
|
}
|
|
|
|
fclose($fp);
|
|
}
|