174 lines
5.7 KiB
PHP
174 lines
5.7 KiB
PHP
<?php
|
|
/**
|
|
* Router for PHP built-in development server.
|
|
* Replicates .htaccess rewrite rules for local development.
|
|
*
|
|
* Usage: php -S localhost:3000 router.php
|
|
*
|
|
* The built-in server uses the CLI php.ini, whose upload_max_filesize is often
|
|
* 2M; to test large image uploads start it with higher limits:
|
|
* php -d upload_max_filesize=20M -d post_max_size=25M -S localhost:3000 router.php
|
|
*/
|
|
|
|
$uri = rawurldecode((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));
|
|
if (preg_match('/[\x00-\x1f\x7f\\\\]/', $uri) || preg_match('#(^|/)\.\.?(/|$)#', $uri)) {
|
|
http_response_code(403);
|
|
return true;
|
|
}
|
|
$root = __DIR__;
|
|
$filePath = $root . $uri;
|
|
|
|
require_once $root . '/includes/page-path.php';
|
|
|
|
/**
|
|
* PHP's built-in server does not reliably honor byte-range requests for media.
|
|
* Scroll-scrubbed videos need ranges so the browser can seek to an arbitrary
|
|
* frame without downloading the entire file first.
|
|
*/
|
|
function _routerServeSeekableMedia(string $filePath): bool {
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
$mimeTypes = [
|
|
'mp4' => 'video/mp4',
|
|
'webm' => 'video/webm',
|
|
'mp3' => 'audio/mpeg',
|
|
'm4a' => 'audio/mp4',
|
|
'ogg' => 'audio/ogg',
|
|
'wav' => 'audio/wav',
|
|
];
|
|
if (!isset($mimeTypes[$extension])) return false;
|
|
|
|
$size = filesize($filePath);
|
|
if ($size === false || $size < 1) return false;
|
|
|
|
$start = 0;
|
|
$end = $size - 1;
|
|
$status = 200;
|
|
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
|
|
|
if ($range !== '') {
|
|
if (!preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches) || ($matches[1] === '' && $matches[2] === '')) {
|
|
header('Content-Range: bytes */' . $size);
|
|
http_response_code(416);
|
|
return true;
|
|
}
|
|
|
|
if ($matches[1] === '' && $matches[2] !== '') {
|
|
$suffixLength = min((int)$matches[2], $size);
|
|
$start = $size - $suffixLength;
|
|
} else {
|
|
$start = (int)$matches[1];
|
|
if ($matches[2] !== '') $end = min((int)$matches[2], $end);
|
|
}
|
|
|
|
if ($start < 0 || $start > $end || $start >= $size) {
|
|
header('Content-Range: bytes */' . $size);
|
|
http_response_code(416);
|
|
return true;
|
|
}
|
|
$status = 206;
|
|
}
|
|
|
|
$length = $end - $start + 1;
|
|
http_response_code($status);
|
|
header('Content-Type: ' . $mimeTypes[$extension]);
|
|
header('Accept-Ranges: bytes');
|
|
header('Content-Length: ' . $length);
|
|
if ($status === 206) header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') return true;
|
|
|
|
$handle = fopen($filePath, 'rb');
|
|
if ($handle === false) {
|
|
http_response_code(500);
|
|
return true;
|
|
}
|
|
fseek($handle, $start);
|
|
$remaining = $length;
|
|
while ($remaining > 0 && !feof($handle)) {
|
|
$chunk = fread($handle, min(8192, $remaining));
|
|
if ($chunk === false || $chunk === '') break;
|
|
echo $chunk;
|
|
$remaining -= strlen($chunk);
|
|
}
|
|
fclose($handle);
|
|
return true;
|
|
}
|
|
|
|
|
|
// Block access to sensitive paths BEFORE serving any files
|
|
if (preg_match('#^/(content|backups|tests|cli|not-for-git)(/|$)|-trash(/|$)|(^|/)\.(?!well-known(?:/|$))#i', $uri)) {
|
|
http_response_code(403);
|
|
echo '403 Forbidden';
|
|
return true;
|
|
}
|
|
if (preg_match('#/(config|smtp-config)\.php$#i', $uri)) {
|
|
http_response_code(403);
|
|
echo '403 Forbidden';
|
|
return true;
|
|
}
|
|
// Uploaded assets are static only; the built-in server would otherwise execute
|
|
// a .php file placed under assets/. Mirrors assets/.htaccess for production.
|
|
if (preg_match('#^/assets/.+\.(php[0-9]?|phtml|phtm|pht|phar|phps|cgi|pl|py|shtml)$#i', $uri)) {
|
|
http_response_code(403);
|
|
echo '403 Forbidden';
|
|
return true;
|
|
}
|
|
|
|
// Run the same route-specific normalization as Apache before directory lookup.
|
|
require_once $root . '/includes/routing-helper.php';
|
|
raijuRoutingCanonicalize($root);
|
|
|
|
// Serve existing files directly (CSS, JS, images, etc.)
|
|
if ($uri !== '/' && is_file($filePath)) {
|
|
if (_routerServeSeekableMedia($filePath)) return true;
|
|
return false;
|
|
}
|
|
|
|
if ($uri === '/sitemap.xml' || $uri === '/robots.txt') {
|
|
if (!is_file($root . '/admin/config.php')) {
|
|
http_response_code(404);
|
|
return true;
|
|
}
|
|
require_once $root . '/admin/config.php';
|
|
require_once $root . '/includes/seo-helper.php';
|
|
if ($uri === '/sitemap.xml') {
|
|
raijuSeoServeSitemap();
|
|
}
|
|
raijuSeoServeRobots();
|
|
}
|
|
|
|
// Apply migration redirects before slug-based routing.
|
|
require_once $root . '/includes/redirect-helper.php';
|
|
applyRedirects($_SERVER['REQUEST_URI'] ?? '/');
|
|
|
|
require_once $root . '/includes/access-guard.php';
|
|
raijuAccessEnforceMaintenance();
|
|
|
|
// Serve existing directories with index.php
|
|
if (is_dir($filePath)) {
|
|
$index = rtrim($filePath, '/') . '/index.php';
|
|
if (is_file($index)) {
|
|
$_SERVER['SCRIPT_FILENAME'] = $index;
|
|
$_SERVER['SCRIPT_NAME'] = $_SERVER['PHP_SELF'] = rtrim($uri, '/') . '/index.php';
|
|
require_once $root . '/includes/analytics-helper.php';
|
|
raijuAnalyticsScheduleTrack(null, null, basename(rtrim($uri, '/')) ?: 'home');
|
|
include $index;
|
|
return true;
|
|
}
|
|
// Don't return false yet — a directory name might collide with a
|
|
// primary-language page slug (e.g. /docs dir vs en/docs.php).
|
|
// Fall through to the slug-based routing below.
|
|
}
|
|
|
|
// Clean URLs: try appending .php
|
|
$phpFile = $filePath . '.php';
|
|
if (is_file($phpFile)) {
|
|
require_once $root . '/includes/analytics-helper.php';
|
|
raijuAnalyticsScheduleTrack(null, null, basename($filePath));
|
|
include $phpFile;
|
|
return true;
|
|
}
|
|
|
|
// Share dynamic routing with Apache so language and page behavior stays identical.
|
|
require $root . '/route.php';
|
|
return true;
|