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
+132 -37
View File
@@ -61,6 +61,7 @@ dramatically faster and more scalable.
- [Architecture](#-architecture)
- [HTTP/2 Support](#-http2-support)
- [Requirements](#-requirements)
- [Roadmap](#-roadmap)
- [License](#-license)
---
@@ -295,21 +296,24 @@ php qbixserver.php --port=8080 # done
Qbix Server understands special response headers from your PHP scripts. These are
the same headers nginx understands (like `X-Accel-Redirect`) plus new ones for
component-level caching. Your PHP sends them with `header()`, the server acts on them.
component-level caching. Your PHP sends them with `Q::header()`, the server acts on them.
### Quick reference
| Header | What it does | Example |
|---|---|---|
| `Cache-Control` | Server caches the response, serves without running PHP | `header('Cache-Control: public, max-age=300');` |
| `X-Accel-Redirect` | Server streams a file after PHP checks access | `header('X-Accel-Redirect: /uploads/private/doc.pdf');` |
| `X-Cache-Tree` | Registers page components with content hashes | `header('X-Cache-Tree: ' . json_encode([...]));` |
| `X-Cache-Deps` | Maps components to data dependency keys | `header('X-Cache-Deps: ' . json_encode([...]));` |
| `X-Cache-Invalidate` | Marks dependency keys as stale | `header('X-Cache-Invalidate: ' . json_encode([...]));` |
| `X-Cache-Stale` | Marks specific components as needing re-render | `header('X-Cache-Stale: feed,sidebar');` |
| `Cache-Control` | Server caches the response, serves without running PHP | `Q::header('Cache-Control: public, max-age=300');` |
| `X-Accel-Redirect` | Server streams a file after PHP checks access | `Q::header('X-Accel-Redirect: /uploads/private/doc.pdf');` |
| `X-Cache-Tree` | Registers page components with content hashes | `Q::header('X-Cache-Tree: ' . json_encode([...]));` |
| `X-Cache-Deps` | Maps components to data dependency keys | `Q::header('X-Cache-Deps: ' . json_encode([...]));` |
| `X-Cache-Invalidate` | Marks dependency keys as stale | `Q::header('X-Cache-Invalidate: ' . json_encode([...]));` |
| `X-Cache-Stale` | Marks specific components as needing re-render | `Q::header('X-Cache-Stale: feed,sidebar');` |
All of these are standard PHP `header()` calls. No SDK, no framework needed.
The server strips them before sending the response to the client.
All of these use `Q::header()` instead of PHP's `header()`. This is because
the server runs in CLI SAPI where `header()` calls are silently discarded —
same as FrankenPHP worker mode and Workerman. `Q::header()` has the same
signature as `header()` but captures the values for the server to send.
The server strips internal headers before sending the response to the client.
### Access-controlled static files
@@ -317,8 +321,17 @@ With a typical server, your uploaded files sit at public URLs. Anyone with the l
access them — and share the link with others. The usual workaround is "unguessable" URLs,
which are just security through obscurity.
`X-Accel-Redirect` lets your PHP check access, then tells the server to serve the file
directly — fast, streamed, with no public URL exposed:
`X-Accel-Redirect` lets your PHP check access, then tells the server to serve the file.
By convention, private files live in `files/` — a sibling of `web/`, outside the document root:
```
myproject/
├── web/ ← public (accessible via URL)
│ └── download.php ← checks access, sends X-Accel-Redirect
└── files/ ← private (NOT accessible via URL)
└── private/
└── doc.pdf ← served only through download.php
```
```php
<?php
@@ -328,25 +341,36 @@ session_start();
$fileId = $_GET['id'] ?? '';
$userId = $_SESSION['user_id'] ?? null;
// Your access control logic
if (!$userId || !userCanAccess($userId, $fileId)) {
http_response_code(403);
echo 'Access denied';
exit;
}
// Tell the server to serve the file directly.
// Tell the server to serve from files/ directory.
// The client never sees the real path.
header("X-Accel-Redirect: /uploads/private/{$fileId}");
header("Content-Disposition: attachment; filename=\"document.pdf\"");
// The server takes over from here — streams the file
// with correct Content-Type, ETag, compression, etc.
// Your PHP process is already done.
Q::header("X-Accel-Redirect: /files/private/{$fileId}");
Q::header("Content-Disposition: attachment; filename=\"document.pdf\"");
```
No public URL for the file. No redirect the user can bookmark. The server streams
the file after your PHP has verified access and exited.
No config needed — `files/` is resolved automatically. For custom mappings:
```json
{
"Q": {
"webserver": {
"accel": {
"mappings": {
"/protected/": "/mnt/storage/protected/",
"/media/": "/var/data/media/"
}
}
}
}
}
```
For nginx compatibility, mirror the mappings: `location /files/ { internal; alias /path/to/files/; }`
### Reverse proxy cache
@@ -358,7 +382,7 @@ Control how the server caches your PHP responses:
// The server caches this response and serves it without
// running PHP again for the next 300 seconds.
header('Cache-Control: public, max-age=300');
Q::header('Cache-Control: public, max-age=300');
echo renderFeed();
```
@@ -370,7 +394,7 @@ echo renderFeed();
// The server generates an ETag from the response body.
// Browsers send If-None-Match on next request.
// Server returns 304 (no body) if nothing changed.
header('Cache-Control: public, max-age=0, must-revalidate');
Q::header('Cache-Control: public, max-age=0, must-revalidate');
echo renderProfile($userId);
```
@@ -379,7 +403,7 @@ echo renderProfile($userId);
<?php
// web/admin.php — never cache
header('Cache-Control: no-store');
Q::header('Cache-Control: no-store');
echo renderAdminPanel();
```
@@ -401,7 +425,7 @@ $sidebarHtml = renderSidebar($communityId);
$membersHtml = renderMembers($communityId);
// Tell the server about the component tree and what data each depends on
header('X-Cache-Tree: ' . json_encode([
Q::header('X-Cache-Tree: ' . json_encode([
'l' => [
'feed' => md5($feedHtml),
'sidebar' => md5($sidebarHtml),
@@ -409,13 +433,13 @@ header('X-Cache-Tree: ' . json_encode([
]
]));
header('X-Cache-Deps: ' . json_encode([
Q::header('X-Cache-Deps: ' . json_encode([
'feed' => ["community/{$communityId}/feed"],
'sidebar' => ["community/{$communityId}/about"],
'members' => ["community/{$communityId}/participants"],
]));
header('Cache-Control: public, max-age=300');
Q::header('Cache-Control: public, max-age=300');
echo $feedHtml . $sidebarHtml . $membersHtml;
```
@@ -427,7 +451,7 @@ echo $feedHtml . $sidebarHtml . $membersHtml;
saveNewPost($communityId, $content);
// Tell the server which dependency key changed
header('X-Cache-Invalidate: ' . json_encode([
Q::header('X-Cache-Invalidate: ' . json_encode([
"community/{$communityId}/feed"
]));
@@ -445,7 +469,7 @@ affected. Everything else is served from the in-memory cache.
### Even more powerful with Qbix Platform
These headers work with plain PHP `header()` calls as shown above. But with the
These headers work with `Q::header()` calls as shown above. But with the
[Qbix Platform](https://github.com/Qbix/Platform), it becomes automatic:
```php
@@ -685,7 +709,7 @@ if (!$user) {
exit;
}
header('Content-Type: application/json');
Q::header('Content-Type: application/json');
echo json_encode([
'token' => Chat\Auth::createToken($user['id']),
'userId' => $user['id'],
@@ -699,8 +723,8 @@ echo json_encode([
$room = $_GET['room'] ?? 'general';
$limit = min((int)($_GET['limit'] ?? 50), 200);
header('Content-Type: application/json');
header('Cache-Control: public, max-age=5');
Q::header('Content-Type: application/json');
Q::header('Cache-Control: public, max-age=5');
echo json_encode(Chat\Messages::recent($room, $limit));
```
@@ -881,7 +905,7 @@ function api_users_validate(&$params, &$result) {
<?php
// handlers/api/users/get.php — handles GET /api/users
function api_users_get(&$params, &$result) {
header('Content-Type: application/json');
Q::header('Content-Type: application/json');
echo json_encode(MyApp\Users::list($_GET));
}
```
@@ -892,7 +916,7 @@ function api_users_get(&$params, &$result) {
function api_users_post(&$params, &$result) {
$user = MyApp\Users::create($_POST);
http_response_code(201);
header('Content-Type: application/json');
Q::header('Content-Type: application/json');
echo json_encode($user);
}
```
@@ -904,13 +928,47 @@ function api_users_post(&$params, &$result) {
2. PHP scripts /legacy.php → web/legacy.php
3. Routed handlers /api/users → handlers/api/users/get.php
4. index.php fallback /anything → web/index.php (if exists)
5. 404
5. Configurable fallback /anything → see below
6. 404
```
Static files and `.php` scripts take priority. Routing only activates when
`Q.routes` is configured and no file matches. This means you can mix
routed handlers with direct PHP scripts — migrate gradually.
### Fallback — SPA routing, custom 404, catch-all
When nothing matches, the server checks `Q.webserver.fallback` in config.
Three options:
**SPA catch-all** — serve `index.html` for all unmatched routes (React, Vue, etc.):
```json
{ "Q": { "webserver": { "fallback": "index.html" } } }
```
**Custom 404 handler** — PHP processes the 404 (logging, custom pages):
```json
{ "Q": { "webserver": { "fallback": {"handler": "app/notfound"} } } }
```
```php
<?php
// handlers/app/notfound/get.php
function app_notfound_get(&$params, &$result) {
Q_Response::code(404);
Q::header('Content-Type: text/html');
echo Q::view('app/404.php', ['path' => $_SERVER['REQUEST_URI']]);
}
```
**Static 404 page** — serve a file without invoking PHP:
```json
{ "Q": { "webserver": { "fallback": {"file": "404.html"} } } }
```
### The full symmetry
```
@@ -981,7 +1039,7 @@ use MyApp\User;
$user = User::find($_GET['id']);
$feed = Q::event('MyApp/feed/get', ['userId' => $user->id]);
header('Content-Type: application/json');
Q::header('Content-Type: application/json');
echo json_encode($feed);
```
@@ -994,6 +1052,7 @@ what you get:
|---|---|
| `Q::event($name, $params)` | Fire an event — runs the handler from `handlers/` |
| `Q::canHandle($name)` | Check if a handler exists for an event |
| `Q::header($str, $replace, $code)` | Set a response header (use instead of `header()`) |
| `Q::view($name, $params)` | Render a PHP template from `views/` |
| `Q::ifset($arr, 'key1', 'key2', $default)` | Safe nested array/object access without isset chains |
| `Q::getObject($data, ['path', 'to', 'key'], $default)` | Deep access into nested arrays/objects |
@@ -1011,6 +1070,11 @@ what you get:
| `Q_Request::files('avatar')` | Uploaded files from `$_FILES` |
| `Q_Request::isAjax()` | True if X-Requested-With: XMLHttpRequest |
| `Q_Request::isJson()` | True if Content-Type is application/json |
| `Q_Request::isInternal()` | True if genuine CLI, false if server-dispatched |
| `Q_Response::setHeader($name, $value)` | Set a response header |
| `Q_Response::code(201)` | Set HTTP status code |
| `Q_Response::setCookie($name, $val, ...)` | Set a cookie (prevents duplicates) |
| `Q_Response::redirect($url)` | 302 redirect (or 301 with `permanently`) |
```php
<?php
@@ -1035,6 +1099,25 @@ echo Q::view('MyApp/settings/page.php', [
]);
```
### Why `Q::header()` instead of `header()`?
The server runs PHP in CLI SAPI (same as FrankenPHP worker mode and Workerman).
PHP's built-in `header()` is silently discarded in CLI mode. `Q::header()` has
the exact same signature but captures headers so the server can send them:
```php
Q::header('Content-Type: application/json'); // same as header() but works
Q::header('HTTP/1.1 201 Created', true, 201); // status code
Q_Response::setHeader('X-Custom', 'value'); // named method
Q_Response::code(201); // status code
Q_Response::setCookie('session', $id); // cookies
Q_Response::redirect('/login'); // redirect
```
For existing code that calls `header()` directly, a CGI carveout mode is coming —
configure URL patterns to use `php-cgi` where native `header()` works (see roadmap).
When you upgrade to the full [Qbix Platform](https://github.com/Qbix/Platform),
the `Q` class expands with hundreds more methods — but everything above
continues to work identically. Your scripts don't need to change.
@@ -1143,7 +1226,7 @@ $result = Q::event('MyApp/feed/post', [
'userId' => $_SESSION['user_id'],
]);
header('Content-Type: application/json');
Q::header('Content-Type: application/json');
echo json_encode($result);
```
@@ -1572,6 +1655,18 @@ the full 10x performance advantage, use Linux or macOS (or WSL).
---
## 🗺️ Roadmap
**Coming next:**
- **Virtual hosts** — `Q.web.hosts.$hostname` config overrides for multi-domain serving
- **CGI carveouts** — regex URL patterns that use `php-cgi` subprocess for full `header()`/`setcookie()` compatibility with legacy code (WordPress, etc.)
- **Hot reload** — watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers
- **Scheduler** — cron-like timed events from config, executed by the event loop
- **Request timeout** — kill workers that exceed N seconds
---
## 📄 License
MIT — see [LICENSE](LICENSE).
BIN
View File
Binary file not shown.
+3
View File
@@ -110,6 +110,9 @@ if (!$webDir || !is_dir($webDir)) {
// Initialize Q with the project root (parent of web/)
// This sets up autoloading from classes/ and handlers from handlers/
$projectRoot = dirname($webDir);
if (!defined('APP_DIR')) {
define('APP_DIR', $projectRoot);
}
Q::init($projectRoot);
// Load .env file if present (sets $_ENV and getenv())
+344
View File
@@ -128,6 +128,93 @@ class Q
return json_decode($json, $assoc, $depth, $options);
}
/**
* Captured response headers. PHP's headers_list() returns empty in CLI SAPI,
* so we capture headers ourselves when scripts call header().
* @property $_responseHeaders
* @static
*/
static $_responseHeaders = array();
static $_responseCode = 200;
/**
* Set a response header. Wraps PHP's header() and captures it.
* Scripts can call either header() directly or Q::header() — both work.
* But Q::header() ensures capture in CLI SAPI mode.
* @method header
* @static
* @param {string} $header Full header string e.g. "Content-Type: text/html"
* @param {boolean} $replace Replace existing header of same name
* @param {integer} $code HTTP status code
*/
static function header($header, $replace = true, $code = 0)
{
// Delegate to Q_Response for proper tracking
$colonPos = strpos($header, ':');
if ($colonPos !== false) {
$name = trim(substr($header, 0, $colonPos));
$value = trim(substr($header, $colonPos + 1));
if (class_exists('Q_Response', false)) {
Q_Response::setHeader($name, $value, $replace);
} else {
// Fallback: direct capture
if ($replace) {
self::$_responseHeaders[$name] = $value;
} elseif (!isset(self::$_responseHeaders[$name])) {
self::$_responseHeaders[$name] = $value;
}
}
}
if ($code > 0) {
self::$_responseCode = $code;
if (class_exists('Q_Response', false)) {
Q_Response::code($code);
}
}
// Also call native header() (works in non-CLI SAPIs)
@header($header, $replace, $code);
}
/**
* Get all captured response headers.
* Falls back to headers_list() if available (non-CLI SAPI).
* @method getResponseHeaders
* @static
* @return {array}
*/
static function getResponseHeaders()
{
// Try PHP native first (works in non-CLI SAPIs)
$native = headers_list();
if (!empty($native)) {
$result = array();
foreach ($native as $h) {
$p = strpos($h, ':');
if ($p !== false) {
$result[trim(substr($h, 0, $p))] = trim(substr($h, $p + 1));
}
}
return $result;
}
// CLI SAPI: merge Q_Response headers over Q:: captured headers
$headers = self::$_responseHeaders;
if (class_exists('Q_Response', false)) {
$headers = array_merge($headers, Q_Response::getHeaders());
}
return $headers;
}
/**
* Clear captured headers (called between requests).
* @method clearResponseHeaders
* @static
*/
static function clearResponseHeaders()
{
self::$_responseHeaders = array();
self::$_responseCode = 200;
}
// ── Event system ────────────────────────────────────
/**
@@ -501,6 +588,227 @@ class Q_Socket
// ── Q_Request ───────────────────────────────────────
/**
* Minimal Q_Response — compatible subset of the Qbix Platform's Q_Response.
* Manages response headers, status codes, and cookies in CLI SAPI mode
* where PHP's header()/setcookie()/headers_list() don't work.
*
* Use Q::header() for simple cases, or Q_Response methods for full control.
*
* @class Q_Response
*/
class Q_Response
{
/** @var array Response headers: name => value */
protected static $headers = array();
/** @var integer HTTP status code */
protected static $statusCode = 200;
/** @var string Status message */
protected static $statusMessage = 'OK';
/** @var array Cookies to set: name => [value, expires, path, domain, secure, httponly, samesite] */
public static $cookies = array();
/** @var array Cookies to remove */
protected static $cookiesToRemove = array();
/** @var string|null Redirect URL if set */
public static $redirected = null;
/**
* Set a response header. Compatible with Q_Response::setHeader() from the Platform.
* @method setHeader
* @static
* @param {string} $name Header name (e.g. 'Content-Type')
* @param {string} $value Header value
* @param {boolean} $replace Whether to replace existing header of same name
*/
static function setHeader($name, $value, $replace = true)
{
if ($replace || !isset(self::$headers[$name])) {
self::$headers[$name] = $value;
}
// Also store in Q's header capture
Q::$_responseHeaders[$name] = $value;
// Call native header() for non-CLI SAPIs
@header("$name: $value", $replace);
}
/**
* Get a response header that was set.
* @method getHeader
* @static
* @param {string} $name
* @return {string|null}
*/
static function getHeader($name)
{
return self::$headers[$name] ?? null;
}
/**
* Get all response headers.
* @method getHeaders
* @static
* @return {array}
*/
static function getHeaders()
{
return self::$headers;
}
/**
* Set the HTTP response status code.
* Compatible with Q_Response::code() from the Platform.
* @method code
* @static
* @param {integer} $code HTTP status code
* @param {string} $message Optional status message
*/
static function code($code, $message = null)
{
self::$statusCode = (int) $code;
if ($message !== null) {
self::$statusMessage = $message;
}
Q::$_responseCode = (int) $code;
http_response_code($code);
}
/**
* Get the current status code.
* @method getStatusCode
* @static
* @return {integer}
*/
static function getStatusCode()
{
return self::$statusCode;
}
/**
* Set a cookie. Compatible with Q_Response::setCookie() from the Platform.
* Prevents duplicate cookies — if the same name+value is already set
* and it's a session cookie, skips it.
* @method setCookie
* @static
* @param {string} $name
* @param {string} $value
* @param {integer} $expires Timestamp, 0 = session cookie
* @param {string} $path Cookie path (default: /)
* @param {string|null} $domain
* @param {boolean} $secure
* @param {boolean} $httponly
* @param {string|null} $samesite None, Lax, or Strict
* @return {string|false}
*/
static function setCookie(
$name, $value, $expires = 0,
$path = '/', $domain = null,
$secure = false, $httponly = false,
$samesite = null
) {
// Skip if already set with same value and is a session cookie
if (isset($_COOKIE[$name]) && $_COOKIE[$name] === $value && !$expires) {
return $value;
}
self::$cookies[$name] = array($value, $expires, $path, $domain, $secure, $httponly, $samesite);
unset(self::$cookiesToRemove[$name]);
return $value;
}
/**
* Get the value of a cookie that will be sent, falling back to $_COOKIE.
* @method cookie
* @static
* @param {string} $name
* @return {string|null}
*/
static function cookie($name)
{
return isset(self::$cookies[$name][0])
? self::$cookies[$name][0]
: ($_COOKIE[$name] ?? null);
}
/**
* Clear a cookie.
* @method clearCookie
* @static
* @param {string} $name
* @param {string} $path
*/
static function clearCookie($name, $path = '/')
{
self::$cookiesToRemove[$name] = array($path);
unset(self::$cookies[$name]);
}
/**
* Set redirect. Compatible with Q_Response::redirect() from the Platform.
* @method redirect
* @static
* @param {string} $url
* @param {array} $options
* @return {boolean}
*/
static function redirect($url, $options = array())
{
$permanently = !empty($options['permanently']);
self::code($permanently ? 301 : 302);
self::setHeader('Location', $url);
self::$redirected = $url;
return true;
}
/**
* Build Set-Cookie header strings from stored cookies.
* Called by the server when assembling the response.
* @method cookieHeaders
* @static
* @return {array} Array of Set-Cookie header strings
*/
static function cookieHeaders()
{
$headers = array();
// Remove cookies
foreach (self::$cookiesToRemove as $name => $args) {
$path = $args[0] ?? '/';
$headers[] = "$name=; Path=$path; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0";
}
// Set cookies
foreach (self::$cookies as $name => $args) {
list($value, $expires, $path, $domain, $secure, $httponly, $samesite) = $args;
$parts = array(urlencode($name) . '=' . urlencode($value));
if ($expires) {
$parts[] = 'Expires=' . gmdate('D, d M Y H:i:s T', $expires);
$parts[] = 'Max-Age=' . max(0, $expires - time());
}
$parts[] = 'Path=' . ($path ?: '/');
if ($domain) $parts[] = 'Domain=' . $domain;
if ($secure) $parts[] = 'Secure';
if ($httponly) $parts[] = 'HttpOnly';
if ($samesite) $parts[] = 'SameSite=' . $samesite;
$headers[] = implode('; ', $parts);
}
return $headers;
}
/**
* Clear all response state between requests (in-process mode).
* @method clear
* @static
*/
static function clear()
{
self::$headers = array();
self::$statusCode = 200;
self::$statusMessage = 'OK';
self::$cookies = array();
self::$cookiesToRemove = array();
self::$redirected = null;
}
}
// ── Q_Request ───────────────────────────────────────
/**
* Minimal Q_Request — compatible subset of the Qbix Platform's Q_Request.
* Provides convenient access to request data that the server has already parsed.
@@ -630,6 +938,42 @@ class Q_Request
return $_FILES[$name] ?? null;
}
/**
* Check if running in CLI mode (command line, cron, not via web server).
* In Qbix Server, scripts run in CLI SAPI but are dispatched as web
* requests. This method returns false for server-dispatched requests
* (because $_SERVER['REQUEST_METHOD'] is set) and true for genuine
* CLI invocations.
* Compatible with Q_Request::isInternal() from the Platform.
* @method isInternal
* @static
* @return {boolean}
*/
static function isInternal()
{
// If REQUEST_METHOD is set, we're handling a web request
// (even though php_sapi_name() === 'cli')
if (!empty($_SERVER['REQUEST_METHOD']) && !empty($_SERVER['REQUEST_URI'])) {
return false;
}
return (php_sapi_name() === 'cli'
|| defined('STDIN')
|| !isset($_SERVER['REQUEST_METHOD']));
}
/**
* Whether the server is running in CLI SAPI.
* Always true for Qbix Server (same as FrankenPHP worker mode, Workerman).
* Scripts should use isInternal() to check if they're handling a web request.
* @method isCli
* @static
* @return {boolean}
*/
static function isCli()
{
return php_sapi_name() === 'cli';
}
/**
* Get the Content-Type of the request.
* @method contentType
+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;
}
}