Added support for legacy scripts via CGI mode instead of CLI SAPI

This commit is contained in:
Gregory Magarshak
2026-07-21 08:39:07 -04:00
parent dce9b9660a
commit 5983f11b91
6 changed files with 559 additions and 65 deletions
+49 -24
View File
@@ -873,7 +873,40 @@ class Q_WebServer
return self::handlePhp($client, $parsed, $indexPhp);
}
// 8. Not found
// 8. Configurable fallback (SPA routing, custom 404 page, etc.)
// Q.webserver.fallback can be:
// - string: path to a static file relative to web/ (e.g. "index.html")
// - object with "handler": event name to dispatch via Q::event()
// - object with "file": static file + auto-detect Content-Type
$fallback = Q_Config::get('Q', 'webserver', 'fallback', null);
if ($fallback !== null) {
if (is_string($fallback)) {
// Static file (SPA catch-all: serve index.html for all routes)
$fbPath = self::$rootDir . str_replace('/', DS, $fallback);
if (is_file($fbPath)) {
return self::serveFile($client, $parsed, $fbPath);
}
} elseif (is_array($fallback)) {
if (!empty($fallback['handler'])) {
// Route to a handler — build a synthetic Q_Uri
$uri = Q_Uri::from(array(
'module' => dirname($fallback['handler']),
'action' => basename($fallback['handler']),
'_originalPath' => $path,
));
if ($uri) {
return self::handleRoute($client, $parsed, $uri);
}
} elseif (!empty($fallback['file'])) {
$fbPath = self::$rootDir . str_replace('/', DS, $fallback['file']);
if (is_file($fbPath)) {
return self::serveFile($client, $parsed, $fbPath);
}
}
}
}
// 9. Not found
self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8');
return false;
}
@@ -959,14 +992,10 @@ class Q_WebServer
// 3. Response
Q::event("$module/$action/response", $routed, false, true);
foreach (headers_list() as $h) {
if (strpos($h, ':') !== false) {
list($k, $v) = explode(':', $h, 2);
$headers[trim($k)] = trim($v);
}
}
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code) $status = $code;
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
@@ -1011,14 +1040,10 @@ class Q_WebServer
echo 'Method Not Allowed';
}
Q::event("$module/$action/response", $routed, false, true);
foreach (headers_list() as $h) {
if (strpos($h, ':') !== false) {
list($k, $v) = explode(':', $h, 2);
$headers[trim($k)] = trim($v);
}
}
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code) $status = $code;
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
@@ -1161,8 +1186,8 @@ if (class_exists('Q_Request',false)) Q_Request::$input = $raw;
ob_start(); $status = 200; $headers = [];
try {
if (is_file($req['scriptPath'])) include $req['scriptPath']; else { $status = 404; echo 'Not Found'; }
foreach (headers_list() as $h) { if (strpos($h,':')!==false) { [$k,$v] = explode(':',$h,2); $headers[trim($k)] = trim($v); } }
$code = http_response_code(); if ($code) $status = $code;
$headers = Q::getResponseHeaders();
$code = http_response_code(); if (Q::$_responseCode !== 200) $status = Q::$_responseCode; if ($code) $status = $code;
} catch (Throwable $e) { $status = 500; ob_clean(); echo $e->getMessage(); $headers['Content-Type']='text/plain'; }
$body = ob_get_clean();
echo json_encode(compact('status','body','headers'), JSON_UNESCAPED_SLASHES);
@@ -1729,6 +1754,8 @@ HTML
while (ob_get_level()) ob_end_clean();
header_remove();
http_response_code(200);
Q::clearResponseHeaders();
if (class_exists('Q_Response', false)) Q_Response::clear();
ob_start();
$status = 200;
$headers = array();
@@ -1746,14 +1773,10 @@ HTML
echo 'Not Found';
}
}
foreach (headers_list() as $h) {
if (strpos($h, ':') !== false) {
list($k, $v) = explode(':', $h, 2);
$headers[trim($k)] = trim($v);
}
}
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code) $status = $code;
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
@@ -2076,6 +2099,8 @@ HTML
private static function resolveStatic($urlPath)
{
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
// Block null bytes (directory traversal via null byte injection)
if (strpos($rel, "\0") !== false) return null;
$fsPath = realpath(self::$rootDir . $rel);
if (!$fsPath) return null;
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
+31 -4
View File
@@ -118,6 +118,14 @@ class Q_WebServer_Headers
$headers['Content-Length'] = strlen($body);
$headers['Connection'] = 'close';
// Merge Q_Response cookies into Set-Cookie headers
if (class_exists('Q_Response', false)) {
$cookieHeaders = Q_Response::cookieHeaders();
foreach ($cookieHeaders as $ch) {
$headers['Set-Cookie'] = $ch; // last one wins for single-value
}
}
static $reasons = array(
200=>'OK', 201=>'Created', 204=>'No Content',
301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified',
@@ -132,6 +140,17 @@ class Q_WebServer_Headers
foreach ($headers as $k => $v) {
$out .= "$k: $v\r\n";
}
// Multiple Set-Cookie headers (can't use the associative array for dupes)
if (class_exists('Q_Response', false)) {
$cookieHeaders = Q_Response::cookieHeaders();
if (count($cookieHeaders) > 1) {
// Remove the single Set-Cookie we added above
$out = preg_replace("/Set-Cookie:.*\r\n/", "", $out);
foreach ($cookieHeaders as $ch) {
$out .= "Set-Cookie: $ch\r\n";
}
}
}
@fwrite($client, $out . "\r\n" . $body);
return true;
}
@@ -337,7 +356,7 @@ class Q_WebServer_Headers
*/
static function resolveAccelPath($accelPath)
{
// Check configured mappings first
// 1. Check configured mappings first (absolute path overrides)
$mappings = Q_Config::get('Q', 'webserver', 'accel', 'mappings', array());
foreach ($mappings as $prefix => $diskPath) {
if (strpos($accelPath, $prefix) === 0) {
@@ -345,7 +364,6 @@ class Q_WebServer_Headers
$fsPath = rtrim($diskPath, DS) . DS
. ltrim(str_replace('/', DS, $relative), DS);
$real = realpath($fsPath);
// Ensure we don't escape the mapped directory
if ($real && strpos($real, realpath($diskPath)) === 0) {
return $real;
}
@@ -353,12 +371,21 @@ class Q_WebServer_Headers
}
}
// Default: resolve relative to APP_DIR (not web root
// the point is to serve files OUTSIDE the web root)
// 2. Default: resolve relative to APP_DIR (project root).
// By convention, private files live in files/ (sibling of web/).
// X-Accel-Redirect: /files/private/doc.pdf
// → APP_DIR/files/private/doc.pdf
if (defined('APP_DIR')) {
$fsPath = APP_DIR . DS . ltrim(str_replace('/', DS, $accelPath), DS);
$real = realpath($fsPath);
if ($real && strpos($real, realpath(APP_DIR)) === 0) {
// Ensure we're NOT serving from web/ — that defeats the purpose
if (isset(Q_WebServer::$rootDir)) {
$webRoot = realpath(rtrim(Q_WebServer::$rootDir, DS));
if ($webRoot && strpos($real, $webRoot) === 0) {
return null; // don't serve public files via accel
}
}
return $real;
}
}