mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Added support for legacy scripts via CGI mode instead of CLI SAPI
This commit is contained in:
@@ -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).
|
||||
|
||||
Binary file not shown.
@@ -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())
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user