diff --git a/README.md b/README.md index 3a367f3..1efd8e8 100644 --- a/README.md +++ b/README.md @@ -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 [ '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) { $_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 $_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). diff --git a/qbixserver.phar b/qbixserver.phar index e934f00..2ac9869 100644 Binary files a/qbixserver.phar and b/qbixserver.phar differ diff --git a/qbixserver.php b/qbixserver.php index c325b22..a23e090 100644 --- a/qbixserver.php +++ b/qbixserver.php @@ -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()) diff --git a/src/Q.php b/src/Q.php index 6d6a2e8..1579010 100644 --- a/src/Q.php +++ b/src/Q.php @@ -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 diff --git a/src/Q/WebServer.php b/src/Q/WebServer.php index 9696369..8943d5c 100644 --- a/src/Q/WebServer.php +++ b/src/Q/WebServer.php @@ -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); diff --git a/src/Q/WebServer/Headers.php b/src/Q/WebServer/Headers.php index 167f7e9..6bec74f 100644 --- a/src/Q/WebServer/Headers.php +++ b/src/Q/WebServer/Headers.php @@ -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; } }