Files
webserver/qbixserver.phar

9197 lines
283 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env php
<?php
Phar::mapPhar('qbixserver.phar');
// Bootstrap
require 'phar://qbixserver.phar/Q.php';
// Parse args the same way as qbixserver.php
$opts = array(
'root' => null,
'app' => null,
'host' => '0.0.0.0',
'port' => 8080,
'workers' => 0,
'config' => null,
'pid' => null,
'debug' => false,
);
define('QBIX_SERVER_VERSION', '1.0.0');
foreach ($argv as $i => $arg) {
if ($i === 0) continue;
if ($arg === '--help' || $arg === '-h') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n\n";
echo "Usage: php qbixserver.phar [options]\n\n";
echo " --root=DIR Document root (default: ./web)\n";
echo " --app=DIR Qbix app directory\n";
echo " --host=IP Bind address (default: 0.0.0.0)\n";
echo " --port=PORT Listen port (default: 8080)\n";
echo " --workers=N Pre-fork workers (default: 0)\n";
echo " --config=FILE JSON config file\n";
echo " --version Print version\n";
exit(0);
}
if ($arg === '--version' || $arg === '-v') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n";
exit(0);
}
if ($arg === '--debug') { $opts['debug'] = true; continue; }
if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) $opts[$m[1]] = $m[2];
}
// Qbix app mode
if ($opts['app']) {
$appDir = realpath($opts['app']);
$qInc = $appDir . '/scripts/Q.inc.php';
if (!$qInc) $qInc = $appDir . '/../Platform/scripts/Q.inc.php';
if (file_exists($qInc)) {
define('APP_DIR', $appDir);
require_once $qInc;
}
$webDir = $appDir . '/web';
} else {
$webDir = $opts['root'] ?: (getcwd() . '/web');
}
$webDir = realpath($webDir);
if (!$webDir || !is_dir($webDir)) {
fwrite(STDERR, "Error: document root not found\n");
exit(1);
}
if ($opts['config']) Q_Config::load($opts['config']);
Q_WebServer::$onRequest = function ($method, $uri, $status, $ms) {
$c = array(2=>"\033[32m",3=>"\033[33m",4=>"\033[31m",5=>"\033[31m");
echo date('H:i:s').' '.($c[(int)($status/100)]??'').$status."\033[0m $method $uri ({$ms}ms)\n";
};
echo "\n Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n";
echo " http://{$opts['host']}:{$opts['port']} Root: " . basename($webDir) . "\n\n";
Q_WebServer::start($webDir, $opts['host'], (int)$opts['port'], (int)$opts['workers']);
Q_WebServer::run();
__HALT_COMPILER(); ?>
˜qbixserver.pharQ/FileCache.php× × T1פQ/WebServer/Pool.php‡/‡/£e‹Š¤Q/WebServer/Proxy.phpAA”YcM¤Q/WebServer/Panel.php§§/-»¤ Q/WebServer/Cache/Components.php77µÐQ/WebServer/Certs.php".".YF“ª¤Q/WebServer/Cache.php$$Ù½îP¤Q/WebServer/Dashboard.php99öº¤Q/WebServer/Log.phpK
K
ý«È¤Q/WebServer/Headers.phpÛ1Û1Q/WebSocket.phpèLèLûU‡Å¤ Q/Uri.phpÊrÁ^¤
Q/Evented.phpè(¬r¤Q/WebServer.phpSS…Z¤Q/Snapshot.phpÝÝ“£ëk¤Q/Evented/Driver.php''ûhšÇ¤Q/Evented/StreamSelect.phpÁ¦.¤Q/Evented/Revolt.phpQ.php¾s¾sõ0ÐÓ¤<?php
/**
* @module Q
*/
/**
* Lightweight mtime-based file cache for long-running PHP processes.
*
* In php-fpm every request re-reads files from disk. In a persistent
* server (Q_WebServer), files load once and stay in memory. This class
* tracks mtimes so changed files get reloaded — one stat() syscall
* per check, same cost as nginx checking a file.
*
* @class Q_FileCache
*/
class Q_FileCache
{
/**
* path => [mtime, content, type]
* @property $cache
* @static
* @protected
*/
protected static $cache = array();
/**
* Load file contents. Returns cached version if mtime unchanged.
* @method load
* @static
* @param {string} $path
* @return {string|false}
*/
static function load($path)
{
$mtime = self::mtime($path);
if ($mtime === false) {
unset(self::$cache[$path]);
return false;
}
if (isset(self::$cache[$path]) && self::$cache[$path]['mtime'] === $mtime) {
return self::$cache[$path]['content'];
}
$content = file_get_contents($path);
if ($content === false) return false;
self::$cache[$path] = array('mtime' => $mtime, 'content' => $content, 'type' => 'raw');
return $content;
}
/**
* Load and JSON-decode a file.
* @method loadJson
* @static
* @param {string} $path
* @return {array|null}
*/
static function loadJson($path)
{
$mtime = self::mtime($path);
if ($mtime === false) { unset(self::$cache[$path]); return null; }
if (isset(self::$cache[$path])
&& self::$cache[$path]['mtime'] === $mtime
&& self::$cache[$path]['type'] === 'json'
) {
return self::$cache[$path]['content'];
}
$raw = file_get_contents($path);
if ($raw === false) return null;
$data = json_decode($raw, true);
self::$cache[$path] = array('mtime' => $mtime, 'content' => $data, 'type' => 'json');
return $data;
}
/**
* Load a PHP file that returns a value.
* Re-includes if mtime changed.
* @method loadPhp
* @static
* @param {string} $path
* @return {mixed}
*/
static function loadPhp($path)
{
$mtime = self::mtime($path);
if ($mtime === false) { unset(self::$cache[$path]); return null; }
if (isset(self::$cache[$path])
&& self::$cache[$path]['mtime'] === $mtime
&& self::$cache[$path]['type'] === 'php'
) {
return self::$cache[$path]['content'];
}
$data = include($path);
self::$cache[$path] = array('mtime' => $mtime, 'content' => $data, 'type' => 'php');
return $data;
}
/**
* Check all cached files for changes. Returns changed paths.
* @method checkAll
* @static
* @return {array}
*/
static function checkAll()
{
$changed = array();
foreach (self::$cache as $path => $entry) {
$mtime = self::mtime($path);
if ($mtime === false || $mtime !== $entry['mtime']) {
$changed[] = $path;
if ($mtime === false) {
unset(self::$cache[$path]);
} else {
self::$cache[$path]['mtime'] = -1; // mark stale
}
}
}
return $changed;
}
/** @method invalidate */
static function invalidate($path) { unset(self::$cache[$path]); }
/** @method clear */
static function clear() { self::$cache = array(); }
/**
* @method mtime
* @static
* @protected
*/
protected static function mtime($path)
{
clearstatcache(true, $path);
return file_exists($path) ? filemtime($path) : false;
}
}
<?php
/**
* @module Q
*/
/**
* Pre-fork worker pool for PHP script execution.
*
* Each worker handles ONE request, then exits. The parent
* maintains N idle workers at all times. When one finishes,
* a replacement is forked immediately.
*
* Why one-request-per-process:
* PHP has no way to fully reset static state — Foo::$bar,
* DB connections, registered shutdown functions, output
* buffers all persist. The only clean reset is process exit.
*
* Why this is fast:
* fork() on Linux uses copy-on-write. The child inherits
* all loaded classes, opcache, config — everything the
* parent loaded during bootstrap — without copying memory.
* Cost: ~0.5ms per fork.
*
* Important: the parent must NOT open DB connections or
* stateful resources before forking. Q's DB connections are
* lazy (opened on first query), so this is natural.
*
* Parent (event loop, Q.inc.php loaded)
* ├── Worker 0 [idle, waiting on socketpair]
* ├── Worker 1 [busy, processing request] → exits → replacement forked
* ├── Worker 2 [idle]
* └── Worker 3 [idle]
*
* @class Q_WebServer_Pool
*/
class Q_WebServer_Pool
{
public $targetSize;
protected $workers = array(); // index => [pid, socket, busy]
protected $workerClients = array(); // index => HTTP client socket
protected $workerBuffers = array(); // index => partial response data
protected $watchers = array(); // index => Q_Evented watcher id
protected $pending = array(); // queued [client, parsed, scriptPath]
protected $nextIndex = 0;
/**
* @method __construct
* @param {integer} [$size=4]
*/
function __construct($size = null)
{
if (!function_exists('pcntl_fork')) {
throw new Exception(
"Q_WebServer_Pool requires pcntl extension. "
. "Use --workers=0 or Caddy/nginx + php-fpm."
);
}
$this->targetSize = $size ?: (int) Q_Config::get(
'Q', 'webserver', 'workers', 4
);
pcntl_signal(SIGCHLD, SIG_DFL);
for ($i = 0; $i < $this->targetSize; $i++) {
$this->forkWorker();
}
}
/**
* Fork one worker. Child inherits parent's loaded state
* via copy-on-write.
* @method forkWorker
* @return {integer} Worker index
*/
protected function forkWorker()
{
$pair = stream_socket_pair(
STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP
);
if (!$pair) throw new Exception("socketpair failed");
$pid = pcntl_fork();
if ($pid === -1) throw new Exception("fork failed");
if ($pid === 0) {
// ── CHILD: wait for one request, handle, exit ──
fclose($pair[0]);
self::childRun($pair[1]);
exit(0);
}
// ── PARENT ──
fclose($pair[1]);
$sock = $pair[0];
stream_set_blocking($sock, false);
$index = $this->nextIndex++;
$this->workers[$index] = array(
'pid' => $pid, 'socket' => $sock, 'busy' => false
);
$pool = $this;
$this->watchers[$index] = Q_Evented::onReadable(
$sock,
function ($s) use ($pool, $index) {
$pool->onWorkerData($index, $s);
}
);
Q_Evented::disable($this->watchers[$index]);
return $index;
}
// ── Child process ────────────────────────────────────
/**
* Child: block on socket, read one request, execute, respond, die.
*/
protected static function childRun($socket)
{
stream_set_blocking($socket, true);
// Read length-prefixed request
$hdr = self::readExact($socket, 4);
if ($hdr === false) return;
$len = unpack('N', $hdr)[1];
if ($len > 10485760) return;
$json = self::readExact($socket, $len);
if ($json === false) return;
$req = json_decode($json, true);
if (!$req) {
self::writeMsg($socket, 500, 'Bad message', array());
return;
}
// Execute the PHP script
$resp = self::executeScript($req);
self::writeMsg($socket, $resp['status'], $resp['body'], $resp['headers']);
fclose($socket);
}
/**
* Set up superglobals and include the PHP script.
* The script (index.php, action.php, etc.) internally calls
* Q_WebController::execute() or Q_ActionController::execute().
*/
protected static function executeScript($req)
{
$_SERVER['REQUEST_METHOD'] = $req['method'];
$_SERVER['REQUEST_URI'] = $req['uri'];
$_SERVER['QUERY_STRING'] = $req['query'] ?? '';
$_SERVER['SCRIPT_FILENAME'] = $req['scriptFilename'];
$_SERVER['SCRIPT_NAME'] = $req['scriptName'] ?? '/index.php';
$_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? '';
$_SERVER['SERVER_NAME'] = $req['headers']['host'] ?? 'localhost';
$_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080';
$_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1';
foreach ($req['headers'] as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
if (isset($req['headers']['content-type']))
$_SERVER['CONTENT_TYPE'] = $req['headers']['content-type'];
if (isset($req['headers']['content-length']))
$_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length'];
$_GET = $_POST = $_REQUEST = array();
if (!empty($req['query'])) parse_str($req['query'], $_GET);
$ct = strtolower($req['headers']['content-type'] ?? '');
$raw = $req['body'] ?? '';
if (strpos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($raw, $_POST);
} elseif (strpos($ct, 'application/json') !== false) {
$_POST = json_decode($raw, true) ?: array();
}
$_REQUEST = array_merge($_GET, $_POST);
// php://input workaround for forked processes
$GLOBALS['_Q_RAW_INPUT'] = $raw;
ob_start();
$status = 200;
$headers = array();
try {
include($req['scriptFilename']);
foreach (headers_list() as $h) {
if (strpos($h, ':') !== false) {
list($k, $v) = explode(':', $h, 2);
$headers[trim($k)] = trim($v);
}
}
$code = http_response_code();
if ($code) $status = $code;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
echo $e->getMessage();
}
$body = ob_get_clean();
return compact('status', 'body', 'headers');
}
// ── Parent-side dispatch ─────────────────────────────
/**
* Send a request to an idle worker. Queues if all busy.
*/
function dispatch($client, $parsed, $scriptPath)
{
$idle = $this->findIdle();
if ($idle === null) {
$this->pending[] = array($client, $parsed, $scriptPath);
return;
}
$this->sendTo($idle, $client, $parsed, $scriptPath);
}
protected function sendTo($index, $client, $parsed, $scriptPath)
{
$this->workers[$index]['busy'] = true;
$this->workerClients[$index] = $client;
$this->workerBuffers[$index] = '';
$this->workerRequestHeaders[$index] = $parsed['headers'];
Q_Evented::enable($this->watchers[$index]);
$msg = json_encode(array(
'method' => $parsed['method'],
'uri' => $parsed['uri'],
'path' => $parsed['path'],
'query' => $parsed['query'],
'headers' => $parsed['headers'],
'body' => $parsed['body'],
'scriptFilename' => $scriptPath,
'scriptName' => '/' . basename($scriptPath),
'documentRoot' => Q_WebServer::$rootDir ?? '',
'serverPort' => (string)($_SERVER['SERVER_PORT'] ?? '8080'),
'remoteAddr' => '127.0.0.1'
));
$written = @fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg);
if ($written === false || $written === 0) {
// Worker died before receiving the request — recycle and re-queue
$this->pending[] = array($client, $parsed, $scriptPath);
$this->recycle($index, true);
}
}
/**
* Called when data or EOF arrives from a worker.
*/
function onWorkerData($index, $sock)
{
$chunk = @fread($sock, 65536);
if ($chunk === false || $chunk === '') {
// Worker exited (expected after one request)
$this->recycle($index, true);
return;
}
$this->workerBuffers[$index] .= $chunk;
$buf = $this->workerBuffers[$index];
if (strlen($buf) < 4) return;
$len = unpack('N', substr($buf, 0, 4))[1];
if (strlen($buf) < 4 + $len) return;
// Got complete response
$json = substr($buf, 4, $len);
$response = json_decode($json, true);
// Check for cache messages piggybacked on the response
if ($response && !empty($response['_cacheMessages'])) {
foreach ($response['_cacheMessages'] as $msg) {
Q_WebServer_Cache_Components::processChildMessage($msg);
}
unset($response['_cacheMessages']);
}
$client = $this->workerClients[$index] ?? null;
if ($response && $client && is_resource($client)) {
$this->sendHttp($client, $response, $index);
}
$this->recycle($index, false);
}
/**
* Clean up a finished worker: close socket, reap pid,
* fork replacement, process pending queue.
*/
protected function recycle($index, $isEof)
{
if (isset($this->watchers[$index])) {
Q_Evented::cancel($this->watchers[$index]);
unset($this->watchers[$index]);
}
// EOF with no response → 502
if ($isEof && isset($this->workerClients[$index])
&& empty($this->workerBuffers[$index])
) {
$c = $this->workerClients[$index];
if (is_resource($c)) {
Q_WebServer::sendResponse($c, 502, 'Worker died');
@fclose($c);
}
} elseif (isset($this->workerClients[$index])) {
@fclose($this->workerClients[$index]);
}
if (isset($this->workers[$index])) {
@fclose($this->workers[$index]['socket']);
pcntl_waitpid($this->workers[$index]['pid'], $st, WNOHANG);
}
unset($this->workers[$index], $this->workerClients[$index],
$this->workerBuffers[$index], $this->workerRequestHeaders[$index]);
// Immediately fork replacement
$newIdx = $this->forkWorker();
// Drain pending queue
if (!empty($this->pending)) {
$next = array_shift($this->pending);
$this->sendTo($newIdx, $next[0], $next[1], $next[2]);
}
}
/**
* We need the original request headers for compression
* negotiation. Store them alongside the client.
* @property $workerRequestHeaders
*/
protected $workerRequestHeaders = array();
protected function sendHttp($client, $resp, $index)
{
$reqHeaders = $this->workerRequestHeaders[$index] ?? array();
Q_WebServer_Headers::processResponse($client, $resp, $reqHeaders);
}
protected function findIdle()
{
foreach ($this->workers as $i => $w) {
if (!$w['busy']) return $i;
}
return null;
}
/**
* Graceful shutdown: SIGTERM all workers, wait up to $timeout seconds,
* then SIGKILL any remaining.
* @param {float} $timeout Seconds to wait after SIGTERM before SIGKILL
*/
function shutdown($timeout = 3.0)
{
// Cancel watchers and close sockets
foreach ($this->workers as $i => $w) {
if (isset($this->watchers[$i])) {
Q_Evented::cancel($this->watchers[$i]);
}
@fclose($w['socket']);
}
// Send SIGTERM to all workers
foreach ($this->workers as $w) {
if (function_exists("posix_kill")) posix_kill($w["pid"], SIGTERM);
}
// Wait for workers to exit gracefully
$deadline = microtime(true) + $timeout;
$remaining = $this->workers;
while (!empty($remaining) && microtime(true) < $deadline) {
foreach ($remaining as $i => $w) {
$result = pcntl_waitpid($w['pid'], $st, WNOHANG);
if ($result > 0 || $result === -1) {
unset($remaining[$i]);
}
}
if (!empty($remaining)) {
usleep(50000); // 50ms
}
}
// SIGKILL any workers that didn't exit in time
foreach ($remaining as $w) {
if (function_exists("posix_kill")) posix_kill($w["pid"], SIGKILL);
pcntl_waitpid($w['pid'], $st, 0);
}
$this->workers = array();
}
function idleCount()
{
$n = 0;
foreach ($this->workers as $w) if (!$w['busy']) $n++;
return $n;
}
// ── Wire helpers ─────────────────────────────────────
protected static function readExact($sock, $n)
{
$buf = '';
while (strlen($buf) < $n) {
$c = fread($sock, $n - strlen($buf));
if ($c === false || $c === '') return false;
$buf .= $c;
}
return $buf;
}
protected static function writeMsg($sock, $status, $body, $headers)
{
$j = json_encode(compact('status', 'body', 'headers'));
fwrite($sock, pack('N', strlen($j)) . $j);
}
}
<?php
/**
* @module Q
*/
/**
* Reverse proxy header handling for Q_WebServer.
*
* When behind Cloudflare, AWS ALB, Caddy, nginx, etc.,
* the client's real IP and protocol are in X-Forwarded-*
* headers. This class extracts them from trusted proxies.
*
* Config:
* "Q": { "webserver": { "proxy": {
* "trusted": ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12",
* "192.168.0.0/16", "173.245.48.0/20", "103.21.244.0/22"],
* "headers": {
* "ip": "X-Forwarded-For",
* "proto": "X-Forwarded-Proto",
* "host": "X-Forwarded-Host"
* }
* }}}
*
* Cloudflare IPs are in the default trusted list. Add your
* own load balancer IPs as needed.
*
* @class Q_WebServer_Proxy
*/
class Q_WebServer_Proxy
{
static $trusted = null;
/**
* Extract the real client IP from proxy headers.
* Only trusts headers from configured proxy IPs.
*
* @method clientIp
* @static
* @param {string} $directIp The socket-level remote IP
* @param {array} $headers Request headers (lowercase keys)
* @return {string} Real client IP
*/
static function clientIp($directIp, $headers)
{
if (!self::isTrusted($directIp)) return $directIp;
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'ip',
'x-forwarded-for'
));
$forwarded = $headers[$headerName] ?? '';
if (!$forwarded) return $directIp;
// X-Forwarded-For: client, proxy1, proxy2
// Rightmost untrusted IP is the real client
$ips = array_map('trim', explode(',', $forwarded));
for ($i = count($ips) - 1; $i >= 0; $i--) {
if (!self::isTrusted($ips[$i])) {
return $ips[$i];
}
}
return $ips[0]; // all trusted, use leftmost
}
/**
* Extract the real protocol (http/https).
*
* @method clientProto
* @static
* @param {string} $directIp
* @param {array} $headers
* @param {boolean} $isTls Whether connection is TLS
* @return {string} 'http' or 'https'
*/
static function clientProto($directIp, $headers, $isTls = false)
{
if ($isTls) return 'https';
if (!self::isTrusted($directIp)) return 'http';
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'proto',
'x-forwarded-proto'
));
$proto = $headers[$headerName] ?? '';
return strtolower($proto) === 'https' ? 'https' : 'http';
}
/**
* Extract the real host.
*
* @method clientHost
* @static
* @param {string} $directIp
* @param {array} $headers
* @return {string}
*/
static function clientHost($directIp, $headers)
{
if (self::isTrusted($directIp)) {
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'host',
'x-forwarded-host'
));
$host = $headers[$headerName] ?? '';
if ($host) return $host;
}
return $headers['host'] ?? 'localhost';
}
/**
* Check if an IP is a trusted proxy.
*
* @method isTrusted
* @static
* @param {string} $ip
* @return {boolean}
*/
static function isTrusted($ip)
{
if (self::$trusted === null) {
self::$trusted = Q_Config::get(
'Q', 'webserver', 'proxy', 'trusted',
array('127.0.0.1', '::1')
);
}
foreach (self::$trusted as $range) {
if (strpos($range, '/') !== false) {
if (self::ipInCidr($ip, $range)) return true;
} else {
if ($ip === $range) return true;
}
}
return false;
}
/**
* Check if IP is within a CIDR range.
*/
static function ipInCidr($ip, $cidr)
{
list($subnet, $bits) = explode('/', $cidr);
$ip = ip2long($ip);
$subnet = ip2long($subnet);
if ($ip === false || $subnet === false) return false;
$mask = -1 << (32 - (int) $bits);
return ($ip & $mask) === ($subnet & $mask);
}
}
<?php
/**
* @module Q
*/
/**
* Web-based control panel for managing Qbix apps.
*
* Serves at /Q/panel. Provides:
* - List/create/start/stop apps
* - Run scripts (configure, install, urls, etc.) via web
* - Open app folders in Finder/Explorer/VS Code
* - Plugin management
* - System info
*
* No CLI needed. Everything a normie needs to manage
* their server from a browser.
*
* @class Q_WebServer_Panel
*/
class Q_WebServer_Panel
{
/**
* Handle panel requests with authentication.
* First visitor sets a password. All subsequent requests require it.
* Password stored in APP_DIR/local/panel.json (gitignored).
* @method handle
* @static
* @param {resource} $client
* @param {array} $parsed
* @return {boolean} true if handled
*/
static function handle($client, $parsed)
{
$path = $parsed['path'];
if ($path === '/Q/panel' || $path === '/Q/panel/') {
Q_WebServer::sendResponse($client, 200,
self::renderPanel($parsed), 'text/html; charset=utf-8');
return true;
}
// API endpoints — require authentication
if (strpos($path, '/Q/api/') === 0) {
// Password setup endpoint — no auth needed
$route = substr($path, 7);
if ($route === 'auth/setup' || $route === 'auth/login') {
$result = self::handleAuthApi($route, $parsed);
Q_WebServer::sendResponse($client, $result['status'] ?? 200,
json_encode($result), 'application/json');
return true;
}
// All other API calls require a valid session token
$authResult = self::checkAuth($parsed);
if (!$authResult['ok']) {
Q_WebServer::sendResponse($client, 401,
json_encode($authResult), 'application/json');
return true;
}
$result = self::handleApi($path, $parsed);
Q_WebServer::sendResponse($client, $result['status'] ?? 200,
json_encode($result), 'application/json');
return true;
}
return false;
}
/**
* Get the panel config file path
*/
private static function panelConfigPath()
{
return defined('APP_DIR')
? APP_DIR . '/local/panel.json'
: sys_get_temp_dir() . '/qbix-panel.json';
}
/**
* Handle auth API endpoints
*/
private static function handleAuthApi($route, $parsed)
{
$configPath = self::panelConfigPath();
$config = file_exists($configPath)
? json_decode(file_get_contents($configPath), true)
: array();
$body = !empty($parsed['body'])
? json_decode($parsed['body'], true)
: array();
if ($route === 'auth/setup') {
// First-time setup: set password
if (!empty($config['passwordHash'])) {
return array('error' => 'Password already set. Use auth/login.',
'needsSetup' => false);
}
$password = $body['password'] ?? '';
if (strlen($password) < 6) {
return array('error' => 'Password must be at least 6 characters');
}
$config['passwordHash'] = password_hash($password, PASSWORD_DEFAULT);
$token = bin2hex(random_bytes(32));
$config['sessions'][$token] = time() + 86400 * 7; // 7 day expiry
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT));
@chmod($configPath, 0600);
return array('ok' => true, 'token' => $token);
}
if ($route === 'auth/login') {
if (empty($config['passwordHash'])) {
return array('needsSetup' => true);
}
$password = $body['password'] ?? '';
if (!password_verify($password, $config['passwordHash'])) {
usleep(500000); // 500ms delay to slow brute force
return array('error' => 'Wrong password', 'status' => 401);
}
// Issue session token
$token = bin2hex(random_bytes(32));
if (!isset($config['sessions'])) $config['sessions'] = array();
// Clean expired sessions
$now = time();
foreach ($config['sessions'] as $t => $exp) {
if ($exp < $now) unset($config['sessions'][$t]);
}
$config['sessions'][$token] = $now + 86400 * 7;
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT));
return array('ok' => true, 'token' => $token);
}
return array('error' => 'Unknown auth endpoint');
}
/**
* Check if the request has a valid auth token
*/
private static function checkAuth($parsed)
{
$configPath = self::panelConfigPath();
if (!file_exists($configPath)) {
return array('ok' => false, 'needsSetup' => true,
'error' => 'No password set. Call auth/setup first.');
}
$config = json_decode(file_get_contents($configPath), true);
if (empty($config['passwordHash'])) {
return array('ok' => false, 'needsSetup' => true,
'error' => 'No password set. Call auth/setup first.');
}
// Check Authorization: Bearer <token> header
$authHeader = $parsed['headers']['authorization'] ?? '';
$token = '';
if (strpos($authHeader, 'Bearer ') === 0) {
$token = substr($authHeader, 7);
}
// Also check X-Panel-Token header
if (empty($token)) {
$token = $parsed['headers']['x-panel-token'] ?? '';
}
// Also check cookie
if (empty($token)) {
$token = $parsed['cookies']['Q_panel_token'] ?? '';
}
if (empty($token)) {
return array('ok' => false, 'error' => 'No auth token provided');
}
$sessions = $config['sessions'] ?? array();
$expiry = $sessions[$token] ?? 0;
if ($expiry < time()) {
return array('ok' => false, 'error' => 'Token expired or invalid');
}
return array('ok' => true);
}
static function handleApi($path, $parsed)
{
$route = substr($path, 7); // strip /Q/api/
switch ($route) {
case 'apps':
return self::apiListApps();
case 'apps/create':
return self::apiCreateApp($parsed);
case 'apps/configure':
return self::apiRunScript($parsed, 'configure');
case 'apps/install':
return self::apiRunScript($parsed, 'install');
case 'apps/open':
return self::apiOpenFolder($parsed);
case 'scripts':
return self::apiListScripts($parsed);
case 'scripts/run':
return self::apiRunScript($parsed);
case 'plugins':
return self::apiListPlugins();
case 'system':
return self::apiSystemInfo();
case 'auth/password':
return self::apiChangePassword($parsed);
case 'auth/logout':
return self::apiLogout($parsed);
default:
return array('status' => 404, 'error' => 'Unknown endpoint');
}
}
private static function apiChangePassword($parsed)
{
$body = !empty($parsed['body'])
? json_decode($parsed['body'], true) : array();
$configPath = self::panelConfigPath();
$config = json_decode(file_get_contents($configPath), true);
$oldPw = $body['oldPassword'] ?? '';
$newPw = $body['newPassword'] ?? '';
if (!password_verify($oldPw, $config['passwordHash'])) {
return array('error' => 'Current password is wrong');
}
if (strlen($newPw) < 6) {
return array('error' => 'New password must be at least 6 characters');
}
$config['passwordHash'] = password_hash($newPw, PASSWORD_DEFAULT);
// Invalidate all other sessions
$currentToken = $parsed['headers']['x-panel-token']
?? $parsed['cookies']['Q_panel_token'] ?? '';
$config['sessions'] = array();
if ($currentToken) {
$config['sessions'][$currentToken] = time() + 86400 * 7;
}
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT));
return array('ok' => true);
}
private static function apiLogout($parsed)
{
$configPath = self::panelConfigPath();
$config = json_decode(file_get_contents($configPath), true);
$token = $parsed['headers']['x-panel-token']
?? $parsed['cookies']['Q_panel_token'] ?? '';
if ($token && isset($config['sessions'][$token])) {
unset($config['sessions'][$token]);
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT));
}
return array('ok' => true);
}
// ── Apps API ─────────────────────────────────────────
static function apiListApps()
{
$appsDir = self::appsDir();
$apps = array();
if (!$appsDir || !is_dir($appsDir)) {
return array('apps' => $apps, 'appsDir' => $appsDir);
}
foreach (scandir($appsDir) as $name) {
if ($name[0] === '.' || !is_dir($appsDir . DS . $name)) continue;
$appDir = $appsDir . DS . $name;
$configFile = $appDir . DS . 'config' . DS . 'app.json';
if (!file_exists($configFile)) continue;
$config = json_decode(file_get_contents($configFile), true);
$localConfig = null;
$localFile = $appDir . DS . 'local' . DS . 'app.json';
if (file_exists($localFile)) {
$localConfig = json_decode(file_get_contents($localFile), true);
}
$appName = $config['Q']['app'] ?? $name;
$plugins = $config['Q']['plugins'] ?? array();
$configured = is_dir($appDir . DS . 'local');
$url = $localConfig['Q']['web']['appRootUrl'] ?? '';
$apps[] = array(
'name' => $appName,
'dir' => $appDir,
'dirName' => $name,
'plugins' => $plugins,
'configured' => $configured,
'url' => $url,
'hasWeb' => is_dir($appDir . DS . 'web'),
);
}
return array('apps' => $apps, 'appsDir' => $appsDir);
}
static function apiCreateApp($parsed)
{
$body = json_decode($parsed['body'], true);
$name = preg_replace('/[^A-Za-z0-9_]/', '', $body['name'] ?? '');
if (!$name) return array('status' => 400, 'error' => 'App name required');
$template = $body['template'] ?? 'MyApp';
$appsDir = self::appsDir();
$targetDir = $appsDir . DS . $name;
if (file_exists($targetDir)) {
return array('status' => 409, 'error' => "App '$name' already exists");
}
// Find template
$templateDir = null;
$candidates = array(
$appsDir . DS . $template,
dirname($appsDir) . DS . $template,
Q_DIR . DS . '..' . DS . $template,
);
foreach ($candidates as $c) {
if (is_dir($c) && file_exists($c . DS . 'config' . DS . 'app.json')) {
$templateDir = realpath($c);
break;
}
}
if (!$templateDir) {
return array('status' => 404, 'error' => "Template '$template' not found");
}
// Copy template
self::copyDir($templateDir, $targetDir);
// Rename references
$oldName = basename($templateDir);
self::renameInApp($targetDir, $oldName, $name);
return array('created' => $name, 'dir' => $targetDir);
}
// ── Scripts API ──────────────────────────────────────
static function apiListScripts($parsed)
{
$body = json_decode($parsed['body'] ?? '{}', true);
$appName = $body['app'] ?? '';
$scripts = array();
// Platform scripts
$platformScripts = Q_DIR . DS . 'scripts';
if (is_dir($platformScripts)) {
foreach (glob($platformScripts . DS . '*.php') as $f) {
$scripts[] = array(
'name' => basename($f, '.php'),
'path' => $f,
'scope' => 'platform'
);
}
}
// App scripts
if ($appName) {
$appDir = self::appsDir() . DS . $appName;
$appScripts = $appDir . DS . 'scripts' . DS . 'Q';
if (is_dir($appScripts)) {
foreach (glob($appScripts . DS . '*.php') as $f) {
$scripts[] = array(
'name' => basename($f, '.php'),
'path' => $f,
'scope' => 'app'
);
}
}
}
return array('scripts' => $scripts);
}
static function apiRunScript($parsed, $scriptName = null)
{
$body = json_decode($parsed['body'] ?? '{}', true);
$appName = $body['app'] ?? '';
$scriptName = $scriptName ?: ($body['script'] ?? '');
$args = $body['args'] ?? array();
if (!$appName || !$scriptName) {
return array('status' => 400, 'error' => 'app and script required');
}
$appDir = self::appsDir() . DS . $appName;
if (!is_dir($appDir)) {
return array('status' => 404, 'error' => "App '$appName' not found");
}
$scriptPath = $appDir . DS . 'scripts' . DS . 'Q' . DS . $scriptName . '.php';
if (!file_exists($scriptPath)) {
return array('status' => 404, 'error' => "Script '$scriptName' not found");
}
// Run script as subprocess
$argStr = '';
foreach ($args as $k => $v) {
if (is_numeric($k)) {
$argStr .= ' ' . escapeshellarg($v);
} else {
$argStr .= ' --' . $k . '=' . escapeshellarg($v);
}
}
$cmd = PHP_BINARY . ' ' . escapeshellarg($scriptPath) . $argStr . ' 2>&1';
$output = array();
$code = 0;
exec($cmd, $output, $code);
return array(
'script' => $scriptName,
'app' => $appName,
'exitCode' => $code,
'output' => implode("\n", $output)
);
}
// ── Plugins API ──────────────────────────────────────
static function apiListPlugins()
{
$plugins = array();
$pluginsDir = Q_DIR . DS . 'plugins';
if (!is_dir($pluginsDir)) {
$pluginsDir = Q_DIR . DS . '..' . DS . 'plugins';
}
if (is_dir($pluginsDir)) {
foreach (scandir($pluginsDir) as $name) {
if ($name[0] === '.') continue;
$pDir = $pluginsDir . DS . $name;
if (!is_dir($pDir)) continue;
$configFile = $pDir . DS . 'config' . DS . 'plugin.json';
$config = file_exists($configFile)
? json_decode(file_get_contents($configFile), true) : array();
$plugins[] = array(
'name' => $name,
'dir' => $pDir,
'hasConfig' => file_exists($configFile),
);
}
}
return array('plugins' => $plugins, 'pluginsDir' => $pluginsDir);
}
// ── System API ───────────────────────────────────────
static function apiSystemInfo()
{
return array(
'php' => PHP_VERSION,
'os' => PHP_OS,
'arch' => php_uname('m'),
'extensions' => get_loaded_extensions(),
'hasComposer' => self::which('composer') !== null,
'hasNode' => self::which('node') !== null,
'hasNpm' => self::which('npm') !== null,
'hasPcntl' => function_exists('pcntl_fork'),
'hasApcu' => function_exists('apcu_fetch'),
'memoryLimit' => ini_get('memory_limit'),
'platform' => defined('Q_DIR') ? Q_DIR : null,
);
}
static function apiOpenFolder($parsed)
{
$body = json_decode($parsed['body'] ?? '{}', true);
$dir = $body['dir'] ?? '';
$editor = $body['editor'] ?? 'folder'; // folder, vscode, textmate
if (!$dir || !is_dir($dir)) {
return array('status' => 400, 'error' => 'Invalid directory');
}
$os = PHP_OS_FAMILY;
switch ($editor) {
case 'vscode':
$cmd = 'code ' . escapeshellarg($dir);
break;
case 'textmate':
$cmd = 'mate ' . escapeshellarg($dir);
break;
default: // open in file manager
if ($os === 'Darwin') {
$cmd = 'open ' . escapeshellarg($dir);
} elseif ($os === 'Windows') {
$cmd = 'explorer ' . escapeshellarg(str_replace('/', '\\', $dir));
} else {
$cmd = 'xdg-open ' . escapeshellarg($dir);
}
}
exec($cmd . ' 2>&1 &');
return array('opened' => $dir, 'editor' => $editor);
}
// ── Helpers ──────────────────────────────────────────
static function appsDir()
{
// Apps directory: configurable, defaults to sibling of platform
$dir = Q_Config::get('Q', 'webserver', 'panel', 'appsDir', null);
if ($dir) return $dir;
if (defined('APP_DIR')) return dirname(APP_DIR);
if (defined('Q_DIR')) return dirname(Q_DIR);
return null;
}
static function which($cmd)
{
$path = trim(shell_exec((PHP_OS_FAMILY === 'Windows' ? 'where' : 'which')
. ' ' . escapeshellarg($cmd) . ' 2>/dev/null') ?? '');
return $path ?: null;
}
static function copyDir($src, $dst)
{
$dir = opendir($src);
@mkdir($dst, 0755, true);
while (($file = readdir($dir)) !== false) {
if ($file === '.' || $file === '..') continue;
$srcPath = $src . DS . $file;
$dstPath = $dst . DS . $file;
if (is_dir($srcPath)) {
self::copyDir($srcPath, $dstPath);
} else {
copy($srcPath, $dstPath);
}
}
closedir($dir);
}
static function renameInApp($dir, $oldName, $newName)
{
// Rename in config/app.json
$configFile = $dir . DS . 'config' . DS . 'app.json';
if (file_exists($configFile)) {
$content = file_get_contents($configFile);
$content = str_replace($oldName, $newName, $content);
file_put_contents($configFile, $content);
}
// Rename handler/class directories
foreach (array('handlers', 'classes', 'views', 'text') as $sub) {
$oldDir = $dir . DS . $sub . DS . $oldName;
$newDir = $dir . DS . $sub . DS . $newName;
if (is_dir($oldDir)) {
rename($oldDir, $newDir);
}
}
// Rename script directories
$oldScripts = $dir . DS . 'scripts' . DS . $oldName;
$newScripts = $dir . DS . 'scripts' . DS . $newName;
if (is_dir($oldScripts)) {
rename($oldScripts, $newScripts);
}
}
// ── Panel HTML ───────────────────────────────────────
static function renderPanel($parsed)
{
$host = $parsed['headers']['host'] ?? 'localhost:8080';
$wsUrl = "ws://$host/Q/ws";
// The panel HTML is too large for inline — load from file
// or generate. For now, inline a functional SPA.
return self::panelHtml($host, $wsUrl);
}
static function panelHtml($host, $wsUrl)
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Qbix Control Panel</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0b14;--sfc:rgba(22,24,40,.7);--sfc-solid:#161828;--bdr:rgba(255,255,255,.06);
--txt:#e1e4ed;--dim:#6b7089;--ac:#7c5cfc;--ac2:#a78bfa;--grn:#4ade80;--yel:#fbbf24;
--red:#f87171;--cyn:#22d3ee;--glow:rgba(124,92,252,.08)}
body{font-family:-apple-system,system-ui,'Segoe UI',sans-serif;
background:var(--bg);color:var(--txt);font-size:14px;min-height:100vh;
background-image:
radial-gradient(ellipse 80% 60% at 20% 0%, rgba(124,92,252,.12) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 100%, rgba(34,211,238,.06) 0%, transparent 50%);
background-attachment:fixed}
/* ── Header ── */
.top{padding:16px 20px;display:flex;justify-content:space-between;align-items:center;
background:rgba(10,11,20,.8);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
border-bottom:1px solid var(--bdr);position:sticky;top:0;z-index:50}
.top h1{font-size:17px;color:#fff;font-weight:700;letter-spacing:-.3px}
.top h1 span{color:var(--ac);font-weight:800}
.status{display:flex;gap:6px;align-items:center;font-size:12px;font-weight:500;
padding:4px 12px;border-radius:20px;background:rgba(34,197,94,.1);color:var(--grn)}
.status .pulse{width:6px;height:6px;border-radius:50%;background:var(--grn);
animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Tabs ── */
.tabs{display:flex;gap:0;padding:0 20px;background:rgba(22,24,40,.6);
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
border-bottom:1px solid var(--bdr);position:sticky;top:53px;z-index:40;
overflow-x:auto;-webkit-overflow-scrolling:touch}
.tab{padding:13px 18px;cursor:pointer;font-size:13px;font-weight:600;color:var(--dim);
border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s;
-webkit-tap-highlight-color:transparent}
.tab:hover{color:var(--txt)}.tab.active{color:var(--ac);border-bottom-color:var(--ac)}
/* ── Content ── */
.content{padding:20px;max-width:960px;margin:0 auto}
/* ── Cards (glass) ── */
.card{background:var(--sfc);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
border:1px solid var(--bdr);border-radius:12px;padding:18px;margin-bottom:14px;
box-shadow:0 2px 12px rgba(0,0,0,.2)}
.card h3{font-size:14px;font-weight:700;margin-bottom:10px;color:var(--txt)}
/* ── App rows ── */
.app-row{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:10px;
margin-bottom:6px;background:rgba(255,255,255,.02);border:1px solid transparent;
transition:all .15s}
.app-row:hover{background:rgba(255,255,255,.04);border-color:var(--bdr)}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.on{background:var(--grn);box-shadow:0 0 8px rgba(74,222,128,.4)}
.dot.off{background:var(--dim)}
.app-name{font-weight:700;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-url{color:var(--dim);font-size:12px;font-family:'SF Mono',monospace;
flex-shrink:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}
/* ── Buttons ── */
.btn{padding:7px 16px;border-radius:8px;font-size:12px;font-weight:600;border:none;
cursor:pointer;transition:all .15s;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
.btn-sm{padding:5px 12px;font-size:11px;border-radius:6px}
.btn-primary{background:linear-gradient(135deg,var(--ac),var(--ac2));color:#fff;
box-shadow:0 2px 8px rgba(124,92,252,.3)}
.btn-primary:hover{box-shadow:0 4px 16px rgba(124,92,252,.4);transform:translateY(-1px)}
.btn-primary:active{transform:translateY(0)}
.btn-ghost{background:rgba(255,255,255,.05);color:var(--txt);border:1px solid var(--bdr)}
.btn-ghost:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.1)}
.btn-grn{background:rgba(34,197,94,.12);color:var(--grn);border:1px solid rgba(34,197,94,.15)}
.btn-grn:hover{background:rgba(34,197,94,.2)}
.btn-row{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap}
.btn.disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
/* ── Dialog (glass modal) ── */
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
display:flex;align-items:center;justify-content:center;z-index:100;padding:20px}
.dialog{background:var(--sfc-solid);border:1px solid var(--bdr);border-radius:16px;
padding:28px;max-width:420px;width:100%;box-shadow:0 24px 48px rgba(0,0,0,.4)}
.dialog h3{font-size:17px;margin-bottom:10px;color:#fff}
.dialog p{font-size:14px;color:var(--dim);margin-bottom:20px;line-height:1.6}
.dialog .btn-row{justify-content:flex-end}
/* ── Forms ── */
input,select{background:rgba(255,255,255,.04);border:1px solid var(--bdr);color:var(--txt);
padding:10px 14px;border-radius:8px;font-size:13px;width:100%;transition:border .15s;
-webkit-appearance:none}
input:focus,select:focus{outline:none;border-color:var(--ac);box-shadow:0 0 0 3px var(--glow)}
.form-row{display:flex;gap:12px;margin-bottom:14px;align-items:center}
.form-row label{min-width:70px;font-size:12px;color:var(--dim);font-weight:600;letter-spacing:.3px}
/* ── Output console ── */
.output{background:rgba(0,0,0,.3);border:1px solid var(--bdr);border-radius:10px;padding:14px;
font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.6;
white-space:pre-wrap;max-height:300px;overflow-y:auto;color:var(--grn);margin-top:14px;
-webkit-overflow-scrolling:touch}
/* ── Grid ── */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.stat-val{font-size:20px;font-weight:700;margin-bottom:2px;letter-spacing:-.3px}
.stat-lbl{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px}
.hidden{display:none}
/* ── Suggestion cards ── */
.suggest{display:flex;gap:10px;align-items:center;padding:12px 16px;border-radius:10px;
margin-bottom:8px;cursor:pointer;transition:all .15s;-webkit-tap-highlight-color:transparent}
.suggest:hover{transform:translateY(-1px)}
.suggest-icon{font-size:22px;flex-shrink:0;width:36px;height:36px;border-radius:8px;
display:flex;align-items:center;justify-content:center}
.suggest-body{flex:1;min-width:0}
.suggest-title{font-size:13px;font-weight:700;margin-bottom:2px}
.suggest-desc{font-size:12px;line-height:1.4}
.suggest-action{flex-shrink:0;font-size:11px;font-weight:700;padding:5px 12px;border-radius:6px}
.suggest-hotspot{background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.12)}
.suggest-hotspot .suggest-icon{background:rgba(34,197,94,.12)}
.suggest-hotspot .suggest-title{color:var(--grn)}
.suggest-hotspot .suggest-desc{color:rgba(34,197,94,.6)}
.suggest-hotspot .suggest-action{background:rgba(34,197,94,.15);color:var(--grn)}
.suggest-app{background:rgba(124,92,252,.06);border:1px solid rgba(124,92,252,.1)}
.suggest-app .suggest-icon{background:rgba(124,92,252,.12)}
.suggest-app .suggest-title{color:var(--ac2)}
.suggest-app .suggest-desc{color:rgba(167,139,250,.5)}
.suggest-app .suggest-action{background:rgba(124,92,252,.15);color:var(--ac2)}
.suggest-warn{background:rgba(245,158,11,.06);border:1px solid rgba(245,158,11,.1)}
.suggest-warn .suggest-icon{background:rgba(245,158,11,.12)}
.suggest-warn .suggest-title{color:var(--yel)}
.suggest-warn .suggest-desc{color:rgba(245,158,11,.5)}
.suggest-warn .suggest-action{background:rgba(245,158,11,.15);color:var(--yel)}
/* ── Responsive ── */
@media(max-width:768px){
.top{padding:14px 16px}
.top h1{font-size:15px}
.tabs{padding:0 12px;gap:0}
.tab{padding:12px 14px;font-size:12px}
.content{padding:16px}
.card{padding:14px;border-radius:10px}
.app-row{flex-wrap:wrap;gap:8px;padding:12px}
.app-name{width:100%;flex:none}
.app-url{width:100%;flex:none;max-width:none;margin-top:-4px}
.btn-row{width:100%;justify-content:flex-start;margin-top:4px}
.form-row{flex-direction:column;gap:6px}
.form-row label{min-width:0}
.grid-2{grid-template-columns:1fr}
.dialog{padding:20px;border-radius:12px}
}
@media(max-width:380px){
.top h1{font-size:14px}
.tab{padding:10px 10px;font-size:11px}
.btn{padding:6px 12px;font-size:11px}
.stat-val{font-size:17px}
}
/* safe area for notched phones */
@supports(padding-top: env(safe-area-inset-top)){
.top{padding-top:calc(16px + env(safe-area-inset-top))}
body{padding-bottom:env(safe-area-inset-bottom)}
}
</style></head><body>
<div class="top">
<h1><span>Q</span>bix Server</h1>
<div class="status"><span class="pulse"></span> Running</div>
</div>
<div class="tabs">
<div class="tab active" onclick="showTab('apps')">Apps</div>
<div class="tab" onclick="showTab('scripts')">Scripts</div>
<div class="tab" onclick="showTab('plugins')">Plugins</div>
<div class="tab" onclick="showTab('system')">System</div>
</div>
<!-- APPS TAB -->
<div id="tab-apps" class="content">
<!-- Suggestions -->
<div id="suggestions" style="margin-bottom:16px"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="font-size:16px">Your Apps</h2>
<button class="btn btn-primary" onclick="showCreate()">+ New App</button>
</div>
<div id="create-form" class="card hidden">
<h3>Create New App</h3>
<div class="form-row"><label>Name</label><input id="new-name" placeholder="MyNewApp (alphanumeric)"></div>
<div class="form-row"><label>Template</label>
<select id="new-template"><option>MyApp</option><option>SimpleHostedPHP</option></select>
</div>
<div class="btn-row"><button class="btn btn-primary" onclick="createApp()">Create</button>
<button class="btn btn-ghost" onclick="hideCreate()">Cancel</button></div>
</div>
<div id="apps-list"></div>
</div>
<!-- SCRIPTS TAB -->
<div id="tab-scripts" class="content hidden">
<h2 style="font-size:16px;margin-bottom:16px">Run Scripts</h2>
<div class="card">
<div class="form-row"><label>App</label><select id="script-app" onchange="loadScripts()"></select></div>
<div class="form-row"><label>Script</label><select id="script-name"></select></div>
<div class="form-row"><label>Args</label><input id="script-args" placeholder="--all or --plugins --composer"></div>
<button class="btn btn-primary" onclick="runScript()">Run</button>
<div id="script-output" class="output hidden"></div>
</div>
<div class="card" style="margin-top:16px">
<h3>Common tasks</h3>
<div class="btn-row" style="flex-wrap:wrap;gap:8px;margin-top:8px">
<button class="btn btn-ghost" onclick="quickScript('configure')">Configure</button>
<button class="btn btn-ghost" onclick="quickScript('install','--all')" id="btn-install-all">Install All</button>
<button class="btn btn-ghost" onclick="quickScript('install','--plugins --composer')">Install Plugins</button>
<button class="btn btn-ghost" onclick="quickScript('urls')">Rebuild URLs</button>
<button class="btn btn-ghost" id="btn-npm" onclick="requireNode(function(){quickScript('install','--npm')})">Install npm packages</button>
<button class="btn btn-ghost" id="btn-bundle" onclick="requireNode(function(){quickScript('bundle')})">Bundle JS/CSS</button>
</div>
</div>
</div>
<!-- PLUGINS TAB -->
<div id="tab-plugins" class="content hidden">
<h2 style="font-size:16px;margin-bottom:16px">Installed Plugins</h2>
<div id="plugins-list"></div>
</div>
<!-- SYSTEM TAB -->
<div id="tab-system" class="content hidden">
<h2 style="font-size:16px;margin-bottom:16px">System Info</h2>
<div class="grid-2" id="system-info"></div>
</div>
<script>
const API = '/Q/api';
let hasNode = false;
let hasComposer = false;
let authToken = null;
// ── Auth ─────────────────────────────────────────────
function getToken() {
if (authToken) return authToken;
try { authToken = sessionStorage.getItem('Q_panel_token'); } catch(e) {}
return authToken;
}
function setToken(t) {
authToken = t;
try { sessionStorage.setItem('Q_panel_token', t); } catch(e) {}
// Also set as cookie for WebSocket auth
document.cookie = 'Q_panel_token=' + t + '; path=/; SameSite=Strict';
}
async function api(path, body) {
var headers = {'Content-Type':'application/json'};
var t = getToken();
if (t) headers['X-Panel-Token'] = t;
var r = await fetch(API+'/'+path, body
? {method:'POST', headers:headers, body:JSON.stringify(body)}
: {headers:headers});
var data = await r.json();
if (data.error && (data.needsSetup || r.status === 401)) {
showAuthScreen(data.needsSetup);
throw new Error('auth');
}
return data;
}
function showAuthScreen(isSetup) {
var main = document.getElementById('main-content');
if (!main) {
// Wrap everything after tabs in a container
var tabs = document.querySelector('.tabs');
var els = [];
var sib = tabs.nextElementSibling;
while (sib) { els.push(sib); sib = sib.nextElementSibling; }
main = document.createElement('div');
main.id = 'main-content';
els.forEach(function(el) { main.appendChild(el); });
tabs.parentNode.insertBefore(main, tabs.nextSibling);
}
main.style.display = 'none';
document.querySelector('.tabs').style.display = 'none';
var existing = document.getElementById('auth-screen');
if (existing) existing.remove();
var screen = document.createElement('div');
screen.id = 'auth-screen';
screen.className = 'content';
screen.style.maxWidth = '380px';
screen.style.margin = '40px auto';
screen.innerHTML = '<div class="card">'
+ '<h3 style="margin-bottom:12px">' + (isSetup ? 'Set Panel Password' : 'Panel Login') + '</h3>'
+ (isSetup ? '<p style="font-size:13px;color:var(--dim);margin-bottom:16px">You\'re the first person to access this panel. Set a password to secure it.</p>' : '')
+ '<div class="form-row"><label>Password</label><input type="password" id="auth-pw" placeholder="' + (isSetup ? 'Choose a password (6+ chars)' : 'Enter password') + '"></div>'
+ (isSetup ? '<div class="form-row"><label>Confirm</label><input type="password" id="auth-pw2" placeholder="Confirm password"></div>' : '')
+ '<button class="btn btn-primary" onclick="doAuth(' + (isSetup ? 'true' : 'false') + ')" style="width:100%">' + (isSetup ? 'Set Password' : 'Login') + '</button>'
+ '<div id="auth-error" style="color:var(--red);font-size:13px;margin-top:8px;display:none"></div>'
+ '</div>';
document.body.insertBefore(screen, document.querySelector('.tabs').nextSibling);
// Enter key
screen.addEventListener('keydown', function(e) {
if (e.key === 'Enter') doAuth(isSetup);
});
document.getElementById('auth-pw').focus();
}
async function doAuth(isSetup) {
var pw = document.getElementById('auth-pw').value;
var errEl = document.getElementById('auth-error');
errEl.style.display = 'none';
if (isSetup) {
var pw2 = document.getElementById('auth-pw2').value;
if (pw !== pw2) { errEl.textContent = 'Passwords don\'t match'; errEl.style.display = 'block'; return; }
if (pw.length < 6) { errEl.textContent = 'Must be at least 6 characters'; errEl.style.display = 'block'; return; }
}
var endpoint = isSetup ? 'auth/setup' : 'auth/login';
var r = await fetch(API + '/' + endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: pw})
});
var data = await r.json();
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = 'block';
return;
}
if (data.token) {
setToken(data.token);
document.getElementById('auth-screen').remove();
document.querySelector('.tabs').style.display = '';
document.getElementById('main-content').style.display = '';
initPanel();
}
}
async function checkAuthAndInit() {
try {
// Quick auth check — system endpoint requires auth
var r = await fetch(API + '/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
});
var data = await r.json();
if (data.needsSetup) {
showAuthScreen(true);
return;
}
// Has password — check if we have a valid token
var t = getToken();
if (!t) {
showAuthScreen(false);
return;
}
// Validate token by calling a real endpoint
try { await api('system'); initPanel(); }
catch (e) { /* showAuthScreen already called by api() */ }
} catch (e) {
showAuthScreen(false);
}
}
function initPanel() {
detectTools();
loadApps();
}
// Node detection + suggestions
async function detectTools() {
var d = await api('system');
hasNode = d.hasNode;
hasComposer = d.hasComposer;
document.querySelectorAll('[id=btn-npm],[id=btn-bundle]').forEach(function(el) {
el.classList.toggle('disabled', !hasNode);
});
renderSuggestions(d);
return d;
}
function renderSuggestions(sys) {
var el = document.getElementById('suggestions');
if (!el) return;
var html = '';
var isIOS = /iPhone|iPad/.test(navigator.userAgent);
var isAndroid = /Android/.test(navigator.userAgent);
var isMobile = isIOS || isAndroid;
if (isMobile) {
html += '<div class="suggest suggest-hotspot" onclick="showHotspotTip()">'
+ '<div class="suggest-icon">' + String.fromCodePoint(0x1F4E1) + '</div><div class="suggest-body">'
+ '<div class="suggest-title">Share with nearby people</div>'
+ '<div class="suggest-desc">Create a Personal Hotspot so others can connect</div>'
+ '</div><div class="suggest-action">How &rarr;</div></div>';
}
if (isIOS) {
html += '<a href="https://apps.apple.com/us/app/groups/id407855546" target="_blank" style="text-decoration:none">'
+ '<div class="suggest suggest-app"><div class="suggest-icon">' + String.fromCodePoint(0x1F465) + '</div><div class="suggest-body">'
+ '<div class="suggest-title">Get the Groups app</div>'
+ '<div class="suggest-desc">Community app with mesh networking</div>'
+ '</div><div class="suggest-action">App Store &rarr;</div></div></a>';
} else if (isAndroid) {
html += '<div class="suggest suggest-app" style="opacity:.6;cursor:default">'
+ '<div class="suggest-icon">' + String.fromCodePoint(0x1F465) + '</div><div class="suggest-body">'
+ '<div class="suggest-title">Groups for Android</div>'
+ '<div class="suggest-desc">Coming soon</div></div></div>';
}
if (!sys.hasNode) {
html += '<div class="suggest suggest-warn" onclick="showNodeDialog()">'
+ '<div class="suggest-icon">' + String.fromCodePoint(0x26A0) + '</div><div class="suggest-body">'
+ '<div class="suggest-title">Node.js not installed</div>'
+ '<div class="suggest-desc">Optional &mdash; needed for npm and JS/CSS bundling</div>'
+ '</div><div class="suggest-action">Install &rarr;</div></div>';
}
el.innerHTML = html;
}
function showHotspotTip() {
var isIOS = /iPhone|iPad/.test(navigator.userAgent);
var steps = isIOS
? 'Open <b>Settings &rarr; Personal Hotspot</b> and turn it on.'
: 'Open <b>Settings &rarr; Hotspot & tethering</b> and enable WiFi hotspot.';
var overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div class="dialog"><h3>Share via Hotspot</h3>'
+ '<p>' + steps + ' Others connect to your hotspot, then scan the QR code to access your server.</p>'
+ '<p style="color:var(--dim);font-size:13px">Once someone connects, their device remembers it. Next time they auto-reconnect.</p>'
+ '<div class="btn-row"><button class="btn btn-ghost" onclick="this.closest(\'.dialog-overlay\').remove()">Got it</button></div></div>';
document.body.appendChild(overlay);
}
function requireNode(callback) {
if (hasNode) return callback();
showNodeDialog();
}
function showNodeDialog() {
var overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div class="dialog">'
+ '<h3>Node.js Required</h3>'
+ '<p>This action needs Node.js for npm package management and JS/CSS bundling. '
+ 'Install Node.js, then refresh this page — the buttons will activate automatically.</p>'
+ '<div class="btn-row">'
+ '<a href="https://nodejs.org/" target="_blank" class="btn btn-primary" '
+ 'style="text-decoration:none">Download Node.js ↗</a>'
+ '<button class="btn btn-ghost" onclick="this.closest(\'.dialog-overlay\').remove()">Cancel</button>'
+ '</div></div>';
document.body.appendChild(overlay);
}
// Tabs
function showTab(name) {
document.querySelectorAll('[id^=tab-]').forEach(function(el) { el.classList.add('hidden'); });
document.getElementById('tab-'+name).classList.remove('hidden');
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
event.target.classList.add('active');
if (name==='apps') loadApps();
if (name==='plugins') loadPlugins();
if (name==='system') loadSystem();
if (name==='scripts') loadAppSelect();
}
// Apps
async function loadApps() {
var d = await api('apps');
var el = document.getElementById('apps-list');
if (!d.apps || !d.apps.length) {
el.innerHTML = '<div class="card"><p style="color:var(--dim)">No apps found. Create one to get started.</p></div>';
return;
}
el.innerHTML = d.apps.map(function(a) { return ''
+ '<div class="app-row">'
+ '<span class="dot '+(a.configured?'on':'off')+'"></span>'
+ '<span class="app-name">'+a.name+'</span>'
+ '<span class="app-url">'+(a.url||'not configured')+'</span>'
+ '<div class="btn-row">'
+ (!a.configured ? '<button class="btn btn-sm btn-grn" onclick="configureApp(\''+a.dirName+'\')">Configure</button>' : '')
+ '<button class="btn btn-sm btn-ghost" onclick="openFolder(\''+a.dir+'\',\'folder\')">📂</button>'
+ '<button class="btn btn-sm btn-ghost" onclick="openFolder(\''+a.dir+'\',\'vscode\')">VS</button>'
+ '</div></div>';
}).join('');
}
function showCreate(){document.getElementById('create-form').classList.remove('hidden')}
function hideCreate(){document.getElementById('create-form').classList.add('hidden')}
async function createApp() {
var name = document.getElementById('new-name').value.trim();
var template = document.getElementById('new-template').value;
if (!name) return alert('Enter an app name');
var r = await api('apps/create', {name:name, template:template});
if (r.error) return alert(r.error);
hideCreate();
loadApps();
}
async function configureApp(name) {
var r = await api('apps/configure', {app:name});
alert(r.output||'Done');
loadApps();
}
async function openFolder(dir, editor) {
await api('apps/open', {dir:dir, editor:editor});
}
// Scripts
async function loadAppSelect() {
var d = await api('apps');
var sel = document.getElementById('script-app');
sel.innerHTML = (d.apps||[]).map(function(a) {
return '<option value="'+a.dirName+'">'+a.name+'</option>';
}).join('');
loadScripts();
}
async function loadScripts() {
var app = document.getElementById('script-app').value;
if (!app) return;
var d = await api('scripts', {app:app});
var sel = document.getElementById('script-name');
sel.innerHTML = (d.scripts||[]).map(function(s) {
return '<option value="'+s.name+'">'+s.name+' ('+s.scope+')</option>';
}).join('');
}
async function runScript() {
var app = document.getElementById('script-app').value;
var script = document.getElementById('script-name').value;
var args = document.getElementById('script-args').value.split(/\s+/).filter(Boolean);
var out = document.getElementById('script-output');
out.classList.remove('hidden');
out.textContent = 'Running '+script+'...';
var r = await api('scripts/run', {app:app, script:script, args:args});
out.textContent = (r.output||'(no output)') + '\n\nExit code: '+(r.exitCode||'0');
}
function quickScript(name, args) {
var app = document.getElementById('script-app').value;
if (!app) return alert('Select an app first');
document.getElementById('script-name').value = name;
document.getElementById('script-args').value = args||'';
runScript();
}
// Plugins
async function loadPlugins() {
var d = await api('plugins');
var el = document.getElementById('plugins-list');
el.innerHTML = (d.plugins||[]).map(function(p) { return ''
+ '<div class="app-row">'
+ '<span class="dot on"></span>'
+ '<span class="app-name">'+p.name+'</span>'
+ '<div class="btn-row">'
+ '<button class="btn btn-sm btn-ghost" onclick="openFolder(\''+p.dir+'\',\'folder\')">📂</button>'
+ '</div></div>';
}).join('');
}
// System
async function loadSystem() {
var d = await detectTools();
var el = document.getElementById('system-info');
var items = [
['PHP', d.php], ['OS', d.os+' '+d.arch], ['Memory Limit', d.memoryLimit],
['pcntl', d.hasPcntl?'✅':'âŒ'], ['APCu', d.hasApcu?'✅':'âŒ'],
['Composer', d.hasComposer?'✅ installed':'⌠not found'],
['Node.js', d.hasNode?'✅ installed':'<span style="color:var(--red)">⌠not found</span> — <a href="https://nodejs.org/" target="_blank" style="color:var(--ac)">install</a>'],
['npm', d.hasNpm?'✅ installed':'⌠requires Node.js'],
];
el.innerHTML = items.map(function(i) {
return '<div class="card"><div class="stat-lbl">'+i[0]+'</div><div class="stat-val" style="font-size:16px">'+i[1]+'</div></div>';
}).join('');
}
// Init
checkAuthAndInit();
</script></body></html>
HTML;
}
}
<?php
/**
* @module Q
*/
/**
* Merkle-tree invalidation layer for Q_WebServer_Cache.
*
* Does NOT store component HTML — only hashes and dependencies.
* The actual cached page lives in Q_WebServer_Cache (page-level).
* This layer answers one question: "is this cached page still valid?"
*
* Better than ESI/SSI because:
* - No component HTML in memory — just a tree of md5 hashes (~100 bytes/page)
* - Stream-driven invalidation: change a stream → invalidate specific pages
* - Children communicate via response headers, not wire protocol
* - All in parent process memory — no edge proxy, no parsing
*
* How it works:
*
* 1. Child renders a page. Q_Response::fillSlot() tracks which slots
* were rendered and which streams each slot read from. After rendering,
* the child sets response headers:
*
* X-Q-Cache-Tree: {"t":{"da":{"av":"a3f2","nt":"b8c1"},"co":{"fe":"d4e5","mb":"f6a7","sb":"c8d9"}},"h":"root_hash"}
* X-Q-Cache-Deps: {"co.fe":["community/feed/456"],"co.mb":["community/participants/456"],"da.av":["Users/avatar/123"]}
*
* 2. Parent receives response, caches it in Q_WebServer_Cache (full page),
* and stores the Merkle tree + deps here (hashes only, ~200 bytes).
*
* 3. When a stream changes (child sends X-Q-Cache-Invalidate header,
* or WebSocket message, or explicit API call):
* - Look up dependency index: stream → [pageKey, leafPath]
* - Remove the page from Q_WebServer_Cache
* - Mark the Merkle leaf as stale (optional: for partial re-render hints)
*
* 4. Next request for this page: cache miss → fork worker → full re-render
* → new tree + new page cached. The stale leaves tell the child which
* slots changed, enabling smart partial rendering if the app supports it.
*
* Config:
* "Q": { "web": { "cache": { "components": {
* "enabled": true,
* "maxTrees": 10000
* }}}}
*
* @class Q_WebServer_Cache_Components
*/
class Q_WebServer_Cache_Components
{
// ── State (parent process memory) ───────────────────
/**
* Merkle trees: pageKey => tree
* A tree is: { 'hash' => rootHash, 'leaves' => { 'path' => hash, ... } }
* Compact — no HTML, no children structure. Just leaf hashes + root.
* The hierarchy is encoded in dot-separated leaf paths.
* @property $trees
*/
protected static $trees = array();
/**
* Forward dependency index: streamKey => [ [pageKey, leafPath], ... ]
* @property $deps
*/
protected static $deps = array();
/**
* Reverse index: pageKey => [ streamKey, ... ]
* For cleanup when a page tree is evicted.
* @property $pageStreams
*/
protected static $pageStreams = array();
/**
* Stale leaves: pageKey => [ leafPath, ... ]
* After invalidation, records which leaves changed so the next
* render can optionally skip unchanged slots.
* @property $staleLeaves
*/
protected static $staleLeaves = array();
// ── Stats ───────────────────────────────────────────
protected static $invalidations = 0;
protected static $pagesInvalidated = 0;
// ── Config ──────────────────────────────────────────
protected static $enabled = false;
protected static $maxTrees = 10000;
/**
* Initialize from config.
*/
static function init()
{
$config = Q_Config::get('Q', 'web', 'cache', 'components', array());
self::$enabled = (bool) Q::ifset($config, 'enabled', false);
self::$maxTrees = (int) Q::ifset($config, 'maxTrees', 10000);
}
static function enabled()
{
return self::$enabled;
}
// ── Process response headers from child ─────────────
/**
* Called by the parent after receiving a response from a worker.
* Extracts X-Q-Cache-Tree, X-Q-Cache-Deps, and X-Q-Cache-Invalidate
* headers. Strips them from the response (they're internal).
*
* @method processResponseHeaders
* @static
* @param {string} $pageKey Cache key for this page
* @param {array} &$headers Response headers (modified in place — internal headers removed)
*/
static function processResponseHeaders($pageKey, &$headers)
{
if (!self::$enabled) return;
// 1. Handle invalidations first (from POST/write requests)
$invalidateHeader = self::extractHeader($headers, 'X-Q-Cache-Invalidate');
if ($invalidateHeader) {
$streams = json_decode($invalidateHeader, true);
if (is_array($streams)) {
self::invalidateStreams($streams);
}
}
// 2. Register tree from GET response
$treeHeader = self::extractHeader($headers, 'X-Q-Cache-Tree');
$depsHeader = self::extractHeader($headers, 'X-Q-Cache-Deps');
if ($treeHeader) {
$tree = json_decode($treeHeader, true);
if (is_array($tree)) {
self::registerTree($pageKey, $tree);
}
}
if ($depsHeader) {
$deps = json_decode($depsHeader, true);
if (is_array($deps)) {
self::registerDeps($pageKey, $deps);
}
}
}
/**
* Extract and remove an internal header from the response.
* Returns the value or null.
*/
protected static function extractHeader(&$headers, $name)
{
$lower = strtolower($name);
foreach ($headers as $k => $v) {
if (strtolower($k) === $lower) {
unset($headers[$k]);
return $v;
}
}
return null;
}
// ── Tree registration ───────────────────────────────
/**
* Register a Merkle tree from a child's response.
*
* Tree format (JSON from X-Q-Cache-Tree header):
* { "h": "root_hash", "l": { "title": "abc", "content.feed": "def", ... } }
*
* "h" = root hash (md5 of concatenated leaf hashes)
* "l" = leaf hashes, keyed by dot-separated path
*
* @param {string} $pageKey
* @param {array} $tree Decoded JSON
*/
static function registerTree($pageKey, $tree)
{
// Evict old tree if exists (cleans up deps)
if (isset(self::$trees[$pageKey])) {
self::evictTree($pageKey);
}
self::$trees[$pageKey] = array(
'hash' => $tree['h'] ?? self::computeRoot($tree['l'] ?? array()),
'leaves' => $tree['l'] ?? array(),
'time' => time(),
);
// Clear any stale markers (we have a fresh render)
unset(self::$staleLeaves[$pageKey]);
// Evict oldest if over limit
while (count(self::$trees) > self::$maxTrees) {
$oldest = array_key_first(self::$trees);
if ($oldest === null || $oldest === $pageKey) break;
self::evictTree($oldest);
}
}
/**
* Register dependencies from a child's response.
*
* Deps format (JSON from X-Q-Cache-Deps header):
* { "content.feed": ["community/feed/456"], "da.av": ["Users/avatar/123"], ... }
*
* Keys = leaf paths, values = arrays of stream keys that leaf reads from.
*
* @param {string} $pageKey
* @param {array} $deps Decoded JSON
*/
static function registerDeps($pageKey, $deps)
{
$allStreams = array();
foreach ($deps as $leafPath => $streamKeys) {
foreach ($streamKeys as $streamKey) {
// Forward index
if (!isset(self::$deps[$streamKey])) {
self::$deps[$streamKey] = array();
}
self::$deps[$streamKey][] = array($pageKey, $leafPath);
$allStreams[$streamKey] = true;
}
}
// Reverse index for cleanup
self::$pageStreams[$pageKey] = array_keys($allStreams);
}
// ── Invalidation ────────────────────────────────────
/**
* Invalidate all pages that depend on a stream.
*
* @method invalidateStream
* @static
* @param {string} $streamKey e.g. 'Streams/avatar/123'
*/
static function invalidateStream($streamKey)
{
if (!isset(self::$deps[$streamKey])) return;
self::$invalidations++;
$pagesHit = array();
foreach (self::$deps[$streamKey] as $dep) {
list($pageKey, $leafPath) = $dep;
if (!isset($pagesHit[$pageKey])) {
$pagesHit[$pageKey] = true;
// Purge from page-level cache
Q_WebServer_Cache::purge($pageKey);
self::$pagesInvalidated++;
}
// Record which leaf is stale (hint for partial re-render)
if (!isset(self::$staleLeaves[$pageKey])) {
self::$staleLeaves[$pageKey] = array();
}
if (!in_array($leafPath, self::$staleLeaves[$pageKey])) {
self::$staleLeaves[$pageKey][] = $leafPath;
}
// Mark leaf hash as stale in tree
if (isset(self::$trees[$pageKey]['leaves'][$leafPath])) {
self::$trees[$pageKey]['leaves'][$leafPath] = null; // stale
self::$trees[$pageKey]['hash'] = null; // root invalid
}
}
}
/**
* Invalidate multiple streams.
*
* @method invalidateStreams
* @static
* @param {array} $streamKeys
*/
static function invalidateStreams($streamKeys)
{
foreach ($streamKeys as $key) {
self::invalidateStream($key);
}
}
// ── Query ───────────────────────────────────────────
/**
* Check if a page's Merkle root still matches what we have.
* Called optionally — the main cache layer (Q_WebServer_Cache)
* already handles TTL-based expiry. This is for instant invalidation.
*
* @method isValid
* @static
* @param {string} $pageKey
* @param {string} $rootHash The root hash to check against
* @return {boolean} true if the tree exists and the root matches
*/
static function isValid($pageKey, $rootHash)
{
if (!isset(self::$trees[$pageKey])) return true; // no tree = no opinion
return self::$trees[$pageKey]['hash'] === $rootHash;
}
/**
* Get the list of stale leaves for a page.
* The child can use this to skip re-rendering unchanged slots.
*
* @method getStaleLeaves
* @static
* @param {string} $pageKey
* @return {array} Leaf paths that changed since last render
*/
static function getStaleLeaves($pageKey)
{
return self::$staleLeaves[$pageKey] ?? array();
}
/**
* Check if a specific leaf is stale.
*
* @method isLeafStale
* @static
* @param {string} $pageKey
* @param {string} $leafPath
* @return {boolean}
*/
static function isLeafStale($pageKey, $leafPath)
{
if (!isset(self::$staleLeaves[$pageKey])) return false;
return in_array($leafPath, self::$staleLeaves[$pageKey]);
}
// ── Hints to child ──────────────────────────────────
/**
* Build a header value telling the child which slots are stale.
* The child can set this on the request when dispatching to a worker.
*
* @method buildStaleHintsHeader
* @static
* @param {string} $pageKey
* @return {string|null} JSON array of stale leaf paths, or null if none
*/
static function buildStaleHintsHeader($pageKey)
{
$stale = self::$staleLeaves[$pageKey] ?? array();
return !empty($stale) ? json_encode($stale) : null;
}
// ── Cleanup ─────────────────────────────────────────
/**
* Remove a page's tree and clean up all its dependency entries.
*/
protected static function evictTree($pageKey)
{
// Remove from forward deps
if (isset(self::$pageStreams[$pageKey])) {
foreach (self::$pageStreams[$pageKey] as $streamKey) {
if (isset(self::$deps[$streamKey])) {
self::$deps[$streamKey] = array_values(array_filter(
self::$deps[$streamKey],
function ($d) use ($pageKey) { return $d[0] !== $pageKey; }
));
if (empty(self::$deps[$streamKey])) {
unset(self::$deps[$streamKey]);
}
}
}
unset(self::$pageStreams[$pageKey]);
}
unset(self::$trees[$pageKey], self::$staleLeaves[$pageKey]);
}
// ── Merkle computation ──────────────────────────────
/**
* Compute root hash from leaf hashes.
* Deterministic: sorts by path, concatenates "path:hash", md5s the result.
*
* @param {array} $leaves path => hash pairs
* @return {string} root hash
*/
protected static function computeRoot($leaves)
{
if (empty($leaves)) return md5('');
ksort($leaves);
$concat = '';
foreach ($leaves as $path => $hash) {
$concat .= $path . ':' . ($hash ?? 'null') . "\n";
}
return md5($concat);
}
// ── Wire protocol (legacy support) ──────────────────
/**
* Process a cache message from a child (via Pool wire protocol).
* Supports both the header-based approach and explicit messages.
*
* @method processChildMessage
* @static
* @param {array} $msg
*/
static function processChildMessage($msg)
{
$action = $msg['action'] ?? '';
if ($action === 'invalidate') {
self::invalidateStreams($msg['streams'] ?? array());
} elseif ($action === 'register') {
$pageKey = $msg['pageKey'] ?? '';
if (isset($msg['tree'])) {
self::registerTree($pageKey, $msg['tree']);
}
if (isset($msg['deps'])) {
self::registerDeps($pageKey, $msg['deps']);
}
}
}
// ── Stats ───────────────────────────────────────────
static function stats()
{
return array(
'trees' => count(self::$trees),
'trackedStreams' => count(self::$deps),
'invalidations' => self::$invalidations,
'pagesInvalidated' => self::$pagesInvalidated,
'stalePagesNow' => count(self::$staleLeaves),
);
}
/**
* Dump a page's tree for debugging/dashboard.
*
* @param {string} $pageKey
* @return {array|null}
*/
static function dumpTree($pageKey)
{
if (!isset(self::$trees[$pageKey])) return null;
$tree = self::$trees[$pageKey];
return array(
'rootHash' => $tree['hash'] ? substr($tree['hash'], 0, 8) : 'STALE',
'cachedAt' => date('H:i:s', $tree['time']),
'leaves' => array_map(function ($h) {
return $h ? substr($h, 0, 8) : 'STALE';
}, $tree['leaves']),
'stale' => self::$staleLeaves[$pageKey] ?? array(),
'deps' => self::$pageStreams[$pageKey] ?? array(),
);
}
}
<?php
/**
* @module Q
*/
/**
* TLS certificate management for Q_WebServer.
*
* Two modes:
*
* 1. Local certbot: runs `certbot certonly` to obtain/renew
* Let's Encrypt certs. Checks expiration via openssl_x509_parse
* and renews automatically — no cron needed, runs on a
* Q_Evented timer.
*
* 2. Remote download: fetches certs from a URL (.zip containing
* fullchain.pem + privkey.pem). For dev domains like
* local.qbix.com where certs are published centrally.
* Checks actual cert expiration, re-downloads when expired.
*
* Config:
* "Q": {
* "web": {
* "https": {
* "cert": "/path/to/fullchain.pem", // or auto-managed path
* "key": "/path/to/privkey.pem",
* "mode": "certbot", // "certbot" | "remote" | "manual"
* "domain": "example.com",
* "certbot": {
* "email": "you@example.com",
* "webroot": "/path/to/app/web", // for webroot validation
* "renewDays": 30 // renew when < 30 days remain
* },
* "remote": {
* "url": "https://certs.qbix.com/local.qbix.com/certs.zip",
* "checkInterval": 86400 // check daily (seconds)
* }
* }
* }
* }
*
* @class Q_WebServer_Certs
*/
class Q_WebServer_Certs
{
/**
* Path to current fullchain.pem
* @property $certPath
* @static
*/
static $certPath = null;
/**
* Path to current privkey.pem
* @property $keyPath
* @static
*/
static $keyPath = null;
/**
* Initialize cert management. Loads existing certs,
* checks expiration, starts renewal timer if needed.
*
* @method init
* @static
* @param {string} $domain The domain to serve
* @return {boolean} true if valid certs are available
*/
static function init($domain = null)
{
$config = Q_Config::get('Q', 'web', 'https', array());
$mode = Q::ifset($config, 'mode', 'manual');
$domain = $domain ?: Q::ifset($config, 'domain', '');
// Determine cert paths
$certsDir = self::certsDir();
self::$certPath = Q::ifset($config, 'cert',
$certsDir . DS . 'fullchain.pem');
self::$keyPath = Q::ifset($config, 'key',
$certsDir . DS . 'privkey.pem');
// Check if we have valid certs already
$valid = self::validateCerts();
if (!$valid) {
// Try to obtain certs
if ($mode === 'certbot') {
$valid = self::obtainCertbot($domain, $config);
} elseif ($mode === 'remote') {
$valid = self::downloadRemote($config);
}
}
// Start renewal timer
if ($mode === 'certbot') {
$checkInterval = 86400; // daily
Q_Evented::repeat((float) $checkInterval, function () use ($domain, $config) {
Q_WebServer_Certs::checkRenewal($domain, $config);
});
} elseif ($mode === 'remote') {
$checkInterval = (float) Q::ifset($config, 'remote', 'checkInterval', 86400);
Q_Evented::repeat($checkInterval, function () use ($config) {
Q_WebServer_Certs::checkRemoteRenewal($config);
});
}
return $valid;
}
/**
* Build an SSL context for stream_socket_server.
*
* @method sslContext
* @static
* @return {resource|null} Stream context or null if no certs
*/
static function sslContext()
{
if (!self::$certPath || !file_exists(self::$certPath)
|| !self::$keyPath || !file_exists(self::$keyPath)
) {
return null;
}
return stream_context_create(array(
'ssl' => array(
'local_cert' => self::$certPath,
'local_pk' => self::$keyPath,
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER
| STREAM_CRYPTO_METHOD_TLSv1_3_SERVER,
)
));
}
/**
* Check if current certs exist and are not expired.
*
* @method validateCerts
* @static
* @return {boolean}
*/
static function validateCerts()
{
if (!self::$certPath || !file_exists(self::$certPath)) return false;
if (!self::$keyPath || !file_exists(self::$keyPath)) return false;
$expiry = self::certExpiry(self::$certPath);
if ($expiry === null) return false;
return $expiry > time();
}
/**
* Get cert expiration as Unix timestamp.
* Uses openssl_x509_parse() — reads from the actual cert,
* not mtime.
*
* @method certExpiry
* @static
* @param {string} $certPath Path to PEM cert
* @return {integer|null} Expiry timestamp or null
*/
static function certExpiry($certPath)
{
if (!function_exists('openssl_x509_parse')) return null;
$pem = file_get_contents($certPath);
if (!$pem) return null;
$cert = openssl_x509_parse($pem);
if (!$cert || !isset($cert['validTo_time_t'])) return null;
return (int) $cert['validTo_time_t'];
}
/**
* Days remaining until cert expires.
*
* @method daysRemaining
* @static
* @return {integer|null}
*/
static function daysRemaining()
{
$expiry = self::certExpiry(self::$certPath);
if ($expiry === null) return null;
return max(0, (int) floor(($expiry - time()) / 86400));
}
// ── Certbot mode ─────────────────────────────────────
/**
* Obtain a cert via certbot certonly.
*
* @method obtainCertbot
* @static
* @param {string} $domain
* @param {array} $config
* @return {boolean}
*/
static function obtainCertbot($domain, $config)
{
if (!$domain) {
echo "[HTTPS] No domain configured for certbot\n";
return false;
}
$email = Q::ifset($config, 'certbot', 'email', '');
$webroot = Q::ifset($config, 'certbot', 'webroot', APP_WEB_DIR);
$certsDir = self::certsDir();
// Use standalone if port 80 is available, webroot otherwise
$emailFlag = $email ? "--email $email" : "--register-unsafely-without-email";
$cmd = "certbot certonly --non-interactive --agree-tos $emailFlag "
. "--webroot -w " . escapeshellarg($webroot) . " "
. "-d " . escapeshellarg($domain) . " "
. "--cert-path " . escapeshellarg($certsDir . DS . 'fullchain.pem') . " "
. "--key-path " . escapeshellarg($certsDir . DS . 'privkey.pem') . " "
. "2>&1";
echo "[HTTPS] Running certbot for $domain...\n";
$output = shell_exec($cmd);
$success = (strpos($output, 'Successfully') !== false
|| strpos($output, 'Certificate not yet due for renewal') !== false);
if ($success) {
// Certbot stores in /etc/letsencrypt/live/$domain/
// Copy or symlink to our certsDir
$leDir = "/etc/letsencrypt/live/$domain";
if (is_dir($leDir)) {
self::$certPath = "$leDir/fullchain.pem";
self::$keyPath = "$leDir/privkey.pem";
}
echo "[HTTPS] Certificate obtained for $domain\n";
return self::validateCerts();
}
echo "[HTTPS] Certbot failed: $output\n";
return false;
}
/**
* Check if certbot renewal is needed.
* Called on Q_Evented timer.
*
* @method checkRenewal
* @static
*/
static function checkRenewal($domain, $config)
{
$renewDays = (int) Q::ifset($config, 'certbot', 'renewDays', 30);
$remaining = self::daysRemaining();
if ($remaining === null || $remaining <= $renewDays) {
echo "[HTTPS] Cert expires in " . ($remaining ?? '?')
. " days, renewing...\n";
$success = self::obtainCertbot($domain, $config);
if ($success) {
echo "[HTTPS] Renewed. " . self::daysRemaining() . " days remaining.\n";
// Reload SSL context in WebServer
self::reloadServerCerts();
}
}
}
// ── Remote download mode ─────────────────────────────
/**
* Download certs from a remote URL (.zip file containing
* fullchain.pem and privkey.pem).
*
* @method downloadRemote
* @static
* @param {array} $config
* @return {boolean}
*/
static function downloadRemote($config)
{
$url = Q::ifset($config, 'remote', 'url', '');
if (!$url) {
echo "[HTTPS] No remote cert URL configured\n";
return false;
}
echo "[HTTPS] Downloading certs from $url...\n";
$certsDir = self::certsDir();
$zipPath = $certsDir . DS . 'certs-download.zip';
// Download
$ch = curl_init($url);
$fp = fopen($zipPath, 'wb');
curl_setopt_array($ch, array(
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
));
$success = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if (!$success || $status >= 400) {
echo "[HTTPS] Download failed (HTTP $status)\n";
@unlink($zipPath);
return false;
}
// Extract
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
echo "[HTTPS] Invalid zip file\n";
@unlink($zipPath);
return false;
}
$extracted = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
$basename = basename($name);
if ($basename === 'fullchain.pem' || $basename === 'privkey.pem') {
$zip->extractTo($certsDir, $name);
// Move to certsDir root if nested
$extractedPath = $certsDir . DS . $name;
$targetPath = $certsDir . DS . $basename;
if ($extractedPath !== $targetPath && file_exists($extractedPath)) {
rename($extractedPath, $targetPath);
}
$extracted = true;
}
}
$zip->close();
@unlink($zipPath);
if (!$extracted) {
echo "[HTTPS] Zip did not contain fullchain.pem / privkey.pem\n";
return false;
}
self::$certPath = $certsDir . DS . 'fullchain.pem';
self::$keyPath = $certsDir . DS . 'privkey.pem';
$days = self::daysRemaining();
echo "[HTTPS] Certs installed, " . ($days ?? '?') . " days remaining\n";
return self::validateCerts();
}
/**
* Check if remote certs need re-downloading.
* Checks actual cert expiration, not mtime.
*
* @method checkRemoteRenewal
* @static
*/
static function checkRemoteRenewal($config)
{
$remaining = self::daysRemaining();
$renewDays = 7; // re-download when < 7 days remain
if ($remaining === null || $remaining <= $renewDays) {
echo "[HTTPS] Remote cert expires in " . ($remaining ?? '?')
. " days, re-downloading...\n";
$success = self::downloadRemote($config);
if ($success) {
self::reloadServerCerts();
}
}
}
// ── Helpers ──────────────────────────────────────────
/**
* Directory for storing cert files.
*
* @method certsDir
* @static
* @return {string}
*/
static function certsDir()
{
$dir = Q_Config::get('Q', 'web', 'https', 'certsDir', null);
if (!$dir) {
$dir = (defined('APP_DIR') ? APP_DIR : '.') . DS . 'config' . DS . 'certs';
}
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
return $dir;
}
/**
* Reload certs in the running server.
* For stream_socket_server, this requires restarting
* the listener with a new SSL context.
*
* @method reloadServerCerts
* @static
*/
static function reloadServerCerts()
{
// With per-connection SSL context, new connections
// automatically pick up the new cert files.
// Just notify Q_WebServer for logging.
Q_WebServer::reloadTls();
}
/**
* Format cert info for display.
*
* @method info
* @static
* @return {array} [valid, daysRemaining, expiry, subject, issuer]
*/
static function info()
{
if (!self::$certPath || !file_exists(self::$certPath)) {
return array('valid' => false);
}
$pem = file_get_contents(self::$certPath);
$cert = openssl_x509_parse($pem);
if (!$cert) return array('valid' => false);
$expiry = $cert['validTo_time_t'];
return array(
'valid' => $expiry > time(),
'daysRemaining' => max(0, (int) floor(($expiry - time()) / 86400)),
'expiry' => date('Y-m-d H:i:s', $expiry),
'subject' => $cert['subject']['CN'] ?? '',
'issuer' => $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? '',
);
}
}
<?php
/**
* @module Q
*/
/**
* Built-in reverse proxy cache for Q_WebServer.
*
* Sits in the parent process event loop, before worker dispatch.
* Cached responses are served without forking a worker — pure
* event loop speed, on par with Varnish for cache hits.
*
* Two storage tiers:
* - APCu: for small responses (under Q.web.cache.apcu.maxSize)
* - Filesystem: for larger responses
*
* Respects HTTP caching semantics:
* - Cache-Control: max-age, s-maxage, no-store, private, no-cache
* - Vary header (cache per Accept-Encoding, etc.)
* - Cookie bypass: skip cache if request has specific cookies
*
* Config:
* "Q": { "web": { "cache": {
* "enabled": true,
* "dir": "files/cache/reverse",
* "apcu": {
* "enabled": true,
* "maxSize": 65536
* },
* "defaultTtl": 0,
* "skip": {
* "cookies": ["Q_sid", "PHPSESSID"]
* }
* }}}
*
* @class Q_WebServer_Cache
*/
class Q_WebServer_Cache
{
static $enabled = false;
static $dir = '';
static $apcuEnabled = false;
static $apcuMaxSize = 65536; // 64KB
static $defaultTtl = 0; // 0 = don't cache unless told to
static $skipCookies = array();
static $hits = 0;
static $misses = 0;
/**
* Initialize cache from config.
* @method init
* @static
*/
static function init()
{
$config = Q_Config::get('Q', 'web', 'cache', array());
self::$enabled = (bool) Q::ifset($config, 'enabled', false);
if (!self::$enabled) return;
self::$dir = Q::ifset($config, 'dir', '');
if (!self::$dir && defined('APP_DIR')) {
self::$dir = APP_DIR . DS . 'files' . DS . 'cache' . DS . 'reverse';
}
if (self::$dir && !is_dir(self::$dir)) {
mkdir(self::$dir, 0755, true);
}
$apcu = Q::ifset($config, 'apcu', array());
self::$apcuEnabled = (bool) Q::ifset($apcu, 'enabled', function_exists('apcu_fetch'));
self::$apcuMaxSize = (int) Q::ifset($apcu, 'maxSize', 65536);
self::$defaultTtl = (int) Q::ifset($config, 'defaultTtl', 0);
self::$skipCookies = Q::ifset($config, 'skip', 'cookies', array('Q_sid', 'PHPSESSID'));
}
/**
* Try to serve from cache. Returns response array or null.
*
* Called in the parent event loop BEFORE dispatching to a
* worker. A cache hit means zero fork overhead.
*
* @method get
* @static
* @param {array} $parsed Parsed request
* @return {array|null} [status, headers, body] or null
*/
static function get($parsed)
{
if (!self::$enabled) return null;
if ($parsed['method'] !== 'GET') return null;
// Skip cache if request has bypass cookies
if (self::hasSkipCookie($parsed['headers'])) return null;
$key = self::cacheKey($parsed);
// Try APCu first (faster)
if (self::$apcuEnabled) {
$entry = apcu_fetch('qcache:' . $key);
if ($entry !== false) {
if ($entry['expires'] > 0 && $entry['expires'] < time()) {
apcu_delete('qcache:' . $key);
} else {
self::$hits++;
$entry['headers']['X-Cache'] = 'HIT';
return $entry;
}
}
}
// Try filesystem
$path = self::filePath($key);
if ($path && file_exists($path)) {
$entry = json_decode(file_get_contents($path), true);
if ($entry && ($entry['expires'] === 0 || $entry['expires'] > time())) {
self::$hits++;
$entry['headers']['X-Cache'] = 'HIT';
// Promote to APCu if small enough
if (self::$apcuEnabled && strlen($entry['body']) <= self::$apcuMaxSize) {
apcu_store('qcache:' . $key, $entry, self::ttlRemaining($entry));
}
return $entry;
}
@unlink($path);
}
self::$misses++;
return null;
}
/**
* Store a response in cache if cacheable.
*
* Checks Cache-Control headers to determine TTL.
* Only caches GET responses with 200 status.
*
* @method put
* @static
* @param {array} $parsed Request
* @param {array} $response [status, headers, body]
*/
static function put($parsed, $response)
{
if (!self::$enabled) return;
if ($parsed['method'] !== 'GET') return;
if (($response['status'] ?? 200) !== 200) return;
if (self::hasSkipCookie($parsed['headers'])) return;
$headers = $response['headers'] ?? array();
$cc = self::parseCacheControl($headers);
// Don't cache if explicitly forbidden
if (isset($cc['no-store']) || isset($cc['private'])) return;
// Determine TTL
$ttl = 0;
if (isset($cc['s-maxage'])) {
$ttl = (int) $cc['s-maxage'];
} elseif (isset($cc['max-age'])) {
$ttl = (int) $cc['max-age'];
} elseif (self::$defaultTtl > 0) {
$ttl = self::$defaultTtl;
}
if ($ttl <= 0) return; // nothing to cache
$key = self::cacheKey($parsed);
$body = $response['body'] ?? '';
$expires = time() + $ttl;
$entry = array(
'status' => $response['status'] ?? 200,
'headers' => $headers,
'body' => $body,
'expires' => $expires,
'stored' => time(),
);
// Store in APCu if small enough
if (self::$apcuEnabled && strlen($body) <= self::$apcuMaxSize) {
apcu_store('qcache:' . $key, $entry, $ttl);
}
// Always store on filesystem (APCu is per-process, lost on restart)
$path = self::filePath($key);
if ($path) {
$dir = dirname($path);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($path, json_encode($entry), LOCK_EX);
}
}
/**
* Purge cache entries matching a URL pattern.
*
* Called by application code when content changes:
* Q_WebServer_Cache::purge('/blog/my-post');
* Q_WebServer_Cache::purge('#^/api/v1/#');
*
* @method purge
* @static
* @param {string} $pattern URL path or regex
*/
static function purge($pattern)
{
if (!self::$dir || !is_dir(self::$dir)) return;
// If it looks like a regex (starts with a delimiter), match against files
$isRegex = (strlen($pattern) > 2 && $pattern[0] === $pattern[strlen($pattern)-1])
|| (strlen($pattern) > 2 && $pattern[0] === '#');
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(self::$dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->getExtension() !== 'json') continue;
$entry = json_decode(file_get_contents($file->getPathname()), true);
if (!$entry) continue;
$url = $entry['url'] ?? '';
$match = $isRegex ? preg_match($pattern, $url) : ($url === $pattern);
if ($match) {
@unlink($file->getPathname());
if (self::$apcuEnabled) {
$key = self::cacheKeyFromUrl($url);
apcu_delete('qcache:' . $key);
}
}
}
}
/**
* Clear all cached entries.
* @method clear
* @static
*/
static function clear()
{
if (self::$apcuEnabled) {
$iterator = new APCUIterator('#^qcache:#');
apcu_delete($iterator);
}
if (self::$dir && is_dir(self::$dir)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(self::$dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $f) {
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
}
}
// ── Internals ────────────────────────────────────────
/**
* Generate a cache key from a request.
* Includes path + query + Vary headers.
*/
static function cacheKey($parsed)
{
$parts = $parsed['path'] . '?' . ($parsed['query'] ?? '');
// Include Accept-Encoding in key for compressed variants
$ae = $parsed['headers']['accept-encoding'] ?? '';
if (strpos($ae, 'br') !== false) {
$parts .= '|br';
} elseif (strpos($ae, 'gzip') !== false) {
$parts .= '|gzip';
}
return md5($parts);
}
static function cacheKeyFromUrl($url)
{
return md5($url);
}
static function filePath($key)
{
if (!self::$dir) return null;
// Two-level directory to avoid too many files in one dir
return self::$dir . DS . substr($key, 0, 2) . DS . $key . '.json';
}
/**
* Check if request has any cookies from the skip list.
* If a session cookie is present, the response is likely
* personalized and shouldn't be cached.
*/
static function hasSkipCookie($headers)
{
$cookieHeader = $headers['cookie'] ?? '';
if (!$cookieHeader || empty(self::$skipCookies)) return false;
foreach (self::$skipCookies as $name) {
if (preg_match('/(?:^|;\s*)' . preg_quote($name, '/') . '=/', $cookieHeader)) {
return true;
}
}
return false;
}
/**
* Parse Cache-Control header into directives.
*/
static function parseCacheControl($headers)
{
$cc = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'cache-control') { $cc = $v; break; }
}
if (!$cc) return array();
$directives = array();
foreach (explode(',', $cc) as $part) {
$part = trim($part);
if (strpos($part, '=') !== false) {
list($k, $v) = explode('=', $part, 2);
$directives[trim($k)] = trim($v);
} else {
$directives[$part] = true;
}
}
return $directives;
}
static function ttlRemaining($entry)
{
if ($entry['expires'] <= 0) return 86400;
return max(1, $entry['expires'] - time());
}
/**
* Stats for the dashboard.
*/
static function stats()
{
$total = self::$hits + self::$misses;
return array(
'hits' => self::$hits,
'misses' => self::$misses,
'hitRate' => $total > 0 ? round(self::$hits / $total * 100, 1) : 0,
);
}
}
<?php
/**
* @module Q
*/
/**
* Server dashboard: comprehensive stats, live HTML display at /Q/dashboard,
* real-time updates via Q_WebSocket on the 'dashboard' channel.
* @class Q_WebServer_Dashboard
*/
class Q_WebServer_Dashboard
{
static $stats = array(
'startTime' => 0, 'requests' => 0,
'status2xx' => 0, 'status3xx' => 0, 'status4xx' => 0, 'status5xx' => 0,
'phpRequests' => 0, 'staticRequests' => 0,
'bytesOut' => 0, 'totalMs' => 0,
'slowest' => 0, 'slowestUri' => '',
);
static $recentRequests = array();
static $topPaths = array(); // path => [count, totalMs]
static $statusCodes = array(); // code => count
static $rpsHistory = array(); // [timestamp => count] for sparkline
static function init() { self::$stats['startTime'] = time(); }
static function recordRequest($method, $uri, $status, $ms, $bytes = 0, $isPhp = false)
{
self::$stats['requests']++;
self::$stats['totalMs'] += $ms;
self::$stats['bytesOut'] += $bytes;
if ($isPhp) self::$stats['phpRequests']++;
else self::$stats['staticRequests']++;
if ($ms > self::$stats['slowest']) {
self::$stats['slowest'] = $ms;
self::$stats['slowestUri'] = $uri;
}
if ($status < 300) self::$stats['status2xx']++;
elseif ($status < 400) self::$stats['status3xx']++;
elseif ($status < 500) self::$stats['status4xx']++;
else self::$stats['status5xx']++;
// Per-status tracking
if (!isset(self::$statusCodes[$status])) self::$statusCodes[$status] = 0;
self::$statusCodes[$status]++;
// Top paths
$pathKey = $method . ' ' . strtok($uri, '?');
if (!isset(self::$topPaths[$pathKey])) self::$topPaths[$pathKey] = array(0, 0);
self::$topPaths[$pathKey][0]++;
self::$topPaths[$pathKey][1] += $ms;
// RPS history (per-second bucket)
$sec = time();
if (!isset(self::$rpsHistory[$sec])) self::$rpsHistory[$sec] = 0;
self::$rpsHistory[$sec]++;
// Keep last 60 seconds
$cutoff = $sec - 60;
foreach (self::$rpsHistory as $t => $c) {
if ($t < $cutoff) unset(self::$rpsHistory[$t]);
else break;
}
$entry = array('time' => date('H:i:s'), 'method' => $method,
'uri' => $uri, 'status' => $status, 'ms' => $ms);
self::$recentRequests[] = $entry;
if (count(self::$recentRequests) > 200) array_shift(self::$recentRequests);
Q_WebSocket::broadcastTo('dashboard', array(
'type' => 'request', 'entry' => $entry, 'stats' => self::getStats()
));
}
static function getStats()
{
$up = time() - self::$stats['startTime'];
$pool = Q_WebServer::$pool;
$reqs = self::$stats['requests'];
$avgMs = $reqs > 0 ? round(self::$stats['totalMs'] / $reqs, 1) : 0;
$rps = $up > 0 ? round($reqs / $up, 1) : 0;
// Current RPS (last 5 seconds)
$now = time();
$recent5 = 0;
for ($i = 1; $i <= 5; $i++) {
$recent5 += self::$rpsHistory[$now - $i] ?? 0;
}
$currentRps = round($recent5 / 5, 1);
// Top 10 paths by count
$topPaths = self::$topPaths;
uasort($topPaths, function($a, $b) { return $b[0] - $a[0]; });
$topPaths = array_slice($topPaths, 0, 10, true);
$topFormatted = array();
foreach ($topPaths as $path => $data) {
$topFormatted[] = array(
'path' => $path,
'count' => $data[0],
'avgMs' => $data[0] > 0 ? round($data[1] / $data[0], 1) : 0,
);
}
// RPS sparkline data (last 60 seconds)
$sparkline = array();
for ($i = 59; $i >= 0; $i--) {
$sparkline[] = self::$rpsHistory[$now - $i] ?? 0;
}
// Connection counts
$wsConnections = count(Q_WebSocket::$workers);
$wsRooms = count(Q_WebSocket::$roomWorkers);
$activeRooms = array();
foreach (Q_WebSocket::$roomWorkers as $name => $rw) {
$activeRooms[] = array(
'name' => $name,
'members' => count($rw['members'] ?? array()),
);
}
return array(
'uptime' => self::fmtUp($up), 'uptimeSec' => $up,
'requests' => $reqs,
'rps' => $rps, 'currentRps' => $currentRps,
'avgMs' => $avgMs,
'slowest' => self::$stats['slowest'],
'slowestUri' => self::$stats['slowestUri'],
'status2xx' => self::$stats['status2xx'],
'status3xx' => self::$stats['status3xx'],
'status4xx' => self::$stats['status4xx'],
'status5xx' => self::$stats['status5xx'],
'statusCodes' => self::$statusCodes,
'phpRequests' => self::$stats['phpRequests'],
'staticRequests' => self::$stats['staticRequests'],
'bytesOut' => self::$stats['bytesOut'],
'bytesFormatted' => self::fmtBytes(self::$stats['bytesOut']),
'memory' => round(memory_get_usage(true)/1048576, 1),
'memoryPeak' => round(memory_get_peak_usage(true)/1048576, 1),
'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'fork',
'wsClients' => Q_WebSocket::clientCount(),
'wsConnections' => $wsConnections,
'wsRooms' => $wsRooms,
'activeRooms' => $activeRooms,
'connections' => count(Q_WebServer::$clients),
'topPaths' => $topFormatted,
'sparkline' => $sparkline,
'cache' => Q_WebServer_Cache::stats(),
'components' => Q_WebServer_Cache_Components::enabled()
? Q_WebServer_Cache_Components::stats() : null,
'php' => PHP_VERSION,
'os' => PHP_OS,
);
}
static function handle($client, $parsed)
{
$p = $parsed['path'];
if ($p === '/Q/dashboard' || $p === '/Q/dashboard/') {
Q_WebServer::sendResponse($client, 200, self::renderHtml($parsed), 'text/html; charset=utf-8');
return true;
}
if ($p === '/Q/stats') {
Q_WebServer::sendResponse($client, 200, json_encode(self::getStats()), 'application/json');
return true;
}
return false;
}
static function fmtUp($s) {
if ($s < 60) return "{$s}s";
$d = floor($s/86400); $h = floor(($s%86400)/3600);
$m = floor(($s%3600)/60);
if ($d > 0) return "{$d}d {$h}h {$m}m";
if ($h > 0) return "{$h}h {$m}m";
return "{$m}m ".($s%60).'s';
}
static function fmtBytes($b) {
if ($b < 1024) return $b . ' B';
if ($b < 1048576) return round($b/1024, 1) . ' KB';
if ($b < 1073741824) return round($b/1048576, 1) . ' MB';
return round($b/1073741824, 2) . ' GB';
}
static function renderHtml($parsed)
{
$stats = json_encode(self::getStats());
$recent = json_encode(array_slice(self::$recentRequests, -50));
$host = $parsed['headers']['host'] ?? 'localhost:8080';
$wsUrl = "ws://$host/Q/ws";
return <<<HTML
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Qbix Server Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--sfc:#1a1d27;--sfc2:#222533;--bdr:#2a2d3a;--txt:#e1e4ed;--dim:#6b7089;
--ac:#7c8aff;--grn:#4ade80;--yel:#fbbf24;--red:#f87171;--cyn:#22d3ee;--pur:#a78bfa}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:var(--bg);color:var(--txt);padding:24px;font-size:13px;max-width:1200px;margin:0 auto}
h1{font-size:20px;font-weight:600;margin-bottom:4px;color:var(--ac);display:flex;align-items:center;gap:10px}
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--grn);animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.sub{font-size:12px;color:var(--dim);margin-bottom:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:20px}
.card{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;padding:14px}
.card .l{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
.card .v{font-size:22px;font-weight:700;line-height:1.2}
.card .s{font-size:11px;color:var(--dim);margin-top:4px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
@media(max-width:700px){.row{grid-template-columns:1fr}}
.panel{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;overflow:hidden}
.ph{padding:10px 14px;border-bottom:1px solid var(--bdr);font-weight:600;font-size:12px;
display:flex;justify-content:space-between;align-items:center}
.pb{padding:8px 14px;max-height:260px;overflow-y:auto}
.spark{height:40px;display:flex;align-items:flex-end;gap:1px;margin:8px 14px}
.spark div{flex:1;background:var(--ac);border-radius:1px 1px 0 0;min-height:1px;opacity:.7;transition:height .3s}
.le{padding:3px 14px;font-size:12px;display:flex;gap:10px;border-bottom:1px solid rgba(255,255,255,.03);font-family:'SF Mono','Fira Code',Consolas,monospace}
.le:hover{background:rgba(255,255,255,.03)}
.lt{color:var(--dim);min-width:58px}.ls{min-width:28px;font-weight:700;text-align:right}
.lm{min-width:42px;color:var(--cyn)}.lu{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ld{color:var(--dim);min-width:54px;text-align:right}
.s2{color:var(--grn)}.s3{color:var(--yel)}.s4,.s5{color:var(--red)}
.tp{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid rgba(255,255,255,.03)}
.tp .p{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:'SF Mono',monospace}
.tp .c{min-width:50px;text-align:right;color:var(--ac)}.tp .a{min-width:50px;text-align:right;color:var(--dim)}
.bar{height:4px;border-radius:2px;margin-top:3px}
.ws{display:inline-flex;align-items:center;gap:6px;font-size:11px}
.wd{width:6px;height:6px;border-radius:50%;background:var(--red)}.wd.on{background:var(--grn)}
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
.pill.g{background:rgba(74,222,128,.15);color:var(--grn)}
.pill.y{background:rgba(251,191,36,.15);color:var(--yel)}
.pill.r{background:rgba(248,113,113,.15);color:var(--red)}
.pill.b{background:rgba(124,138,255,.15);color:var(--ac)}
.room{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
.room .n{font-family:'SF Mono',monospace;color:var(--pur)}
</style></head><body>
<h1><span class="dot"></span>Qbix Server <span style="font-size:12px;color:var(--dim);font-weight:400" id="ver"></span></h1>
<div class="sub"><span id="up"></span> · PHP <span id="phpv"></span> · <span id="os"></span> · <span class="ws"><span class="wd" id="wd"></span><span id="wl">connecting</span></span></div>
<div class="grid">
<div class="card"><div class="l">Total requests</div><div class="v" id="sr">0</div><div class="s" id="srps">0 avg req/s</div></div>
<div class="card"><div class="l">Current RPS</div><div class="v" id="crps" style="color:var(--cyn)">0</div><div class="s">last 5 sec</div></div>
<div class="card"><div class="l">Avg response</div><div class="v" id="avg">0<span style="font-size:12px;font-weight:400">ms</span></div><div class="s">slowest: <span id="slow">0ms</span></div></div>
<div class="card"><div class="l">Memory</div><div class="v" id="sm">—</div><div class="s">peak <span id="smp">—</span></div></div>
<div class="card"><div class="l">Workers</div><div class="v" id="sw">—</div><div class="s" id="phpn">0 PHP / 0 static</div></div>
<div class="card"><div class="l">WebSocket</div><div class="v" id="wsc" style="color:var(--pur)">0</div><div class="s"><span id="wsr">0</span> rooms</div></div>
<div class="card"><div class="l">Data out</div><div class="v" id="bout">0</div><div class="s" id="conn">0 connections</div></div>
<div class="card"><div class="l">Status codes</div><div class="v" style="font-size:12px;line-height:1.8">
<span class="s2" id="s2">0</span> ok · <span class="s3" id="s3">0</span> redir · <span class="s4" id="s4">0</span> 4xx · <span class="s5" id="s5">0</span> 5xx</div></div>
</div>
<div class="panel" style="margin-bottom:16px"><div class="ph">Throughput <span style="font-size:11px;color:var(--dim)">last 60s</span></div>
<div class="spark" id="spark"></div></div>
<div class="row">
<div class="panel"><div class="ph">Top paths</div><div class="pb" id="paths"></div></div>
<div class="panel"><div class="ph">Active rooms</div><div class="pb" id="rooms"><div style="color:var(--dim);padding:8px;font-size:12px">No active rooms</div></div></div>
</div>
<div class="panel"><div class="ph">Live requests <span style="font-size:11px;color:var(--dim)" id="reqc">0 total</span></div>
<div class="pb" style="max-height:50vh" id="log"></div></div>
<script>
var S=$stats,R=$recent,L=document.getElementById('log'),SP=document.getElementById('spark');
function U(s){S=s;
el('sr',s.requests.toLocaleString());
el('crps',s.currentRps);
el('avg',s.avgMs+'<span style="font-size:12px;font-weight:400">ms</span>');
el('slow',s.slowest+'ms');
el('sm',s.memory+' MB');el('smp',s.memoryPeak+' MB');
el('sw',s.workers);el('wsc',s.wsConnections);el('wsr',s.wsRooms);
el('s2',s.status2xx);el('s3',s.status3xx);el('s4',s.status4xx);el('s5',s.status5xx);
el('up','up '+s.uptime);el('phpv',s.php);el('os',s.os);
el('bout',s.bytesFormatted);el('conn',s.connections+' connections');
el('srps',(s.rps)+' avg req/s');
el('phpn',s.phpRequests+' PHP / '+s.staticRequests+' static');
el('reqc',s.requests.toLocaleString()+' total');
// Sparkline
if(s.sparkline){var mx=Math.max.apply(null,s.sparkline)||1;
SP.innerHTML=s.sparkline.map(function(v){return'<div style="height:'+Math.max(1,v/mx*36)+'px" title="'+v+' req/s"></div>'}).join('')}
// Top paths
var pp=document.getElementById('paths');
if(s.topPaths&&s.topPaths.length){var mx2=s.topPaths[0].count;
pp.innerHTML=s.topPaths.map(function(p){return'<div class="tp"><span class="p">'+esc(p.path)+
'</span><span class="c">'+p.count+'</span><span class="a">'+p.avgMs+'ms</span></div>'}).join('')}
// Rooms
var rm=document.getElementById('rooms');
if(s.activeRooms&&s.activeRooms.length){rm.innerHTML=s.activeRooms.map(function(r){
return'<div class="room"><span class="n">'+esc(r.name)+'</span><span>'+r.members+' members</span></div>'}).join('')}
else{rm.innerHTML='<div style="color:var(--dim);padding:8px;font-size:12px">No active rooms</div>'}}
function el(id,v){var e=document.getElementById(id);if(e)e.innerHTML=v}
function esc(s){return s.replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function A(e){var d=document.createElement('div');d.className='le';
var c=e.status<300?'s2':e.status<400?'s3':e.status<500?'s4':'s5';
d.innerHTML='<span class="lt">'+e.time+'</span><span class="ls '+c+'">'+e.status+
'</span><span class="lm">'+e.method+'</span><span class="lu">'+
esc(e.uri)+'</span><span class="ld">'+e.ms+'ms</span>';
L.appendChild(d);if(L.children.length>200)L.removeChild(L.firstChild);L.scrollTop=L.scrollHeight}
U(S);R.forEach(A);
var ws;function C(){ws=new WebSocket('$wsUrl');
ws.onopen=function(){document.getElementById('wd').className='wd on';el('wl','live')};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='request'){A(m.entry);U(m.stats)}};
ws.onclose=function(){document.getElementById('wd').className='wd';el('wl','reconnecting');setTimeout(C,2000)}}
C();
</script></body></html>
HTML;
}
}
<?php
/**
* @module Q
*/
/**
* Request logging with file rotation for Q_WebServer.
*
* Writes access.log (Apache combined format) and error.log.
* Rotates by size (default 10MB) or by date.
*
* Config:
* "Q": { "webserver": { "log": {
* "access": "logs/access.log",
* "error": "logs/error.log",
* "maxSize": 10485760,
* "rotate": true
* }}}
*
* @class Q_WebServer_Log
*/
class Q_WebServer_Log
{
static $accessPath = null;
static $errorPath = null;
static $accessFp = null;
static $errorFp = null;
static $maxSize = 10485760; // 10MB
/**
* Initialize logging. Opens files, sets up rotation check.
* @method init
* @static
*/
static function init()
{
$config = Q_Config::get('Q', 'webserver', 'log', array());
self::$maxSize = (int) Q::ifset($config, 'maxSize', 10485760);
self::$accessPath = Q::ifset($config, 'access', null);
self::$errorPath = Q::ifset($config, 'error', null);
if (self::$accessPath) {
$dir = dirname(self::$accessPath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$accessFp = fopen(self::$accessPath, 'a');
}
if (self::$errorPath) {
$dir = dirname(self::$errorPath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$errorFp = fopen(self::$errorPath, 'a');
}
// Periodic rotation check (every 60s)
if (self::$accessPath || self::$errorPath) {
Q_Evented::repeat(60.0, function () {
Q_WebServer_Log::checkRotation();
});
}
}
/**
* Log a request in combined log format.
* @method access
* @static
*/
static function access($ip, $method, $uri, $status, $size, $referer, $ua, $ms)
{
if (!self::$accessFp) return;
$time = date('d/M/Y:H:i:s O');
$line = sprintf(
"%s - - [%s] \"%s %s HTTP/1.1\" %d %d \"%s\" \"%s\" %.1fms\n",
$ip, $time, $method, $uri, $status, $size,
$referer ?: '-', $ua ?: '-', $ms
);
fwrite(self::$accessFp, $line);
}
/**
* Log an error.
* @method error
* @static
*/
static function error($message, $context = '')
{
if (self::$errorFp) {
$time = date('Y-m-d H:i:s');
fwrite(self::$errorFp, "[$time] $message $context\n");
}
// Always echo errors to stderr
fwrite(STDERR, "[ERROR] $message $context\n");
}
/**
* Rotate log files if they exceed maxSize.
* @method checkRotation
* @static
*/
static function checkRotation()
{
if (self::$accessPath && file_exists(self::$accessPath)) {
clearstatcache(true, self::$accessPath);
if (filesize(self::$accessPath) > self::$maxSize) {
self::rotate(self::$accessPath, self::$accessFp);
self::$accessFp = fopen(self::$accessPath, 'a');
}
}
if (self::$errorPath && file_exists(self::$errorPath)) {
clearstatcache(true, self::$errorPath);
if (filesize(self::$errorPath) > self::$maxSize) {
self::rotate(self::$errorPath, self::$errorFp);
self::$errorFp = fopen(self::$errorPath, 'a');
}
}
}
static function rotate($path, &$fp)
{
if ($fp) fclose($fp);
$date = date('Y-m-d-His');
$rotated = $path . '.' . $date;
rename($path, $rotated);
// Keep last 10 rotated files
$pattern = $path . '.*';
$files = glob($pattern);
sort($files);
while (count($files) > 10) {
unlink(array_shift($files));
}
}
static function shutdown()
{
if (self::$accessFp) { fclose(self::$accessFp); self::$accessFp = null; }
if (self::$errorFp) { fclose(self::$errorFp); self::$errorFp = null; }
}
}
<?php
/**
* @module Q
*/
/**
* HTTP response header processing for Q_WebServer.
*
* Handles special headers that control server behavior
* (like nginx does), plus compression negotiation:
*
* - X-Accel-Redirect: serve a file from an internal path
* instead of sending the PHP response body. PHP checks
* permissions, sets Content-Type, then the server does
* the efficient file I/O. The header is stripped from
* the client response.
*
* - X-Accel-Buffering: yes/no — controls output buffering
*
* - X-Accel-Expires: override Cache-Control for the proxy
*
* - Content-Encoding: gzip/br negotiation based on
* Accept-Encoding and content type. For static files,
* checks for pre-compressed .gz/.br siblings first.
*
* @class Q_WebServer_Headers
*/
class Q_WebServer_Headers
{
/**
* Headers that are server directives — never sent to client.
* @property $internalHeaders
* @static
*/
static $internalHeaders = array(
'x-accel-redirect',
'x-accel-buffering',
'x-accel-charset',
);
/**
* Content types eligible for compression.
* @property $compressibleTypes
* @static
*/
static $compressibleTypes = array(
'text/html', 'text/css', 'text/plain', 'text/xml',
'text/csv', 'text/yaml',
'application/javascript', 'application/json',
'application/xml', 'application/rss+xml',
'application/atom+xml', 'image/svg+xml',
);
/**
* Minimum body size to bother compressing.
* @property $compressMinSize
* @static
*/
static $compressMinSize = 1024;
/**
* Process a response from PHP (worker pool or in-process).
* Handles X-Accel-Redirect and compression. Returns the
* final response to send to the client.
*
* @method processResponse
* @static
* @param {resource} $client Socket to write to
* @param {array} $response [status, body, headers] from PHP
* @param {array} $requestHeaders Original request headers
* (needed for Accept-Encoding)
* @return {boolean} true if response was fully handled
*/
static function processResponse($client, $response, $requestHeaders)
{
$status = $response['status'] ?? 200;
$body = $response['body'] ?? '';
$headers = $response['headers'] ?? array();
// ── X-Accel-Redirect ─────────────────────────────
// PHP script says "serve this internal file instead"
$accelPath = null;
foreach ($headers as $k => $v) {
if (strtolower($k) === 'x-accel-redirect') {
$accelPath = $v;
}
}
if ($accelPath) {
// Strip internal headers from response
$headers = self::stripInternal($headers);
// Resolve the internal path
$fsPath = self::resolveAccelPath($accelPath);
if ($fsPath && is_file($fsPath)) {
// Serve the file, keeping Content-Type and other
// headers the PHP script set
self::serveAccelFile($client, $fsPath, $headers, $requestHeaders);
return true;
}
// Path not found — send 404
Q_WebServer::sendResponse($client, 404, 'X-Accel-Redirect: file not found');
return true;
}
// ── Strip internal headers ───────────────────────
$headers = self::stripInternal($headers);
// ── Compression ──────────────────────────────────
$ct = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') $ct = $v;
}
$body = self::maybeCompress($body, $ct, $requestHeaders, $headers);
// ── Send response ────────────────────────────────
$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',
400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden',
404=>'Not Found', 405=>'Method Not Allowed',
413=>'Payload Too Large', 500=>'Internal Server Error',
502=>'Bad Gateway', 503=>'Service Unavailable',
);
$reason = $reasons[$status] ?? 'OK';
$out = "HTTP/1.1 $status $reason\r\n";
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;
}
/**
* Serve a file via X-Accel-Redirect, applying compression
* if appropriate. Merges headers the PHP script set
* (Content-Type, Cache-Control, etc.) with file serving headers.
*
* @method serveAccelFile
* @static
*/
static function serveAccelFile($client, $fsPath, $phpHeaders, $requestHeaders)
{
clearstatcache(true, $fsPath);
$size = filesize($fsPath);
$mtime = filemtime($fsPath);
$ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION));
// Start with headers from PHP, fill in defaults
$headers = $phpHeaders;
if (!self::hasHeader($headers, 'Content-Type')) {
$headers['Content-Type'] = Q_WebServer::mimeType($ext);
}
if (!self::hasHeader($headers, 'Cache-Control')) {
$headers['Cache-Control'] = 'public, max-age=0, must-revalidate';
}
// ETag / Last-Modified
$etag = '"' . dechex($mtime) . '-' . dechex($size) . '"';
$headers['ETag'] = $etag;
$headers['Last-Modified'] = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
// Check for pre-compressed version
$compressed = self::findPreCompressed($fsPath, $requestHeaders);
if ($compressed) {
$headers['Content-Encoding'] = $compressed['encoding'];
$headers['Content-Length'] = $compressed['size'];
$headers['Vary'] = 'Accept-Encoding';
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
fwrite($client, $out . "\r\n");
$fp = fopen($compressed['path'], 'rb');
while (!feof($fp)) {
$data = fread($fp, 65536);
if ($data === false || @fwrite($client, $data) === false) break;
}
fclose($fp);
return;
}
// Check if we should compress on-the-fly
$ct = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') $ct = $v;
}
$shouldCompress = self::shouldCompress($ct, $size, $requestHeaders);
if ($shouldCompress && $size < 5242880) { // < 5MB: read + compress + send
$body = file_get_contents($fsPath);
$body = self::maybeCompress($body, $ct, $requestHeaders, $headers);
$headers['Content-Length'] = strlen($body);
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
@fwrite($client, $out . "\r\n" . $body);
return;
}
// No compression — stream directly
$headers['Content-Length'] = $size;
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
fwrite($client, $out . "\r\n");
$fp = fopen($fsPath, 'rb');
while (!feof($fp)) {
$data = fread($fp, 65536);
if ($data === false || @fwrite($client, $data) === false) break;
}
fclose($fp);
}
/**
* Check for pre-compressed .gz or .br sibling files
* (like nginx gzip_static / brotli_static).
*
* @method findPreCompressed
* @static
* @param {string} $fsPath Original file path
* @param {array} $requestHeaders
* @return {array|null} [path, encoding, size] or null
*/
static function findPreCompressed($fsPath, $requestHeaders)
{
$accept = strtolower($requestHeaders['accept-encoding'] ?? '');
// Prefer brotli over gzip
if (strpos($accept, 'br') !== false) {
$brPath = $fsPath . '.br';
if (file_exists($brPath)) {
clearstatcache(true, $brPath);
// Only use if not older than original
if (filemtime($brPath) >= filemtime($fsPath)) {
return array(
'path' => $brPath,
'encoding' => 'br',
'size' => filesize($brPath)
);
}
}
}
if (strpos($accept, 'gzip') !== false) {
$gzPath = $fsPath . '.gz';
if (file_exists($gzPath)) {
clearstatcache(true, $gzPath);
if (filemtime($gzPath) >= filemtime($fsPath)) {
return array(
'path' => $gzPath,
'encoding' => 'gzip',
'size' => filesize($gzPath)
);
}
}
}
return null;
}
/**
* Maybe compress a response body (gzip on-the-fly).
* Modifies $headers by reference to add Content-Encoding + Vary.
*
* @method maybeCompress
* @static
* @param {string} $body
* @param {string} $contentType
* @param {array} $requestHeaders
* @param {array} &$headers Response headers (modified)
* @return {string} Possibly compressed body
*/
static function maybeCompress($body, $contentType, $requestHeaders, &$headers)
{
if (!self::shouldCompress($contentType, strlen($body), $requestHeaders)) {
return $body;
}
$accept = strtolower($requestHeaders['accept-encoding'] ?? '');
// Try gzip (universally supported, no ext needed)
if (strpos($accept, 'gzip') !== false && function_exists('gzencode')) {
$compressed = gzencode($body, 6);
if ($compressed !== false && strlen($compressed) < strlen($body)) {
$headers['Content-Encoding'] = 'gzip';
$headers['Vary'] = 'Accept-Encoding';
return $compressed;
}
}
return $body;
}
/**
* Check whether a response should be compressed.
*
* @method shouldCompress
* @static
* @param {string} $contentType
* @param {integer} $bodySize
* @param {array} $requestHeaders
* @return {boolean}
*/
static function shouldCompress($contentType, $bodySize, $requestHeaders)
{
if ($bodySize < self::$compressMinSize) return false;
if (empty($requestHeaders['accept-encoding'])) return false;
// Check content type (strip charset parameter)
$baseType = strtolower(strtok($contentType, ';'));
return in_array($baseType, self::$compressibleTypes);
}
/**
* Resolve an X-Accel-Redirect path to a filesystem path.
*
* Supports two patterns:
* /Q/internal/... → maps to configured internal directory
* /absolute/path → maps relative to document root
*
* Config: Q.webserver.accel.mappings = { "/Q/internal": "/path/on/disk" }
*
* @method resolveAccelPath
* @static
* @param {string} $accelPath The X-Accel-Redirect value
* @return {string|null} Filesystem path or null
*/
static function resolveAccelPath($accelPath)
{
// 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) {
$relative = substr($accelPath, strlen($prefix));
$fsPath = rtrim($diskPath, DS) . DS
. ltrim(str_replace('/', DS, $relative), DS);
$real = realpath($fsPath);
if ($real && strpos($real, realpath($diskPath)) === 0) {
return $real;
}
return null;
}
}
// 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;
}
}
return null;
}
/**
* Strip internal/server-directive headers from a response.
*
* @method stripInternal
* @static
* @param {array} $headers
* @return {array} Cleaned headers
*/
static function stripInternal($headers)
{
$result = array();
foreach ($headers as $k => $v) {
if (!in_array(strtolower($k), self::$internalHeaders)) {
$result[$k] = $v;
}
}
return $result;
}
/**
* Check if a header exists (case-insensitive).
*/
static function hasHeader($headers, $name)
{
$lower = strtolower($name);
foreach ($headers as $k => $v) {
if (strtolower($k) === $lower) return true;
}
return false;
}
}
<?php
/**
* @module Q
*/
/**
* WebSocket server for Q_Evented loops.
*
* Handles RFC 6455 WebSocket protocol: upgrade handshake,
* frame encoding/decoding, ping/pong, channels, broadcast.
*
* Two types of worker processes:
* - Connection workers: one per WebSocket connection (user isolation)
* - Room workers: one per active room (shared ephemeral state)
*
* @class Q_WebSocket
*/
class Q_WebSocket
{
const GUID = '258EAFA5-E914-47DA-95CA-5AB5DC587B41';
/** Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage] */
static $clients = array();
/** Channel/room subscriptions. channelName => [socketKey => true] */
static $channels = array();
/** Connection workers. socketKey => [pid, pipe, watcher] */
static $workers = array();
/** Room workers. roomName => [pid, pipe, watcher, members => [socketKey => true], tick => ms] */
static $roomWorkers = array();
/** Cached room patterns from config */
static $roomPatterns = null;
// ── Upgrade + framing (unchanged) ───────────────
static function upgrade($socket, $headers, $onMessage = null, $channel = null)
{
$key = $headers['sec-websocket-key'] ?? null;
if (!$key) return false;
$accept = base64_encode(sha1($key . self::GUID, true));
$resp = "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\nConnection: Upgrade\r\n"
. "Sec-WebSocket-Accept: $accept\r\n"
. "Server: QbixServer\r\n\r\n";
@fwrite($socket, $resp);
$sk = (int) $socket;
$watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) {
Q_WebSocket::onData($sk, $sock);
});
self::$clients[$sk] = array(
'socket' => $socket, 'watcher' => $watcher,
'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage
);
if ($channel) self::subscribe($sk, $channel);
return true;
}
static function onData($sk, $socket)
{
if (!isset(self::$clients[$sk])) return;
$data = @fread($socket, 65536);
if ($data === false || $data === '') {
self::disconnect($sk);
return;
}
self::$clients[$sk]['buffer'] .= $data;
while (($frame = self::decodeFrame(self::$clients[$sk]['buffer'])) !== null) {
switch ($frame['opcode']) {
case 0x1: // text
$cb = self::$clients[$sk]['onMessage'];
if ($cb) $cb($sk, $frame['payload']);
break;
case 0x2: // binary — ignore
break;
case 0x8: // close
self::disconnect($sk);
return;
case 0x9: // ping → pong
self::encodeAndSend(self::$clients[$sk]['socket'], 0xA, $frame['payload']);
break;
case 0xA: // pong — ignore
break;
}
}
}
static function decodeFrame(&$buffer)
{
$len = strlen($buffer);
if ($len < 2) return null;
$b0 = ord($buffer[0]); $b1 = ord($buffer[1]);
$opcode = $b0 & 0x0F;
$masked = ($b1 >> 7) & 1;
$payloadLen = $b1 & 0x7F;
$offset = 2;
if ($payloadLen === 126) {
if ($len < 4) return null;
$payloadLen = unpack('n', substr($buffer, 2, 2))[1];
$offset = 4;
} elseif ($payloadLen === 127) {
if ($len < 10) return null;
$payloadLen = unpack('J', substr($buffer, 2, 8))[1];
$offset = 10;
}
if ($masked) {
if ($len < $offset + 4 + $payloadLen) return null;
$mask = substr($buffer, $offset, 4);
$offset += 4;
$payload = '';
$raw = substr($buffer, $offset, $payloadLen);
for ($i = 0; $i < $payloadLen; $i++) {
$payload .= chr(ord($raw[$i]) ^ ord($mask[$i % 4]));
}
} else {
if ($len < $offset + $payloadLen) return null;
$payload = substr($buffer, $offset, $payloadLen);
}
$buffer = substr($buffer, $offset + $payloadLen);
return array('opcode' => $opcode, 'payload' => $payload);
}
// ── Sending ─────────────────────────────────────
static function send($socketKey, $data)
{
if (!isset(self::$clients[$socketKey])) return;
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $json);
}
static function broadcast($data)
{
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
foreach (self::$clients as $sk => $c) {
self::encodeAndSend($c['socket'], 0x1, $json);
}
}
static function broadcastTo($channel, $data)
{
if (!isset(self::$channels[$channel])) return;
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
foreach (self::$channels[$channel] as $sk => $_) {
if (isset(self::$clients[$sk])) {
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $json);
}
}
}
static function subscribe($sk, $channel)
{
if (!isset(self::$channels[$channel])) self::$channels[$channel] = array();
self::$channels[$channel][$sk] = true;
if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true;
// If a room worker exists for this channel, notify it
self::notifyRoomJoin($channel, $sk);
}
static function unsubscribe($sk, $channel)
{
unset(self::$channels[$channel][$sk]);
if (empty(self::$channels[$channel])) unset(self::$channels[$channel]);
if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]);
self::notifyRoomLeave($channel, $sk);
}
static function disconnect($sk)
{
if (!isset(self::$clients[$sk])) return;
self::notifyDisconnect($sk);
$w = self::$clients[$sk]['watcher'];
if ($w) Q_Evented::cancel($w);
foreach (self::$clients[$sk]['channels'] as $ch => $_) {
unset(self::$channels[$ch][$sk]);
self::notifyRoomLeave($ch, $sk);
}
@fclose(self::$clients[$sk]['socket']);
unset(self::$clients[$sk]);
}
static function encodeAndSend($socket, $opcode, $payload)
{
$len = strlen($payload);
$frame = chr(0x80 | $opcode);
if ($len < 126) {
$frame .= chr($len);
} elseif ($len < 65536) {
$frame .= chr(126) . pack('n', $len);
} else {
$frame .= chr(127) . pack('J', $len);
}
$frame .= $payload;
@fwrite($socket, $frame);
}
// ── Connection worker (process-per-socket) ──────
static function dispatchEvent($socketKey, $raw, $path = '/')
{
$msg = json_decode($raw, true);
if (!$msg || empty($msg['event'])) return;
$event = $msg['event'];
// Check if this event should go to a room worker instead
if (isset(self::$clients[$socketKey])) {
foreach (self::$clients[$socketKey]['channels'] as $ch => $_) {
if (isset(self::$roomWorkers[$ch])) {
// Forward to room worker with sender info
$msg['_socketId'] = $socketKey;
self::sendToRoomWorker($ch, $msg);
return;
}
}
}
// Default: per-connection worker
if (!isset(self::$workers[$socketKey])) {
self::spawnWorker($socketKey, $path);
}
if (!isset(self::$workers[$socketKey])) return;
$json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$packet = pack('N', strlen($json)) . $json;
$written = @fwrite(self::$workers[$socketKey]['pipe'], $packet);
if ($written === false || $written === 0) {
self::cleanupWorker($socketKey);
self::spawnWorker($socketKey, $path);
if (isset(self::$workers[$socketKey])) {
@fwrite(self::$workers[$socketKey]['pipe'], $packet);
}
}
}
static function spawnWorker($socketKey, $path)
{
if (!function_exists('pcntl_fork')) return;
$pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET;
$pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
if (!$pair) return;
$pid = pcntl_fork();
if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; }
if ($pid === 0) {
// ── CHILD: connection message loop ──
fclose($pair[0]);
$pipe = $pair[1];
Q_Socket::$_pipe = $pipe;
Q_Socket::$_socketId = $socketKey;
$connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null);
if ($connectHandler) {
Q::event($connectHandler, array(
'_socketId' => $socketKey, '_path' => $path,
'event' => '_connect', 'data' => array(),
));
Q_Socket::flush();
}
while (true) {
$header = @fread($pipe, 4);
if ($header === false || $header === '' || strlen($header) < 4) break;
$len = unpack('N', $header)[1];
if ($len <= 0 || $len > 10485760) break;
$json = '';
while (strlen($json) < $len) {
$chunk = @fread($pipe, $len - strlen($json));
if ($chunk === false || $chunk === '') break 2;
$json .= $chunk;
}
$msg = json_decode($json, true);
if (!$msg) continue;
$event = $msg['event'] ?? '';
if ($event === '_disconnect') break;
$mapped = Q_Config::get('Q', 'webserver', 'sockets', 'events', $event, $event);
Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null;
$result = null;
$params = array(
'_socketId' => $socketKey,
'_path' => $path,
'_ack' => Q_Socket::$_ack,
'event' => $event,
'data' => $msg['data'] ?? array(),
);
Q::event($mapped, $params, false, false, $result);
if (Q_Socket::$_ack !== null && $result !== null) {
Q_Socket::reply(array('ack' => Q_Socket::$_ack, 'data' => $result));
}
Q_Socket::flush();
}
$disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null);
if ($disconnectHandler) {
Q::event($disconnectHandler, array(
'_socketId' => $socketKey, 'event' => '_disconnect', 'data' => array(),
));
Q_Socket::flush();
}
fclose($pipe);
exit(0);
}
// ── PARENT ──
fclose($pair[1]);
stream_set_blocking($pair[0], false);
$ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($socketKey) {
$data = @fread($pipe, 65536);
if ($data === false || $data === '') {
Q_WebSocket::cleanupWorker($socketKey);
return;
}
$lines = explode("\n", trim($data));
foreach ($lines as $line) {
if ($line === '') continue;
$cmd = json_decode($line, true);
if ($cmd) Q_WebSocket::executeCommand($cmd);
}
});
self::$workers[$socketKey] = array(
'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher,
);
}
static function cleanupWorker($socketKey)
{
if (!isset(self::$workers[$socketKey])) return;
$w = self::$workers[$socketKey];
if ($w['watcher']) Q_Evented::cancel($w['watcher']);
@fclose($w['pipe']);
if ($w['pid'] > 0 && function_exists('posix_kill')) {
posix_kill($w['pid'], SIGTERM);
pcntl_waitpid($w['pid'], $st, WNOHANG);
}
unset(self::$workers[$socketKey]);
}
static function notifyDisconnect($socketKey)
{
if (!isset(self::$workers[$socketKey])) return;
$json = json_encode(array('event' => '_disconnect', 'data' => array()));
$packet = pack('N', strlen($json)) . $json;
@fwrite(self::$workers[$socketKey]['pipe'], $packet);
self::cleanupWorker($socketKey);
}
// ── Room workers (process-per-room) ─────────────
/**
* Get room patterns from config. Cached.
* Config format:
* Q.webserver.sockets.rooms.$pattern = {handler, tick?}
* e.g. "game/$id" => {"handler": "game/room", "tick": 100}
* @method getRoomPatterns
* @static
*/
static function getRoomPatterns()
{
if (self::$roomPatterns !== null) return self::$roomPatterns;
self::$roomPatterns = Q_Config::get('Q', 'webserver', 'sockets', 'rooms', array());
return self::$roomPatterns;
}
/**
* Check if a room name matches a configured room pattern.
* Returns the config (handler, tick) or null.
* @method matchRoomPattern
* @static
*/
static function matchRoomPattern($roomName)
{
$patterns = self::getRoomPatterns();
if (empty($patterns)) return null;
$segments = explode('/', $roomName);
foreach ($patterns as $pattern => $config) {
$pSegments = explode('/', $pattern);
if (count($pSegments) !== count($segments)) continue;
$match = true;
$params = array();
for ($i = 0; $i < count($pSegments); $i++) {
$ps = $pSegments[$i];
if (isset($ps[0]) && ($ps[0] === '$' || $ps[0] === ':')) {
$params[substr($ps, 1)] = $segments[$i];
} elseif ($ps !== $segments[$i]) {
$match = false;
break;
}
}
if ($match) {
return array_merge((array) $config, array('_params' => $params, '_pattern' => $pattern));
}
}
return null;
}
/**
* Spawn a room worker process.
* @method spawnRoomWorker
* @static
*/
static function spawnRoomWorker($roomName, $config)
{
if (!function_exists('pcntl_fork')) return;
if (isset(self::$roomWorkers[$roomName])) return;
$pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET;
$pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
if (!$pair) return;
$handler = $config['handler'] ?? '';
$tick = isset($config['tick']) ? (int) $config['tick'] : 0;
$params = $config['_params'] ?? array();
$pid = pcntl_fork();
if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; }
if ($pid === 0) {
// ── CHILD: room message loop ──
fclose($pair[0]);
$pipe = $pair[1];
Q_Socket::$_pipe = $pipe;
Q_Socket::$_socketId = 0; // room process, no single socket
// Set up tick timer if configured
$tickCallback = null;
if ($tick > 0) {
$tickCallback = function () use ($handler, $roomName, $params, $pipe) {
Q_Socket::$_ack = null;
$result = null;
$p = array_merge($params, array(
'_room' => $roomName, 'event' => '_tick',
'data' => array(), '_socketId' => 0,
));
Q::event($handler, $p, false, false, $result);
Q_Socket::flush();
};
}
// Fire _init event
$result = null;
Q::event($handler, array_merge($params, array(
'_room' => $roomName, 'event' => '_init', 'data' => array(),
'_socketId' => 0,
)), false, false, $result);
Q_Socket::flush();
// Message loop with optional tick
stream_set_blocking($pipe, false);
$lastTick = microtime(true);
while (true) {
$read = array($pipe);
$write = $except = null;
$timeout = $tick > 0 ? max(0.001, ($tick / 1000.0) - (microtime(true) - $lastTick)) : 1.0;
$ready = @stream_select($read, $write, $except, (int) $timeout,
(int) (($timeout - (int) $timeout) * 1000000));
// Tick
if ($tick > 0 && (microtime(true) - $lastTick) * 1000 >= $tick) {
$lastTick = microtime(true);
if ($tickCallback) $tickCallback();
}
if ($ready === false) break;
if ($ready === 0) continue;
// Read length-prefixed messages
$raw = @fread($pipe, 65536);
if ($raw === false || $raw === '') break;
// May contain multiple messages
$buf = $raw;
while (strlen($buf) >= 4) {
$len = unpack('N', substr($buf, 0, 4))[1];
if ($len <= 0 || $len > 10485760) { $buf = ''; break; }
if (strlen($buf) < 4 + $len) break;
$json = substr($buf, 4, $len);
$buf = substr($buf, 4 + $len);
$msg = json_decode($json, true);
if (!$msg) continue;
$event = $msg['event'] ?? '';
if ($event === '_shutdown') break 2;
Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null;
Q_Socket::$_socketId = $msg['_socketId'] ?? 0;
$result = null;
$p = array_merge($params, array(
'_room' => $roomName,
'_socketId' => Q_Socket::$_socketId,
'_ack' => Q_Socket::$_ack,
'event' => $event,
'data' => $msg['data'] ?? array(),
));
Q::event($handler, $p, false, false, $result);
if (Q_Socket::$_ack !== null && $result !== null) {
Q_Socket::send(Q_Socket::$_socketId,
array('ack' => Q_Socket::$_ack, 'data' => $result));
}
Q_Socket::flush();
}
}
// Fire _destroy event
Q::event($handler, array_merge($params, array(
'_room' => $roomName, 'event' => '_destroy', 'data' => array(),
'_socketId' => 0,
)), false, false, $result);
Q_Socket::flush();
fclose($pipe);
exit(0);
}
// ── PARENT ──
fclose($pair[1]);
stream_set_blocking($pair[0], false);
$ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($roomName) {
$data = @fread($pipe, 65536);
if ($data === false || $data === '') {
Q_WebSocket::cleanupRoomWorker($roomName);
return;
}
$lines = explode("\n", trim($data));
foreach ($lines as $line) {
if ($line === '') continue;
$cmd = json_decode($line, true);
if ($cmd) Q_WebSocket::executeCommand($cmd);
}
});
self::$roomWorkers[$roomName] = array(
'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher,
'members' => array(),
);
}
/**
* Send a message to a room worker.
* @method sendToRoomWorker
* @static
*/
static function sendToRoomWorker($roomName, $msg)
{
if (!isset(self::$roomWorkers[$roomName])) return;
$json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$packet = pack('N', strlen($json)) . $json;
@fwrite(self::$roomWorkers[$roomName]['pipe'], $packet);
}
/**
* Notify room worker when a socket joins.
* @method notifyRoomJoin
* @static
*/
static function notifyRoomJoin($channel, $socketKey)
{
$config = self::matchRoomPattern($channel);
if (!$config) return;
// Spawn room worker if not running
if (!isset(self::$roomWorkers[$channel])) {
self::spawnRoomWorker($channel, $config);
}
if (!isset(self::$roomWorkers[$channel])) return;
self::$roomWorkers[$channel]['members'][$socketKey] = true;
self::sendToRoomWorker($channel, array(
'event' => '_join', 'data' => array(),
'_socketId' => $socketKey,
));
}
/**
* Notify room worker when a socket leaves.
* @method notifyRoomLeave
* @static
*/
static function notifyRoomLeave($channel, $socketKey)
{
if (!isset(self::$roomWorkers[$channel])) return;
unset(self::$roomWorkers[$channel]['members'][$socketKey]);
self::sendToRoomWorker($channel, array(
'event' => '_leave', 'data' => array(),
'_socketId' => $socketKey,
));
// Shut down room if empty
if (empty(self::$roomWorkers[$channel]['members'])) {
self::sendToRoomWorker($channel, array(
'event' => '_shutdown', 'data' => array(),
));
self::cleanupRoomWorker($channel);
}
}
/**
* Clean up a room worker.
* @method cleanupRoomWorker
* @static
*/
static function cleanupRoomWorker($roomName)
{
if (!isset(self::$roomWorkers[$roomName])) return;
$w = self::$roomWorkers[$roomName];
if ($w['watcher']) Q_Evented::cancel($w['watcher']);
@fclose($w['pipe']);
if ($w['pid'] > 0 && function_exists('posix_kill')) {
posix_kill($w['pid'], SIGTERM);
pcntl_waitpid($w['pid'], $st, WNOHANG);
}
unset(self::$roomWorkers[$roomName]);
}
// ── In-process fallback (Windows) ───────────────
static function dispatchEventInProcess($eventName, $params, $socketKey, $ack)
{
Q_Socket::$_directMode = true;
Q_Socket::$_socketId = $socketKey;
Q_Socket::$_ack = $ack;
$result = null;
Q::event($eventName, $params, false, false, $result);
if ($ack !== null && $result !== null) {
self::send($socketKey, array('ack' => $ack, 'data' => $result));
}
Q_Socket::$_directMode = false;
}
// ── IPC command execution ───────────────────────
static function executeCommand($cmd)
{
switch ($cmd['cmd'] ?? '') {
case 'send':
self::send($cmd['socketId'], $cmd['data']);
break;
case 'broadcast':
self::broadcastTo($cmd['room'], $cmd['data']);
break;
case 'broadcastAll':
self::broadcast($cmd['data']);
break;
case 'join':
self::subscribe($cmd['socketId'], $cmd['room']);
break;
case 'leave':
self::unsubscribe($cmd['socketId'], $cmd['room']);
break;
}
}
}
<?php
/**
* @module Q
*/
/**
* Represents an internal URI, routed from a URL via config patterns.
* Compatible subset of the full Qbix Platform's Q_Uri class.
* Uses the same config format, pattern syntax, and compiled-pattern caching.
*
* @class Q_Uri
*/
class Q_Uri
{
/**
* @property $fields
* @type array
*/
public $fields = array();
/**
* @property $route
* @type string|null
*/
public $route = null;
protected $querystring = null;
protected $anchorstring = null;
/**
* Variable prefixes recognised in route patterns.
* Supports both $var and :var (same as Platform).
* @property $variablePrefixes
* @static
*/
public static $variablePrefixes = array('$', ':');
public static $escapedVariablePrefixes = array('\$', '\:');
/**
* Memoized compiled patterns, merged routes, and path→URI cache.
* Survives across forked children via COW — parent compiles once.
*/
protected static $compiledPatterns = array();
protected static $routesCache = null;
protected static $pathCache = array();
function __get($name)
{
return $this->fields[$name] ?? null;
}
function __set($name, $value)
{
$this->fields[$name] = $value;
}
function __isset($name)
{
return isset($this->fields[$name]);
}
function toArray()
{
return $this->fields;
}
/**
* Create a Q_Uri from an array of fields.
* @method from
* @static
*/
static function from($fields)
{
$uri = new self();
if (is_array($fields)) {
$uri->fields = $fields;
}
return $uri;
}
/**
* Get merged routes from Q/routes@start, Q/routes, Q/routes@end.
* Same merge order as the full Platform. Memoized.
* @method getRoutes
* @static
* @return {array}
*/
static function getRoutes()
{
if (isset(self::$routesCache)) {
return self::$routesCache;
}
$routesStart = Q_Config::get('Q', 'routes@start', array());
$routes = Q_Config::get('Q', 'routes', array());
$routesEnd = Q_Config::get('Q', 'routes@end', array());
// Reverse order within each block (later plugins override earlier)
$result = array();
foreach (array($routesStart, $routes, $routesEnd) as $source) {
if (!is_array($source)) continue;
$keys = array_keys($source);
$vals = array_values($source);
$keys = array_reverse($keys);
$vals = array_reverse($vals);
foreach ($keys as $i => $k) {
if (!isset($result[$k])) {
$result[$k] = $vals[$i];
}
}
}
self::$routesCache = $result;
return $result;
}
/**
* Clear all memoized routing state.
* Call when config changes (e.g. --hot reload).
* @method clearRouteCache
* @static
*/
static function clearRouteCache()
{
self::$routesCache = null;
self::$compiledPatterns = array();
self::$pathCache = array();
}
/**
* Route a URL path to a Q_Uri using configured routes.
* Results are memoized — the same path always returns the same URI.
* @method fromPath
* @static
* @param {string} $path URL path (e.g. "api/users/42")
* @return {Q_Uri|null}
*/
static function fromPath($path)
{
$path = trim($path, '/');
if (isset(self::$pathCache[$path])) {
return self::$pathCache[$path];
}
$segments = $path !== '' ? explode('/', $path) : array();
$routes = self::getRoutes();
if (empty($routes)) {
self::$pathCache[$path] = null;
return null;
}
foreach ($routes as $pattern => $fields) {
if (!isset($fields)) continue; // disabled route
$matched = self::matchSegments($pattern, $segments);
if ($matched === false) continue;
// Check regex constraints on matched values
$valid = true;
foreach ($matched as $k => $v) {
if (isset($fields[$k]) && is_string($fields[$k])) {
if (!preg_match('/' . $fields[$k] . '/', $v)) {
$valid = false;
break;
}
}
}
// Special condition handler (same as Platform)
if ($valid && !empty($fields[''])) {
$params = array(
'uriFields' => $matched,
'routeFields' => $fields,
'fields' => array_merge($fields, $matched),
'pattern' => $pattern,
);
if (false === Q::event($fields[''], $params, false, false, $params)) {
$valid = false;
}
}
if (!$valid) continue;
// Merge route defaults with matched values
$uriFields = array();
foreach ($fields as $k => $v) {
if ($k === '' || is_int($k)) continue;
$uriFields[$k] = $v;
}
$uriFields = array_merge($uriFields, $matched);
$uri = new self();
$uri->fields = $uriFields;
$uri->route = $pattern;
self::$pathCache[$path] = $uri;
return $uri;
}
self::$pathCache[$path] = null;
return null;
}
/**
* Compile a route pattern into a reusable structure.
* Same implementation as the full Platform's Q_Uri::compilePattern().
* Memoized by pattern string — compiled once, reused forever.
* @method compilePattern
* @static
* @protected
*/
protected static function compilePattern($pattern)
{
if (isset(self::$compiledPatterns[$pattern])) {
return self::$compiledPatterns[$pattern];
}
$route_segments = explode('/', $pattern);
$tailArray = false;
$tailField = null;
$valid = true;
if (substr($pattern, -2) === '[]') {
$tailArray = true;
$last_rs = end($route_segments);
if (!isset($last_rs[0]) || !in_array($last_rs[0], self::$variablePrefixes)) {
$valid = false;
} else {
$tailField = substr($last_rs, 1, -2);
}
$route_segments = array_slice($route_segments, 0, -1);
}
$segments = array();
foreach ($route_segments as $rs) {
$rs_parts = explode('.', $rs);
$parts = array();
foreach ($rs_parts as $part) {
if (!isset($part[0]) || !in_array($part[0], self::$variablePrefixes)) {
$parts[] = array(
'var' => false,
'literal' => str_replace(
self::$escapedVariablePrefixes,
self::$variablePrefixes,
$part
)
);
} else {
$parts[] = array(
'var' => true,
'field' => substr($part, 1)
);
}
}
$segments[] = $parts;
}
$compiled = array(
'valid' => $valid,
'segments' => $segments,
'count' => count($segments),
'tailArray' => $tailArray,
'tailField' => $tailField,
);
self::$compiledPatterns[$pattern] = $compiled;
return $compiled;
}
/**
* Match URL segments against a compiled route pattern.
* Same implementation as the full Platform's Q_Uri::matchSegments().
* @method matchSegments
* @static
* @protected
*/
protected static function matchSegments($pattern, $segments)
{
if (!$pattern && $pattern !== '0') {
return count($segments) === 0 ? array() : false;
}
$compiled = self::compilePattern($pattern);
if (!$compiled['valid']) return false;
$count = $compiled['count'];
$segCount = count($segments);
if ($compiled['tailArray']) {
if ($count >= $segCount) return false;
} else {
if ($count !== $segCount) return false;
}
$args = array();
$cs = $compiled['segments'];
for ($i = 0; $i < $count; $i++) {
$rs_parts = $cs[$i];
$rs_parts_count = count($rs_parts);
$segment = urldecode($segments[$i]);
$s_parts = explode('.', $segment, $rs_parts_count);
if (count($s_parts) < $rs_parts_count) return false;
for ($j = 0; $j < $rs_parts_count; $j++) {
$p = $rs_parts[$j];
if (!$p['var']) {
if ($s_parts[$j] !== $p['literal']) return false;
continue;
}
$args[$p['field']] = $s_parts[$j];
}
}
if ($compiled['tailArray']) {
$args[$compiled['tailField']] = array();
for (; $i < $segCount; $i++) {
$args[$compiled['tailField']][] = urldecode($segments[$i]);
}
}
return $args;
}
}
<?php
/**
* @module Q
*/
/**
* Non-blocking event loop for Q. Timers, stream watchers,
* deferred callbacks, signal handling. Built-in stream_select
* driver. Optional Revolt driver when amphp/ReactPHP installed.
* @class Q_Evented
*/
class Q_Evented
{
static function onReadable($stream, callable $cb) { return self::driver()->onReadable($stream, $cb); }
static function onWritable($stream, callable $cb) { return self::driver()->onWritable($stream, $cb); }
static function delay($sec, callable $cb) { return self::driver()->delay($sec, $cb); }
static function repeat($sec, callable $cb) { return self::driver()->repeat($sec, $cb); }
static function defer(callable $cb) { return self::driver()->defer($cb); }
static function onSignal($sig, callable $cb) { return self::driver()->onSignal($sig, $cb); }
static function cancel($id) { self::driver()->cancel($id); }
static function disable($id) { self::driver()->disable($id); }
static function enable($id) { self::driver()->enable($id); }
static function run() { self::driver()->run(); }
static function tick($timeout = 0) { self::driver()->tick($timeout); }
static function stop() { self::driver()->stop(); }
static function running() { return self::driver()->running(); }
static function driver()
{
if (!self::$driver) {
self::$driver = class_exists('Revolt\\EventLoop')
? new Q_Evented_Revolt()
: new Q_Evented_StreamSelect();
}
return self::$driver;
}
static function setDriver(Q_Evented_Driver $d) { self::$driver = $d; }
protected static $driver = null;
}
<?php
/**
* @module Q
*/
/**
* Pure-PHP web server for Qbix apps.
*
* Serves static files with readfile(), ETag/304, companion .headers.
* Routes .php scripts to pre-forked workers (Q_WebServer_Pool).
* Upgrades WebSocket connections via Q_WebSocket.
* Responsive directory listings with media previews.
* Runs entirely on Q_Evented.
*
* @class Q_WebServer
*/
class Q_WebServer
{
/** @property $pool Q_WebServer_Pool|null */
static $pool = null;
/** @property $rootDir Document root with trailing DS */
public static $rootDir;
/** @property $host Bound host */
public static $host;
/** @property $port Bound port */
public static $port;
/** @property $onRequest Logging callback(method, uri, status, ms) */
static $onRequest = null;
// ── Lifecycle ────────────────────────────────────────
/**
* @method start
* @static
* @param {string} $dir Document root
* @param {string} [$host='0.0.0.0']
* @param {int} [$port=8080]
* @param {int} [$workers=0] 0=in-process, N=prefork pool
*/
static function start($dir, $host = '0.0.0.0', $port = 8080, $workers = 0)
{
if (self::$running) {
throw new Exception("Q_WebServer already running");
}
// Ignore SIGPIPE — prevents crash when writing to a closed socket
// (e.g. client disconnects, or worker dies mid-response)
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGPIPE, SIG_IGN);
}
$root = realpath($dir);
if (!$root || !is_dir($root)) {
throw new Exception("Invalid document root: $dir");
}
self::$rootDir = rtrim(str_replace(array('/','\\'), DS, $root), DS) . DS;
self::$host = $host;
self::$port = $port;
if ($ext = Q_Config::get('Q', 'webserver', 'extensions', null)) {
self::$allowedExtensions = $ext;
}
// File response cache config
self::$fileCacheMaxSize = Q_Config::get('Q', 'webserver', 'fileCache', 'maxSize', 67108864);
self::$fileCacheMaxFile = Q_Config::get('Q', 'webserver', 'fileCache', 'maxFile', 1048576);
self::$fileCacheCheckInterval = Q_Config::get('Q', 'webserver', 'fileCache', 'checkInterval', 1);
// ── HTTP listener ────────────────────────────────
$errno = $errstr = 0;
self::$socket = stream_socket_server(
"tcp://{$host}:{$port}", $errno, $errstr,
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
);
if (!self::$socket) {
throw new Exception("Could not bind to {$host}:{$port} — $errstr");
}
stream_set_blocking(self::$socket, false);
self::$acceptWatcher = Q_Evented::onReadable(
self::$socket, array(__CLASS__, 'onAccept')
);
// ── HTTPS listener (if certs configured) ─────────
$httpsConfig = Q_Config::get('Q', 'web', 'https', array());
$httpsPort = (int) Q::ifset($httpsConfig, 'port', 0);
if ($httpsPort || Q::ifset($httpsConfig, 'mode', '')) {
if (!$httpsPort) $httpsPort = 443;
self::$httpsPort = $httpsPort;
$domain = Q::ifset($httpsConfig, 'domain', '');
$certsReady = Q_WebServer_Certs::init($domain);
if ($certsReady) {
self::startTls($host, $httpsPort);
} else {
echo "[HTTPS] No valid certs yet, HTTPS disabled. "
. "HTTP still running on port $port.\n";
}
}
// ── Preload classes (before forking) ─────────────
$preload = Q_Config::get('Q', 'webserver', 'preload', array());
if (!empty($preload)) {
// Load the autoloader first (e.g. Composer's)
$autoload = is_string($preload)
? $preload
: (isset($preload['autoload']) ? $preload['autoload'] : null);
if ($autoload) {
$autoloadPath = $autoload;
// Resolve relative to the document root's parent (project root)
if ($autoloadPath[0] !== '/' && $autoloadPath[0] !== '\\') {
$projectRoot = dirname(rtrim(self::$rootDir, DS));
$autoloadPath = $projectRoot . DS . $autoloadPath;
}
if (file_exists($autoloadPath)) {
require_once $autoloadPath;
$count = count(get_declared_classes());
echo " Autoloader: " . basename($autoload) . "\n";
} else {
echo " Warning: autoload file not found: $autoloadPath\n";
}
}
// Then load each named class (triggers the autoloader)
$classes = isset($preload['classes']) ? $preload['classes'] : array();
if (!empty($classes)) {
$loaded = 0;
foreach ($classes as $class) {
if (!class_exists($class, true) && !interface_exists($class, true)
&& !trait_exists($class, true)
) {
echo " Warning: could not preload $class\n";
} else {
$loaded++;
}
}
echo " Preloaded: $loaded classes\n";
}
}
// ── Worker pool ──────────────────────────────────
if ($workers > 0 && function_exists('pcntl_fork')) {
self::$pool = new Q_WebServer_Pool($workers);
}
self::$running = true;
}
/**
* Start or restart the TLS listener.
* Uses tcp:// + stream_socket_enable_crypto() for non-blocking
* TLS handshake. The handshake happens per-connection in the
* event loop, not during accept.
*
* @method startTls
* @static
*/
static function startTls($host, $port)
{
if (self::$tlsWatcher) {
Q_Evented::cancel(self::$tlsWatcher);
self::$tlsWatcher = null;
}
if (self::$tlsSocket) {
@fclose(self::$tlsSocket);
self::$tlsSocket = null;
}
if (!Q_WebServer_Certs::validateCerts()) return;
// Listen on plain tcp:// — TLS handshake happens after accept
$errno = $errstr = 0;
self::$tlsSocket = stream_socket_server(
"tcp://{$host}:{$port}", $errno, $errstr,
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
);
if (!self::$tlsSocket) {
echo "[HTTPS] Could not bind to {$host}:{$port} — $errstr\n";
return;
}
stream_set_blocking(self::$tlsSocket, false);
self::$tlsWatcher = Q_Evented::onReadable(
self::$tlsSocket,
function ($sock) { Q_WebServer::onAcceptTls($sock); }
);
echo "[HTTPS] Listening on https://{$host}:{$port}\n";
}
/**
* Accept a connection on the TLS port and begin
* non-blocking crypto handshake.
*
* @method onAcceptTls
* @static
*/
static function onAcceptTls($serverSocket)
{
$client = @stream_socket_accept($serverSocket, 0);
if (!$client) return;
stream_set_blocking($client, false);
// Set SSL context options on this specific socket
$certPath = Q_WebServer_Certs::$certPath;
$keyPath = Q_WebServer_Certs::$keyPath;
stream_context_set_option($client, 'ssl', 'local_cert', $certPath);
stream_context_set_option($client, 'ssl', 'local_pk', $keyPath);
stream_context_set_option($client, 'ssl', 'allow_self_signed', true);
stream_context_set_option($client, 'ssl', 'verify_peer', false);
$key = (int) $client;
self::$clients[$key] = $client;
self::$buffers[$key] = '';
self::$tlsPending[$key] = true;
// Start the handshake — may need multiple attempts
self::continueTlsHandshake($key);
}
/**
* Continue a non-blocking TLS handshake.
* stream_socket_enable_crypto() returns:
* true → handshake complete
* false → handshake failed
* 0 → handshake in progress, try again
*
* @method continueTlsHandshake
* @static
*/
static function continueTlsHandshake($key)
{
if (!isset(self::$clients[$key])) return;
$client = self::$clients[$key];
$cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_SERVER;
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) {
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_SERVER;
}
$result = @stream_socket_enable_crypto($client, true, $cryptoMethod);
if ($result === true) {
// Handshake complete — treat like a normal client
unset(self::$tlsPending[$key]);
self::$clientWatchers[$key] = Q_Evented::onReadable(
$client,
function ($c) { Q_WebServer::onClientData($c); }
);
} elseif ($result === 0) {
// In progress — watch for readability to retry
self::$clientWatchers[$key] = Q_Evented::onReadable(
$client,
function ($c) {
$k = (int) $c;
// Cancel this watcher and retry handshake
if (isset(Q_WebServer::$clientWatchers[$k])) {
Q_Evented::cancel(Q_WebServer::$clientWatchers[$k]);
unset(Q_WebServer::$clientWatchers[$k]);
}
Q_WebServer::continueTlsHandshake($k);
}
);
} else {
// Failed
self::closeClient($key);
}
}
/**
* Reload TLS after cert renewal. Called by Q_WebServer_Certs.
* New connections will use the new certs. Existing connections
* keep their old certs until they close (normal behavior).
*
* @method reloadTls
* @static
*/
static function reloadTls()
{
if (self::$httpsPort) {
// No need to restart the listener — we set SSL context
// per-connection in onAcceptTls, so new connections
// will pick up the new cert files automatically.
echo "[HTTPS] Certificates reloaded for new connections.\n";
}
}
/**
* Graceful shutdown: stop accepting new connections,
* wait for in-flight requests to complete (up to timeout),
* then close everything.
* @method stop
* @static
* @param {float} $drainTimeout Max seconds to wait for in-flight requests
*/
static function stop($drainTimeout = 5.0)
{
if (!self::$running) return;
self::$running = false;
// 1. Stop accepting new connections
if (self::$acceptWatcher) {
Q_Evented::cancel(self::$acceptWatcher);
self::$acceptWatcher = null;
}
if (self::$tlsWatcher) {
Q_Evented::cancel(self::$tlsWatcher);
self::$tlsWatcher = null;
}
if (self::$socket) { @fclose(self::$socket); self::$socket = null; }
if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; }
// 2. Wait for in-flight connections to drain (up to timeout)
$deadline = microtime(true) + $drainTimeout;
while (!empty(self::$clients) && microtime(true) < $deadline) {
Q_Evented::tick(0.1); // process pending I/O briefly
}
// 3. Force-close remaining connections
foreach (self::$timeoutWatchers as $id) Q_Evented::cancel($id);
self::$timeoutWatchers = array();
foreach (self::$clientWatchers as $id) Q_Evented::cancel($id);
self::$clientWatchers = array();
foreach (self::$clients as $c) @fclose($c);
self::$clients = array();
self::$buffers = array();
self::$clientInfo = array();
self::$keepAliveCount = array();
// 4. Disconnect WebSockets
Q_WebSocket::disconnectAll();
// 5. Gracefully shut down worker pool (SIGTERM → wait → SIGKILL)
if (self::$pool) { self::$pool->shutdown(); self::$pool = null; }
}
static function run()
{
if (!self::$running) return;
if (function_exists('pcntl_signal')) {
Q_Evented::onSignal(SIGINT, function () {
echo "\n Graceful shutdown (SIGINT)...\n";
self::stop();
Q_Evented::stop();
});
Q_Evented::onSignal(SIGTERM, function () {
echo "\n Graceful shutdown (SIGTERM)...\n";
self::stop();
Q_Evented::stop();
});
// Reap zombie children from fork-per-request PHP execution
Q_Evented::onSignal(SIGCHLD, function () {
while (pcntl_waitpid(-1, $st, WNOHANG) > 0) {}
});
}
Q_Evented::run();
}
// ── Connection handling ──────────────────────────────
static function onAccept($socket)
{
// Max connections check (cached)
static $maxConn = null;
if ($maxConn === null) $maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024);
if (count(self::$clients) >= $maxConn) {
$reject = @stream_socket_accept($socket, 0);
if ($reject) {
@fwrite($reject, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
@fclose($reject);
}
return;
}
$client = @stream_socket_accept($socket, 0);
if (!$client) return;
stream_set_blocking($client, false);
// Disable Nagle's algorithm — eliminates 40ms delayed ACK on keep-alive
if (function_exists('socket_import_stream')) {
$rawSocket = socket_import_stream($client);
if ($rawSocket) {
socket_set_option($rawSocket, SOL_TCP, TCP_NODELAY, 1);
}
}
$key = (int) $client;
self::$clients[$key] = $client;
self::$buffers[$key] = '';
self::$keepAliveCount[$key] = 0;
// Store remote IP for logging + proxy resolution
$peer = stream_socket_get_name($client, true);
$ip = $peer ? explode(':', $peer)[0] : '0.0.0.0';
self::$clientInfo[$key] = array(
'ip' => $ip,
'connectTime' => microtime(true)
);
// Rate limit check
if (!self::checkRateLimit($ip)) {
@fwrite($client, "HTTP/1.1 429 Too Many Requests\r\n"
. "Retry-After: 60\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
@fclose($client);
unset(self::$clients[$key], self::$buffers[$key], self::$keepAliveCount[$key],
self::$clientInfo[$key]);
return;
}
self::$clientWatchers[$key] = Q_Evented::onReadable(
$client, function ($c) { Q_WebServer::onClientData($c); }
);
// Read timeout — close if no complete request within N seconds
static $readTimeout = null;
if ($readTimeout === null) $readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30);
self::$timeoutWatchers[$key] = Q_Evented::delay($readTimeout, function () use ($key) {
Q_WebServer::closeClient($key);
});
}
static function onClientData($client)
{
$key = (int) $client;
if (!isset(self::$clients[$key])) return;
// Check if we already have a complete request from pipelining
$buf = self::$buffers[$key] ?? '';
$havePipelined = ($buf !== '' && strpos($buf, "\r\n\r\n") !== false);
if (!$havePipelined) {
$chunk = @fread($client, 65536);
if ($chunk === false || $chunk === '') {
self::closeClient($key);
return;
}
self::$buffers[$key] .= $chunk;
$buf = self::$buffers[$key];
}
// Wait for complete headers
$headerEnd = strpos($buf, "\r\n\r\n");
if ($headerEnd === false) {
if (strlen($buf) > 65536) self::closeClient($key);
return;
}
// Wait for complete body on POST/PUT/PATCH
$firstChar = $buf[0];
if ($firstChar === 'P') { // POST, PUT, PATCH all start with P
$cl = 0;
if (preg_match('/content-length:\s*(\d+)/i', $buf, $m)) {
$cl = (int) $m[1];
}
if ($cl > 10485760) {
self::sendResponse($client, 413, 'Payload Too Large');
self::closeClient($key);
return;
}
if (strlen($buf) - $headerEnd - 4 < $cl) return;
}
// Cancel read timeout (request received)
if (isset(self::$timeoutWatchers[$key])) {
Q_Evented::cancel(self::$timeoutWatchers[$key]);
unset(self::$timeoutWatchers[$key]);
}
// Calculate consumed bytes for pipelining support
$headerEnd = strpos($buf, "\r\n\r\n");
$bodyLen = 0;
$firstChar = $buf[0];
if ($firstChar === 'P') { // POST/PUT/PATCH
if (preg_match('/content-length:\s*(\d+)/i', $buf, $clm)) {
$bodyLen = (int) $clm[1];
}
}
$consumed = $headerEnd + 4 + $bodyLen;
$start = microtime(true);
$parsed = self::parseRequest($buf);
// Reject malformed request lines
if (!empty($parsed['_malformed'])) {
self::sendResponse($client, 400, 'Bad Request');
self::closeClient($key);
return;
}
// Reject oversized headers (>64KB total)
$headerEnd = strpos($buf, "\r\n\r\n");
if ($headerEnd > 65536) {
self::sendResponse($client, 431, 'Request Header Fields Too Large',
'text/plain; charset=utf-8', array('Connection' => 'close'));
self::closeClient($key);
return;
}
// Resolve proxy headers for real client IP
$directIp = self::$clientInfo[$key]['ip'] ?? '0.0.0.0';
$parsed['clientIp'] = Q_WebServer_Proxy::clientIp($directIp, $parsed['headers']);
$parsed['_remoteAddr'] = $parsed['clientIp'];
$peer = stream_socket_get_name($client, true);
$parsed['_remotePort'] = $peer ? (int) substr(strrchr($peer, ':'), 1) : 0;
// Determine keep-alive before handling request
static $maxKeepAlive = null;
if ($maxKeepAlive === null) $maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100);
$connHeader = strtolower($parsed['headers']['connection'] ?? 'keep-alive');
self::$keepAliveCount[$key] = (self::$keepAliveCount[$key] ?? 0) + 1;
$parsed['_keepAlive'] = ($connHeader !== 'close')
&& self::$keepAliveCount[$key] < $maxKeepAlive;
try {
$keepOpen = self::handleRequest($client, $parsed);
} catch (\Throwable $e) {
// Never let a request crash the event loop
$msg = htmlspecialchars($e->getMessage());
self::sendResponse($client, 500, "Internal Server Error: $msg");
self::closeClient($key);
$ms = round((microtime(true) - $start) * 1000, 1);
Q_WebServer_Dashboard::recordRequest(
$parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms
);
if (self::$onRequest) {
(self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms);
}
return;
}
$ms = round((microtime(true) - $start) * 1000, 1);
if ($keepOpen) {
// WebSocket upgraded — Q_WebSocket owns this socket now
if (isset(self::$clientWatchers[$key])) {
Q_Evented::cancel(self::$clientWatchers[$key]);
}
unset(self::$clientWatchers[$key], self::$clients[$key],
self::$buffers[$key], self::$clientInfo[$key],
self::$keepAliveCount[$key]);
return;
}
// Stats + logging
Q_WebServer_Dashboard::recordRequest(
$parsed['method'], $parsed['uri'], self::$lastStatus, $ms
);
if (self::$onRequest) {
(self::$onRequest)($parsed['method'], $parsed['uri'], self::$lastStatus, $ms);
}
// Log to file
$bodyLen = strlen(self::$lastBody ?? '');
Q_WebServer_Log::access(
$parsed['clientIp'], $parsed['method'], $parsed['uri'],
self::$lastStatus, $bodyLen,
$parsed['headers']['referer'] ?? '',
$parsed['headers']['user-agent'] ?? '',
$ms
);
// ── Keep-alive decision ──────────────────────────
static $keepAliveTimeout = null;
if ($keepAliveTimeout === null) $keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15);
$shouldKeepAlive = !empty($parsed['_keepAlive']) && self::$lastStatus < 500;
if ($shouldKeepAlive) {
// Keep leftover data for pipelined requests
$leftover = strlen($buf) > $consumed ? substr($buf, $consumed) : '';
self::$buffers[$key] = $leftover;
// Set idle timeout — close if no new request arrives
self::$timeoutWatchers[$key] = Q_Evented::delay(
$keepAliveTimeout,
function () use ($key) {
Q_WebServer::closeClient($key);
}
);
// If there's already a complete request in the buffer, process it now
if ($leftover !== '' && strpos($leftover, "\r\n\r\n") !== false) {
Q_Evented::defer(function () use ($client) {
Q_WebServer::onClientData($client);
});
}
} else {
self::closeClient($key);
}
}
// ── Request routing ──────────────────────────────────
/**
* Route a parsed request and return a response array.
*
* This is the clean interface that external HTTP drivers
* (like amphp/http-server) call. Handles all routing:
* blocked paths, static files, PHP dispatch, directory
* listings, X-Accel-Redirect, compression.
*
* The built-in server uses handleRequest() which writes
* directly to sockets. amphp calls route() and converts
* the response to its own format.
*
* @method route
* @static
* @param {array} $parsed [method, uri, path, query, headers, body, clientIp]
* @return {array} [status, headers, body]
*/
static function route($parsed)
{
$method = $parsed['method'];
$path = $parsed['path'];
// Reverse cache check (before any dispatch)
$cached = Q_WebServer_Cache::get($parsed);
if ($cached) return $cached;
if ($path === '/Q/health') {
$stats = Q_WebServer_Dashboard::getStats();
return array('status'=>200, 'body'=>json_encode(array('status'=>'ok')+$stats),
'headers'=>array('Content-Type'=>'application/json'));
}
if ($path === '/Q/dashboard' || $path === '/Q/dashboard/') {
return array('status'=>200, 'body'=>Q_WebServer_Dashboard::renderHtml($parsed),
'headers'=>array('Content-Type'=>'text/html; charset=utf-8'));
}
if (self::isBlocked($path)) {
return array('status'=>403, 'body'=>'Forbidden',
'headers'=>array('Content-Type'=>'text/plain'));
}
$fsPath = self::resolveStatic($path);
// Directory
if ($fsPath && is_dir($fsPath)) {
if (substr($path, -1) !== '/') {
return array('status'=>301, 'body'=>'',
'headers'=>array('Location'=>$path.'/'));
}
foreach (array('index.html','index.php') as $idx) {
$ip = $fsPath.DS.$idx;
if (is_file($ip)) { $fsPath = $ip; break; }
}
if (is_dir($fsPath)) {
if (self::isIndexed($path)) {
return array('status'=>200,
'body'=>self::renderDirectoryListing($fsPath, $path),
'headers'=>array('Content-Type'=>'text/html; charset=utf-8',
'Cache-Control'=>'no-store'));
}
return array('status'=>403, 'body'=>'Forbidden',
'headers'=>array('Content-Type'=>'text/plain'));
}
}
// File
if ($fsPath && is_file($fsPath)) {
$ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION));
if ($ext === 'php') {
// PHP dispatch (in-process — amphp uses fibers for concurrency)
$response = self::dispatchToQ($parsed);
$response = self::processPhpResponse($response, $parsed['headers']);
Q_WebServer_Cache::put($parsed, $response);
return $response;
}
if (in_array($ext, self::$allowedExtensions)
&& ($method === 'GET' || $method === 'HEAD')
) {
return self::buildFileResponse($fsPath, $ext, $method, $parsed['headers']);
}
}
// Clean URL → index.php
if (is_file(self::$rootDir . 'index.php')) {
$response = self::dispatchToQ($parsed);
$response = self::processPhpResponse($response, $parsed['headers']);
Q_WebServer_Cache::put($parsed, $response);
return $response;
}
return array('status'=>404, 'body'=>self::render404($path),
'headers'=>array('Content-Type'=>'text/html; charset=utf-8'));
}
/**
* Process a PHP response: X-Accel-Redirect + compression.
* Used by both route() and handlePhp().
*/
static function processPhpResponse($response, $reqHeaders)
{
$headers = Q_WebServer_Headers::stripInternal($response['headers'] ?? array());
$body = $response['body'] ?? '';
// X-Accel-Redirect
foreach ($response['headers'] ?? array() as $k => $v) {
if (strtolower($k) === 'x-accel-redirect') {
$af = Q_WebServer_Headers::resolveAccelPath($v);
if ($af && is_file($af)) {
$body = file_get_contents($af);
$ext = strtolower(pathinfo($af, PATHINFO_EXTENSION));
if (!Q_WebServer_Headers::hasHeader($headers, 'Content-Type')) {
$headers['Content-Type'] = self::mimeType($ext);
}
}
$headers = Q_WebServer_Headers::stripInternal($headers);
break;
}
}
$ct = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') $ct = $v;
}
$body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers);
return array('status'=>$response['status']??200, 'body'=>$body, 'headers'=>$headers);
}
/**
* Build a static file response with ETag/compression.
* Used by route() for amphp compatibility.
*/
static function buildFileResponse($fsPath, $ext, $method, $reqHeaders)
{
clearstatcache(true, $fsPath);
$mtime = filemtime($fsPath);
$size = filesize($fsPath);
$ct = self::mimeType($ext);
$headers = array(
'Content-Type' => $ct,
'ETag' => '"' . dechex($mtime) . '-' . dechex($size) . '"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
'Cache-Control' => 'public, max-age=0, must-revalidate'
);
$body = ($method === 'HEAD') ? '' : file_get_contents($fsPath);
if ($method !== 'HEAD') {
$body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers);
}
return array('status'=>200, 'body'=>$body, 'headers'=>$headers);
}
// ── Built-in server: socket-based routing ────────────
/**
* Route a request (built-in server).
* Returns true if the connection should stay open (WebSocket).
* @return {boolean}
*/
private static function handleRequest($client, $parsed)
{
$method = $parsed['method'];
$path = $parsed['path'];
// 1. Dashboard + Panel + WebSocket + Health (/Q/*)
if (strpos($path, '/Q/') === 0) {
if ($path === '/Q/ws') {
$upgraded = Q_WebSocket::upgrade(
$client, $parsed['headers'], null, 'dashboard'
);
return $upgraded; // true = keep open
}
if ($path === '/Q/health') {
$stats = Q_WebServer_Dashboard::getStats();
self::sendResponse($client, 200,
json_encode(array('status' => 'ok') + $stats),
'application/json');
return false;
}
// Panel (control panel + API)
$handled = Q_WebServer_Panel::handle($client, $parsed);
if ($handled) return false;
// Dashboard (live stats)
$handled = Q_WebServer_Dashboard::handle($client, $parsed);
if ($handled) return false;
}
// 2. WebSocket upgrade on any path
$upgrade = strtolower($parsed['headers']['upgrade'] ?? '');
if ($upgrade === 'websocket' && $path !== '/Q/ws') {
$upgraded = Q_WebSocket::upgrade(
$client, $parsed['headers'],
function ($sk, $msg) use ($path) {
Q_WebSocket::dispatchEvent($sk, $msg, $path);
}
);
return $upgraded;
}
// 3. Blocked paths
if (self::isBlocked($path)) {
self::sendResponse($client, 403, 'Forbidden');
return false;
}
// 3. Component cache check (Merkle tree — serves from cached slots)
if (Q_WebServer_Cache_Components::enabled()) {
$pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? '');
$cachedPage = Q_WebServer_Cache_Components::getPage($pageKey);
if ($cachedPage !== null) {
self::sendResponse($client, 200, $cachedPage,
'text/html; charset=utf-8',
array('X-Cache' => 'HIT-COMPONENTS'));
return false;
}
}
// 4. Reverse cache check (before forking a worker)
$cached = Q_WebServer_Cache::get($parsed);
if ($cached) {
self::sendResponse($client, $cached['status'],
$cached['body'], $cached['headers']['Content-Type'] ?? 'text/html',
$cached['headers']);
return false;
}
// 4. Resolve filesystem path
$fsPath = self::resolveStatic($path);
// 4. Directory handling
if ($fsPath && is_dir($fsPath)) {
if (substr($path, -1) !== '/') {
self::sendRedirect($client, $path . '/');
return false;
}
// Check for index files
foreach (array('index.html', 'index.php') as $idx) {
$indexPath = $fsPath . DS . $idx;
if (is_file($indexPath)) {
$fsPath = $indexPath;
break;
}
}
if (is_dir($fsPath)) {
// No index file → check if listings are enabled for this path
if (self::isIndexed($path)) {
$html = self::renderDirectoryListing($fsPath, $path);
self::sendResponse($client, 200, $html, 'text/html; charset=utf-8',
array('Cache-Control' => 'no-store'));
} else {
self::sendResponse($client, 403, 'Forbidden');
}
return false;
}
}
// 5. File handling
if ($fsPath && is_file($fsPath)) {
$ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION));
// PHP scripts → worker pool or in-process
if ($ext === 'php') {
return self::handlePhp($client, $parsed, $fsPath);
}
// Static file
if ($method === 'GET' || $method === 'HEAD') {
self::serveStaticFile($client, $fsPath, $method, $parsed['headers'], !empty($parsed['_keepAlive']));
return false;
}
}
// 6. Route dispatch — if Q.routes configured, match URL to handler
// Q_Uri caches compiled patterns and path→URI results in memory.
static $routingEnabled = null;
if ($routingEnabled === null) {
$routingEnabled = Q_Config::get('Q', 'routes', null) !== null
&& class_exists('Q_Uri', true);
}
if ($routingEnabled) {
$uri = Q_Uri::fromPath($path);
if ($uri && !empty($uri->module) && !empty($uri->action)) {
return self::handleRoute($client, $parsed, $uri);
}
}
// 7. Clean URL → route through index.php (if exists)
$indexPhp = self::$rootDir . 'index.php';
if (is_file($indexPhp)) {
return self::handlePhp($client, $parsed, $indexPhp);
}
// 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
static $fallback = null;
if ($fallback === null) $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;
}
/**
* Handle a routed request via Q::event() dispatch pipeline.
* Fires the same events as Qbix Platform's Q_Dispatcher:
* {module}/{action}/validate → validate input
* {module}/{action}/{method} → handle GET/POST/PUT/DELETE
* {module}/{action}/response → render response
*
* @method handleRoute
* @static
* @private
* @param {resource} $client
* @param {array} $parsed
* @param {Q_Uri} $uri
* @return {boolean}
*/
private static function handleRoute($client, $parsed, $uri)
{
$module = $uri->module;
$action = $uri->action;
$routed = $uri->toArray();
$method = strtolower($parsed['method']); // get, post, put, delete
// Set up superglobals
$parsed['_scriptPath'] = ''; // no script — handler-based
$saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE); $_SERVER['REQUEST_METHOD'] = $parsed['method'];
$_SERVER['REQUEST_URI'] = $parsed['uri'];
$_SERVER['QUERY_STRING'] = $parsed['query'];
$_SERVER['SERVER_NAME'] = explode(':', $parsed['headers']['host'] ?? 'localhost')[0];
$_SERVER['SERVER_PORT'] = self::$port;
$_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
$_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0';
$_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS);
$_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1';
$_SERVER['REQUEST_TIME'] = time();
$_SERVER['REQUEST_TIME_FLOAT'] = microtime(true);
foreach ($parsed['headers'] as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
$_GET = $_POST = $_REQUEST = $_FILES = array();
if ($parsed['query']) parse_str($parsed['query'], $_GET);
$ct = strtolower($parsed['headers']['content-type'] ?? '');
$rawBody = $parsed['body'] ?? '';
if (strpos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($rawBody, $_POST);
} elseif (strpos($ct, 'application/json') !== false) {
$_POST = json_decode($rawBody, true) ?: array();
} elseif (strpos($ct, 'multipart/form-data') !== false) {
$origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? '';
self::parseMultipart($origCt, $rawBody, $_POST, $_FILES);
}
$_REQUEST = array_merge($_GET, $_POST);
// Make raw body available
Q_Request::$input = $rawBody;
// If pcntl available, fork to isolate
if (function_exists('pcntl_fork')) {
$pid = pcntl_fork();
if ($pid === 0) {
// ── CHILD: run dispatch pipeline ──
while (ob_get_level()) ob_end_clean();
ob_start();
$status = 200;
$headers = array();
try {
// 1. Validate
Q::event("$module/$action/validate", $routed, false, true);
// 2. Method handler (get, post, put, delete)
if (Q::canHandle("$module/$action/$method")) {
Q::event("$module/$action/$method", $routed);
} elseif ($method !== 'get') {
$status = 405;
echo 'Method Not Allowed';
}
// 3. Response
Q::event("$module/$action/response", $routed, false, true);
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
echo json_encode(array('error' => $e->getMessage()));
$headers['Content-Type'] = 'application/json';
}
$body = ob_get_clean();
$response = compact('status', 'body', 'headers');
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
@fclose($client);
exit(0);
} elseif ($pid > 0) {
@fclose($client);
$key = (int) $client;
if (isset(self::$clientWatchers[$key])) {
Q_Evented::cancel(self::$clientWatchers[$key]);
}
unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]);
pcntl_waitpid($pid, $st, WNOHANG);
self::$lastStatus = 200;
list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved;
return false;
}
// Fork failed — fall through to in-process
}
// In-process fallback
while (ob_get_level()) ob_end_clean();
header_remove();
http_response_code(200);
ob_start();
$status = 200;
$headers = array();
try {
Q::event("$module/$action/validate", $routed, false, true);
if (Q::canHandle("$module/$action/$method")) {
Q::event("$module/$action/$method", $routed);
} elseif ($method !== 'get') {
$status = 405;
echo 'Method Not Allowed';
}
Q::event("$module/$action/response", $routed, false, true);
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
echo json_encode(array('error' => $e->getMessage()));
$headers['Content-Type'] = 'application/json';
}
$body = ob_get_clean();
header_remove();
list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved;
$response = compact('status', 'body', 'headers');
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
self::$lastStatus = $status;
Q_WebServer_Cache::put($parsed, $response);
return false;
}
/**
* Route a .php script to the worker pool or dispatch in-process.
* @return {boolean} false (connection closes after response)
*/
private static function handlePhp($client, $parsed, $scriptPath)
{
// ── CGI carveout: check if this script should use php-cgi ──
// Scripts matching Q.webserver.cgi.patterns run via php-cgi subprocess
// where native header(), setcookie(), headers_list() all work.
// Use for legacy/third-party code (WordPress, etc.) that calls header() directly.
static $cgiPatterns = null;
static $cgiBinary = null;
if ($cgiPatterns === null) {
$cgiPatterns = Q_Config::get('Q', 'webserver', 'cgi', 'patterns', array());
$cgiBinary = Q_Config::get('Q', 'webserver', 'cgi', 'binary', null);
if (!$cgiBinary) {
// Auto-detect php-cgi
foreach (array('php-cgi', 'php-cgi8.3', 'php-cgi8.2', 'php-cgi8.1') as $bin) {
$path = trim(shell_exec("which $bin 2>/dev/null") ?? '');
if ($path && is_executable($path)) {
$cgiBinary = $path;
break;
}
}
}
}
if (!empty($cgiPatterns) && $cgiBinary) {
$relPath = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen(self::$rootDir))), '/');
foreach ($cgiPatterns as $pattern) {
if (@preg_match($pattern, $relPath)) {
return self::handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary);
}
}
}
if (self::$pool) {
self::$lastStatus = 200;
self::$pool->dispatch($client, $parsed, $scriptPath);
$key = (int) $client;
if (isset(self::$clientWatchers[$key])) {
Q_Evented::cancel(self::$clientWatchers[$key]);
}
unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]);
return false;
}
// No pool — fork a single child if pcntl is available.
// This protects the server from exit()/die() in scripts
// and prevents blocking the event loop during PHP execution.
if (function_exists('pcntl_fork')) {
$pid = pcntl_fork();
if ($pid === -1) {
// Fork failed — fall through to in-process execution
} elseif ($pid === 0) {
// ── CHILD: handle request, write response to client, exit ──
$parsed['_scriptPath'] = $scriptPath;
$response = self::dispatchToQ($parsed);
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
@fclose($client);
exit(0);
} else {
// ── PARENT: close client socket (child owns it now), reap later ──
@fclose($client);
$key = (int) $client;
if (isset(self::$clientWatchers[$key])) {
Q_Evented::cancel(self::$clientWatchers[$key]);
}
unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]);
// Non-blocking reap — don't wait for child
pcntl_waitpid($pid, $st, WNOHANG);
self::$lastStatus = 200;
return false;
}
}
// Fallback: use proc_open to run PHP in a subprocess (Windows,
// or fork failed). Safe against exit()/die() — the subprocess
// dies, not the server. Slower than fork (no shared classes)
// but still provides isolation.
return self::handlePhpSubprocess($client, $parsed, $scriptPath);
}
/**
* Execute a PHP script in a subprocess via proc_open.
* Used on Windows (no pcntl_fork) or as fallback when fork fails.
* The subprocess gets request data via stdin, returns response via stdout.
* @method handlePhpSubprocess
* @static
* @private
*/
private static function handlePhpSubprocess($client, $parsed, $scriptPath)
{
// Build a small inline PHP worker that:
// 1. Reads request JSON from stdin
// 2. Sets up superglobals
// 3. Loads Q.php for autoloader/events
// 4. Includes the target script
// 5. Writes response JSON to stdout
$qFile = dirname(__FILE__) . DS . 'Q.php';
$workerCode = <<<'WORKER'
<?php
$json = '';
while (!feof(STDIN)) { $c = fread(STDIN, 65536); if ($c === false || $c === '') break; $json .= $c; }
$req = json_decode($json, true);
if (!$req) { echo json_encode(['status'=>500,'body'=>'Bad request','headers'=>[]]); exit; }
if (isset($req['qFile']) && file_exists($req['qFile'])) {
require_once $req['qFile'];
if (isset($req['projectRoot'])) Q::init($req['projectRoot']);
}
$_SERVER['REQUEST_METHOD'] = $req['method'] ?? 'GET';
$_SERVER['REQUEST_URI'] = $req['uri'] ?? '/';
$_SERVER['QUERY_STRING'] = $req['query'] ?? '';
$_SERVER['SCRIPT_FILENAME'] = $req['scriptPath'] ?? '';
$_SERVER['SCRIPT_NAME'] = '/' . basename($req['scriptPath'] ?? 'index.php');
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['PATH_TRANSLATED'] = $req['scriptPath'] ?? '';
$_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? '';
$_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['SERVER_NAME'] = $req['serverName'] ?? 'localhost';
$_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080';
$_SERVER['SERVER_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1';
$_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0';
$_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1';
$_SERVER['REDIRECT_STATUS'] = 200;
$_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1';
$_SERVER['REMOTE_PORT'] = $req['remotePort'] ?? 0;
$_SERVER['REQUEST_TIME'] = time();
$_SERVER['REQUEST_TIME_FLOAT'] = microtime(true);
$_SERVER['REQUEST_SCHEME'] = ($req['https'] ?? false) ? 'https' : 'http';
$_SERVER['HTTPS'] = ($req['https'] ?? false) ? 'on' : '';
foreach ($req['headers'] ?? [] as $k=>$v) $_SERVER['HTTP_'.strtoupper(str_replace('-','_',$k))] = $v;
if (isset($req['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $req['headers']['content-type'];
if (isset($req['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length'];
// Parse cookies
$_COOKIE = [];
$ck = $req['headers']['cookie'] ?? '';
if ($ck) { foreach (explode(';',$ck) as $p) { $p=trim($p); if(!$p)continue; $e=strpos($p,'='); if($e===false)continue; $_COOKIE[urldecode(trim(substr($p,0,$e)))]=urldecode(trim(substr($p,$e+1))); } }
// Parse Basic auth
$auth = $req['headers']['authorization'] ?? '';
if (stripos($auth,'Basic ')===0) { $d=base64_decode(substr($auth,6)); if($d&&strpos($d,':')!==false) { [$u,$pw]=explode(':',$d,2); $_SERVER['PHP_AUTH_USER']=$u; $_SERVER['PHP_AUTH_PW']=$pw; $_SERVER['AUTH_TYPE']='Basic'; } }
$_GET = $_POST = $_REQUEST = $_FILES = [];
if (!empty($req['query'])) parse_str($req['query'], $_GET);
$ct = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
$raw = $req['body'] ?? '';
if (strpos($ct,'application/x-www-form-urlencoded') !== false) parse_str($raw, $_POST);
elseif (strpos($ct,'application/json') !== false) $_POST = json_decode($raw, true) ?: [];
elseif (strpos($ct,'multipart/form-data') !== false) { $oct=$req['headers']['content-type']??''; Q_WebServer::parseMultipart($oct, $raw, $_POST, $_FILES); }
$_REQUEST = array_merge($_COOKIE, $_GET, $_POST);
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'; }
$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);
WORKER;
// Write worker code to a temp file (reuse across requests)
static $workerFile = null;
if (!$workerFile || !file_exists($workerFile)) {
$workerFile = tempnam(sys_get_temp_dir(), 'qbix_worker_');
file_put_contents($workerFile, $workerCode);
register_shutdown_function(function () use (&$workerFile) {
@unlink($workerFile);
});
}
// Build request payload
$host = $parsed['headers']['host'] ?? 'localhost';
$payload = json_encode(array(
'method' => $parsed['method'],
'uri' => $parsed['uri'],
'query' => $parsed['query'],
'headers' => $parsed['headers'],
'body' => $parsed['body'] ?? '',
'scriptPath' => $scriptPath,
'documentRoot'=> rtrim(self::$rootDir, DS),
'serverName' => explode(':', $host)[0],
'serverPort' => (string) self::$port,
'remoteAddr' => $parsed['_remoteAddr'] ?? '127.0.0.1',
'remotePort' => $parsed['_remotePort'] ?? 0,
'https' => !empty(self::$tlsSocket),
'qFile' => $qFile,
'projectRoot' => dirname(rtrim(self::$rootDir, DS)),
), JSON_UNESCAPED_SLASHES);
// Launch subprocess
$descriptors = array(
0 => array('pipe', 'r'), // stdin
1 => array('pipe', 'w'), // stdout
2 => array('pipe', 'w'), // stderr
);
$phpBin = defined('PHP_BINARY') ? PHP_BINARY : 'php';
$process = proc_open($phpBin . ' ' . escapeshellarg($workerFile), $descriptors, $pipes);
if (!is_resource($process)) {
// proc_open failed — last resort, run in-process
$parsed['_scriptPath'] = $scriptPath;
$response = self::dispatchToQ($parsed);
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
self::$lastStatus = $response['status'] ?? 200;
return false;
}
// Send request data to child's stdin
fwrite($pipes[0], $payload);
fclose($pipes[0]);
// Read response from child's stdout
$stdout = '';
while (!feof($pipes[1])) {
$chunk = fread($pipes[1], 65536);
if ($chunk === false || $chunk === '') break;
$stdout .= $chunk;
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Parse response
$response = json_decode($stdout, true);
if (!$response) {
$response = array(
'status' => 502,
'body' => 'Worker subprocess failed',
'headers' => array('Content-Type' => 'text/plain'),
);
}
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
self::$lastStatus = $response['status'] ?? 200;
Q_WebServer_Cache::put($parsed, $response);
return false;
}
/**
* Execute a PHP script via php-cgi binary for full header() compatibility.
* Used for legacy/third-party code (WordPress, Laravel, etc.) that calls
* header() and setcookie() directly. The php-cgi binary outputs real HTTP
* headers followed by the body — we parse and forward them.
*
* Slower than fork mode (no preload benefit) but 100% compatible.
*
* @method handlePhpCgi
* @static
* @private
*/
private static function handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary)
{
$host = $parsed['headers']['host'] ?? 'localhost';
$hostParts = explode(':', $host);
$isHttps = !empty(self::$tlsSocket);
$fwdProto = strtolower($parsed['headers']['x-forwarded-proto'] ?? '');
if ($fwdProto === 'https') $isHttps = true;
$cfVisitor = $parsed['headers']['cf-visitor'] ?? '';
if (strpos($cfVisitor, '"https"') !== false) $isHttps = true;
// Compute SCRIPT_NAME and PATH_INFO (frameworks need correct PATH_INFO)
$requestPath = parse_url($parsed['uri'], PHP_URL_PATH) ?: '/';
$docRoot = rtrim(self::$rootDir, DS);
$scriptRel = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen($docRoot))), '/');
$pathInfo = '';
if (strlen($requestPath) > strlen($scriptRel)) {
$pathInfo = substr($requestPath, strlen($scriptRel));
}
// Build CGI environment variables — full set matching nginx fastcgi_params
$env = array(
'REDIRECT_STATUS' => '200',
'GATEWAY_INTERFACE' => 'CGI/1.1',
'SERVER_SOFTWARE' => 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0'),
'SERVER_PROTOCOL' => 'HTTP/' . ($parsed['httpVersion'] ?? '1.1'),
'SERVER_NAME' => $hostParts[0],
'SERVER_PORT' => isset($hostParts[1]) ? $hostParts[1] : (string) self::$port,
'SERVER_ADDR' => self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host,
'REQUEST_METHOD' => $parsed['method'],
'REQUEST_URI' => $parsed['uri'],
'QUERY_STRING' => $parsed['query'],
'SCRIPT_FILENAME' => $scriptPath,
'SCRIPT_NAME' => $scriptRel,
'PHP_SELF' => $scriptRel . $pathInfo,
'PATH_INFO' => $pathInfo,
'PATH_TRANSLATED' => $pathInfo ? $docRoot . $pathInfo : '',
'DOCUMENT_ROOT' => $docRoot,
'DOCUMENT_URI' => $scriptRel,
'REMOTE_ADDR' => $parsed['_remoteAddr'] ?? '127.0.0.1',
'REMOTE_PORT' => (string) ($parsed['_remotePort'] ?? 0),
'REQUEST_SCHEME' => $isHttps ? 'https' : 'http',
'HTTPS' => $isHttps ? 'on' : '',
'REQUEST_TIME' => (string) time(),
'REQUEST_TIME_FLOAT' => (string) microtime(true),
);
// Forward ALL request headers as HTTP_* env vars
foreach ($parsed['headers'] as $k => $v) {
$envKey = 'HTTP_' . strtoupper(str_replace('-', '_', $k));
$env[$envKey] = $v;
}
// Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec)
if (isset($parsed['headers']['content-type'])) {
$env['CONTENT_TYPE'] = $parsed['headers']['content-type'];
}
if (isset($parsed['headers']['content-length'])) {
$env['CONTENT_LENGTH'] = $parsed['headers']['content-length'];
}
// Basic auth
$auth = $parsed['headers']['authorization'] ?? '';
if (stripos($auth, 'Basic ') === 0) {
$decoded = base64_decode(substr($auth, 6));
if ($decoded && strpos($decoded, ':') !== false) {
list($user, $pass) = explode(':', $decoded, 2);
$env['PHP_AUTH_USER'] = $user;
$env['PHP_AUTH_PW'] = $pass;
$env['AUTH_TYPE'] = 'Basic';
}
}
// Inherit essential system env vars
foreach (array('PATH', 'HOME', 'TEMP', 'TMP', 'TMPDIR', 'SYSTEMROOT') as $sysVar) {
if (isset($_ENV[$sysVar])) $env[$sysVar] = $_ENV[$sysVar];
elseif (($v = getenv($sysVar)) !== false) $env[$sysVar] = $v;
}
// Launch php-cgi
$descriptors = array(
0 => array('pipe', 'r'), // stdin (request body)
1 => array('pipe', 'w'), // stdout (CGI response)
2 => array('pipe', 'w'), // stderr (errors)
);
$cwd = dirname($scriptPath); // run in the script's directory
$process = proc_open($cgiBinary, $descriptors, $pipes, $cwd, $env);
if (!is_resource($process)) {
self::sendResponse($client, 502, 'CGI process failed to start');
return false;
}
// Non-blocking reads for timeout support
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
// Send request body to stdin
if (!empty($parsed['body'])) {
@fwrite($pipes[0], $parsed['body']);
}
fclose($pipes[0]);
// Read CGI response with timeout
static $timeout = null;
if ($timeout === null) $timeout = Q_Config::get('Q', 'webserver', 'cgi', 'timeout', 30);
$deadline = microtime(true) + $timeout;
$stdout = '';
$stderr = '';
while (true) {
$read = array($pipes[1], $pipes[2]);
$write = $except = null;
$remaining = max(0.1, $deadline - microtime(true));
if ($remaining <= 0) break; // timeout
$ready = @stream_select($read, $write, $except, (int) $remaining, (int) (($remaining - (int) $remaining) * 1000000));
if ($ready === false) break;
if ($ready === 0) continue;
foreach ($read as $pipe) {
$chunk = @fread($pipe, 65536);
if ($chunk === false || $chunk === '') {
if ($pipe === $pipes[1] && feof($pipes[1])) break 2;
continue;
}
if ($pipe === $pipes[1]) $stdout .= $chunk;
else $stderr .= $chunk;
}
}
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
// Log stderr if non-empty
if ($stderr !== '') {
Q_WebServer_Log::error("CGI stderr ($scriptPath): " . trim($stderr));
}
// Handle timeout
if (microtime(true) >= $deadline && $stdout === '') {
self::sendResponse($client, 504, 'CGI process timed out');
return false;
}
// Handle empty response
if ($stdout === '') {
$status = ($exitCode !== 0) ? 500 : 200;
$response = array('status' => $status, 'body' => '', 'headers' => array());
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
self::$lastStatus = $status;
return false;
}
// Parse CGI output: headers separated by blank line from body
$headerEnd = strpos($stdout, "\r\n\r\n");
$sep = 4;
if ($headerEnd === false) {
$headerEnd = strpos($stdout, "\n\n");
$sep = 2;
}
if ($headerEnd === false) {
$body = $stdout;
$headers = array();
$status = 200;
$extraHeaders = array();
} else {
$headerBlock = substr($stdout, 0, $headerEnd);
$body = substr($stdout, $headerEnd + $sep);
$headers = array();
$extraHeaders = array(); // for multiple Set-Cookie headers
$status = 200;
foreach (explode("\n", $headerBlock) as $line) {
$line = rtrim($line, "\r");
if ($line === '') continue;
// Status line: "Status: 404 Not Found"
if (stripos($line, 'Status:') === 0) {
$status = (int) trim(substr($line, 7));
continue;
}
// Location header implies redirect status
$colonPos = strpos($line, ':');
if ($colonPos === false) continue;
$name = trim(substr($line, 0, $colonPos));
$value = trim(substr($line, $colonPos + 1));
// Multiple Set-Cookie headers must all be forwarded
if (strtolower($name) === 'set-cookie') {
$extraHeaders[] = array($name, $value);
} else {
$headers[$name] = $value;
}
if (strtolower($name) === 'location' && $status === 200) {
$status = 302; // implicit redirect
}
}
}
// Build and send response
// We bypass processResponse for Set-Cookie to handle multiples
$headers['Content-Length'] = strlen($body);
$headers['Connection'] = 'close';
static $reasons = array(
200=>'OK', 201=>'Created', 204=>'No Content',
301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified',
400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden',
404=>'Not Found', 405=>'Method Not Allowed',
413=>'Payload Too Large', 500=>'Internal Server Error',
502=>'Bad Gateway', 503=>'Service Unavailable', 504=>'Gateway Timeout',
);
$reason = $reasons[$status] ?? 'OK';
$out = "HTTP/1.1 $status $reason\r\n";
foreach ($headers as $k => $v) {
$out .= "$k: $v\r\n";
}
// Append all Set-Cookie headers (can't use associative array)
foreach ($extraHeaders as $pair) {
$out .= $pair[0] . ': ' . $pair[1] . "\r\n";
}
@fwrite($client, $out . "\r\n" . $body);
self::$lastStatus = $status;
return false;
}
private static function serveStaticFile($client, $fsPath, $method, $reqHeaders, $keepAlive = false)
{
$ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION));
if (!in_array($ext, self::$allowedExtensions)) {
self::sendResponse($client, 403, 'Forbidden');
return;
}
$connKey = $keepAlive ? 'ka' : 'cl';
$now = microtime(true);
// ── Try response cache ──
if (isset(self::$fileCache[$fsPath])) {
$cached = &self::$fileCache[$fsPath];
// Revalidate mtime periodically
if (($now - $cached['checked']) >= self::$fileCacheCheckInterval) {
clearstatcache(true, $fsPath);
if (filemtime($fsPath) !== $cached['mtime']) {
self::$fileCacheSize -= $cached['bodyLen'] * 2;
unset(self::$fileCache[$fsPath]);
} else {
$cached['checked'] = $now;
}
}
}
if (isset(self::$fileCache[$fsPath])) {
$cached = &self::$fileCache[$fsPath];
$etag = $cached['etag'];
// 304 against cached etag
if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) {
self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive);
return;
}
if (isset($reqHeaders['if-modified-since'])) {
$since = strtotime($reqHeaders['if-modified-since']);
if ($since !== false && $cached['mtime'] <= $since) {
self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive);
return;
}
}
// Serve from cache — single fwrite
self::$lastStatus = 200;
if ($method === 'HEAD') {
@fwrite($client, $cached['head'][$connKey]);
} else {
@fwrite($client, $cached['full'][$connKey]);
}
return;
}
// ── Cache miss — build from disk ──
clearstatcache(true, $fsPath);
$mtime = filemtime($fsPath);
$size = filesize($fsPath);
$etag = '"' . dechex($mtime) . '-' . dechex($size) . '"';
// 304 Not Modified
if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) {
self::sendNotModified($client, $etag, $mtime, $keepAlive);
return;
}
if (isset($reqHeaders['if-modified-since'])) {
$since = strtotime($reqHeaders['if-modified-since']);
if ($since !== false && $mtime <= $since) {
self::sendNotModified($client, $etag, $mtime, $keepAlive);
return;
}
}
$contentType = self::mimeType($ext);
$baseHeaders = "Content-Type: $contentType\r\n"
. "ETag: $etag\r\n"
. "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n"
. "Cache-Control: public, max-age=0, must-revalidate\r\n";
// Companion .headers file
$hf = $fsPath . '.headers';
if (file_exists($hf)) {
foreach (file($hf, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || strpos($line, ':') === false) continue;
$baseHeaders .= trim($line) . "\r\n";
}
}
$connHeader = $keepAlive ? 'keep-alive' : 'close';
// Pre-compressed siblings — not cached (different per Accept-Encoding)
$preComp = Q_WebServer_Headers::findPreCompressed($fsPath, $reqHeaders);
if ($preComp) {
$out = "HTTP/1.1 200 OK\r\n" . $baseHeaders
. "Content-Encoding: " . $preComp['encoding'] . "\r\n"
. "Content-Length: " . $preComp['size'] . "\r\n"
. "Vary: Accept-Encoding\r\n"
. "Connection: $connHeader\r\n\r\n";
self::$lastStatus = 200;
@fwrite($client, $method === 'HEAD' ? $out : $out . file_get_contents($preComp['path']));
return;
}
// On-the-fly gzip — not cached (different per Accept-Encoding)
if ($size < 5242880) {
$gzHeaders = array();
if (Q_WebServer_Headers::shouldCompress($contentType, $size, $reqHeaders)) {
$body = file_get_contents($fsPath);
$body = Q_WebServer_Headers::maybeCompress($body, $contentType, $reqHeaders, $gzHeaders);
$out = "HTTP/1.1 200 OK\r\n" . $baseHeaders;
foreach ($gzHeaders as $k => $v) $out .= "$k: $v\r\n";
$out .= "Content-Length: " . strlen($body) . "\r\n"
. "Connection: $connHeader\r\n\r\n";
self::$lastStatus = 200;
@fwrite($client, $method === 'HEAD' ? $out : $out . $body);
return;
}
}
// ── Uncompressed — serve and cache ──
$body = file_get_contents($fsPath);
$kaHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders
. "Content-Length: $size\r\nConnection: keep-alive\r\n\r\n";
$clHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders
. "Content-Length: $size\r\nConnection: close\r\n\r\n";
self::$lastStatus = 200;
$headStr = $keepAlive ? $kaHead : $clHead;
@fwrite($client, $method === 'HEAD' ? $headStr : $headStr . $body);
// Cache if small enough
if ($size <= self::$fileCacheMaxFile
&& self::$fileCacheSize + $size * 2 < self::$fileCacheMaxSize
) {
self::$fileCache[$fsPath] = array(
'mtime' => $mtime,
'bodyLen' => $size,
'etag' => $etag,
'checked' => $now,
'head' => array('ka' => $kaHead, 'cl' => $clHead),
'full' => array('ka' => $kaHead . $body, 'cl' => $clHead . $body),
);
self::$fileCacheSize += $size * 2;
// Evict oldest if over limit
while (self::$fileCacheSize > self::$fileCacheMaxSize && self::$fileCache) {
$evict = array_key_first(self::$fileCache);
self::$fileCacheSize -= self::$fileCache[$evict]['bodyLen'] * 2;
unset(self::$fileCache[$evict]);
}
}
}
// ── Privacy / access control ─────────────────────────
/**
* Check if a URL path is blocked entirely (403 Forbidden).
*
* These paths cannot be accessed by any URL. They contain
* server-side code, config, and internal data.
*
* Blocked: /config/, /classes/, /handlers/, /scripts/
* Also: dotfiles/dotdirs (except /.well-known/)
* Also: paths in Q.web.blocked.paths config
*
* For true access control on files, use X-Accel-Redirect
* (PHP checks permissions, server does file I/O).
*
* @method isBlocked
* @static
* @param {string} $urlPath
* @return {boolean}
*/
static function isBlocked($urlPath)
{
// Core blocked directories (server internals)
$blocked = array('/config/', '/classes/', '/handlers/', '/scripts/');
foreach ($blocked as $prefix) {
if (strpos($urlPath, $prefix) === 0) return true;
}
// Dotfiles/dotdirs (except /.well-known/)
if (preg_match('#/\.(?!well-known)#', $urlPath)) return true;
// Config-based blocked paths
static $blockedPaths = null;
if ($blockedPaths === null) $blockedPaths = Q_Config::get('Q', 'web', 'blocked', 'paths', array());
foreach ($blockedPaths as $pp => $v) {
if ($v && strpos($urlPath, '/' . ltrim($pp, '/')) === 0) return true;
}
return false;
}
/**
* Check if a URL path allows directory listing.
*
* Directory listings are OFF by default (more secure).
* Only paths matching regexes in
* Q.web.indexed.paths get listings. Default: /img/.
*
* Config:
* "Q": { "web": { "indexed": { "paths": {
* "#^/img/#": true,
* "#^/downloads/#": true
* }}}}
*
* For actual access control, use X-Accel-Redirect.
*
* @method isIndexed
* @static
* @param {string} $urlPath
* @return {boolean}
*/
static function isIndexed($urlPath)
{
static $patterns = null;
if ($patterns === null) $patterns = Q_Config::get('Q', 'web', 'indexed', 'paths', array(
'#^/img/#' => true
));
foreach ($patterns as $regex => $enabled) {
if ($enabled && preg_match($regex, $urlPath)) return true;
}
return false; // not indexed by default
}
// ── Directory listing ────────────────────────────────
/**
* Render a responsive directory listing with media previews.
* Only called for paths that pass isIndexed().
* Dotfiles are always hidden from listings.
*
* @method renderDirectoryListing
* @static
* @param {string} $dir Filesystem path
* @param {string} $urlPath URL path
* @return {string} HTML
*/
static function renderDirectoryListing($dir, $urlPath)
{
$maxImages = (int) Q_Config::get('Q', 'webserver', 'listing', 'images', 'max', 100);
$items = scandir($dir);
$dirs = array();
$files = array();
$media = array();
$imageExts = array('png','jpg','jpeg','gif','webp','svg');
$videoExts = array('mp4','webm','ogg');
$audioExts = array('mp3','wav','ogg');
foreach ($items as $name) {
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue; // dotfiles always hidden
$full = $dir . DS . $name;
$href = htmlspecialchars($urlPath . $name, ENT_QUOTES);
$safe = htmlspecialchars($name, ENT_QUOTES);
if (is_dir($full)) {
$dirs[] = "<a href=\"{$href}/\" class=\"item dir\"><span class=\"icon\">ðŸ“</span><span class=\"name\">{$safe}/</span></a>";
continue;
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, self::$allowedExtensions)) continue;
$size = filesize($full);
$sizeStr = $size < 1024 ? "${size} B"
: ($size < 1048576 ? round($size/1024,1).' KB'
: round($size/1048576,1).' MB');
$files[] = "<a href=\"{$href}\" class=\"item file\"><span class=\"icon\">📄</span><span class=\"name\">{$safe}</span><span class=\"size\">{$sizeStr}</span></a>";
// Collect media for preview grid
if (count($media) < $maxImages) {
if (in_array($ext, $imageExts)) {
$media[] = "<div class=\"media-item\"><a href=\"{$href}\"><img src=\"{$href}\" loading=\"lazy\" alt=\"{$safe}\"></a><div class=\"caption\">{$safe}</div></div>";
} elseif (in_array($ext, $videoExts)) {
$media[] = "<div class=\"media-item\"><video src=\"{$href}\" controls preload=\"metadata\"></video><div class=\"caption\">{$safe}</div></div>";
} elseif (in_array($ext, $audioExts)) {
$media[] = "<div class=\"media-item\"><audio src=\"{$href}\" controls preload=\"metadata\"></audio><div class=\"caption\">{$safe}</div></div>";
}
}
}
$safePath = htmlspecialchars($urlPath, ENT_QUOTES);
$upLink = ($urlPath !== '/')
? '<a href="../" class="item dir up"><span class="icon">⬆</span><span class="name">Parent Directory</span></a>'
: '';
$mediaSection = '';
if ($media) {
$mediaSection = '<div class="divider">Media Preview</div><div class="media-grid">'
. implode("\n", $media) . '</div>';
}
return <<<HTML
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index of {$safePath}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:#f8f9fa;color:#333;padding:20px;max-width:960px;margin:0 auto}
h1{font-size:20px;font-weight:600;padding:16px 0;border-bottom:2px solid #e9ecef;margin-bottom:12px;
word-break:break-all}
.listing{display:flex;flex-direction:column;gap:2px}
.item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;
text-decoration:none;color:#333;transition:background .15s}
.item:hover{background:#e9ecef}
.icon{font-size:18px;flex-shrink:0;width:24px;text-align:center}
.name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
.size{color:#868e96;font-size:12px;flex-shrink:0}
.dir .name{color:#1971c2;font-weight:500}
.file .name{color:#333}
.up{border-bottom:1px solid #e9ecef;margin-bottom:4px;padding-bottom:10px}
.divider{font-size:12px;color:#868e96;text-transform:uppercase;letter-spacing:.5px;
padding:20px 0 8px;border-top:1px solid #e9ecef;margin-top:16px}
.media-grid{display:flex;flex-wrap:wrap;gap:12px;padding:8px 0}
.media-item{max-width:200px;text-align:center}
.media-item img{max-width:200px;max-height:200px;height:auto;border-radius:6px;
display:block;margin:0 auto 4px;object-fit:cover}
.media-item video{max-width:200px;max-height:200px;border-radius:6px;display:block;margin:0 auto 4px}
.media-item audio{max-width:200px;display:block;margin:0 auto 4px}
.caption{font-size:11px;color:#868e96;word-break:break-all;max-width:200px}
@media(max-width:600px){
body{padding:12px}
.item{padding:10px 8px}
.media-item{max-width:calc(50vw - 24px)}
.media-item img,.media-item video{max-width:100%}
}
</style>
</head><body>
<h1>Index of {$safePath}</h1>
<div class="listing">
{$upLink}
HTML
. implode("\n", $dirs) . "\n"
. implode("\n", $files)
. "\n</div>\n"
. $mediaSection
. "\n</body></html>";
}
// ── MIME types ────────────────────────────────────────
static function mimeType($ext)
{
static $types = array(
'html'=>'text/html; charset=utf-8', 'htm'=>'text/html; charset=utf-8',
'css'=>'text/css; charset=utf-8', 'js'=>'application/javascript; charset=utf-8',
'mjs'=>'application/javascript; charset=utf-8', 'json'=>'application/json; charset=utf-8',
'xml'=>'application/xml', 'txt'=>'text/plain; charset=utf-8',
'md'=>'text/plain; charset=utf-8', 'csv'=>'text/csv; charset=utf-8',
'yaml'=>'text/yaml', 'yml'=>'text/yaml', 'log'=>'text/plain; charset=utf-8',
'map'=>'application/json',
'png'=>'image/png', 'jpg'=>'image/jpeg', 'jpeg'=>'image/jpeg',
'gif'=>'image/gif', 'webp'=>'image/webp', 'svg'=>'image/svg+xml',
'bmp'=>'image/bmp', 'ico'=>'image/x-icon', 'avif'=>'image/avif',
'woff'=>'font/woff', 'woff2'=>'font/woff2',
'ttf'=>'font/ttf', 'otf'=>'font/otf',
'mp3'=>'audio/mpeg', 'wav'=>'audio/wav', 'ogg'=>'audio/ogg',
'mp4'=>'video/mp4', 'webm'=>'video/webm',
'pdf'=>'application/pdf', 'zip'=>'application/zip',
'wasm'=>'application/wasm',
);
return $types[$ext] ?? 'application/octet-stream';
}
// ── Q_Dispatcher bridge ──────────────────────────────
static function dispatchToQ($parsed)
{
$saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE);
$scriptPath = $parsed['_scriptPath'] ?? self::$rootDir . 'index.php';
$host = $parsed['headers']['host'] ?? 'localhost';
$hostParts = explode(':', $host);
// ── Standard CGI variables ──────────────────────
$_SERVER['REQUEST_METHOD'] = $parsed['method'];
$_SERVER['REQUEST_URI'] = $parsed['uri'];
$_SERVER['QUERY_STRING'] = $parsed['query'];
$_SERVER['SCRIPT_NAME'] = '/' . basename($scriptPath);
$_SERVER['SCRIPT_FILENAME'] = $scriptPath;
$_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; // WordPress uses this
$_SERVER['PATH_TRANSLATED'] = $scriptPath;
$_SERVER['PATH_INFO'] = '';
$_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS);
$_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME'];
$_SERVER['SERVER_NAME'] = $hostParts[0];
$_SERVER['SERVER_PORT'] = isset($hostParts[1]) ? $hostParts[1] : self::$port;
$_SERVER['SERVER_ADDR'] = self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host;
$_SERVER['SERVER_PROTOCOL'] = 'HTTP/' . ($parsed['httpVersion'] ?? '1.1');
$_SERVER['SERVER_SOFTWARE'] = 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0');
$_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1';
$_SERVER['REDIRECT_STATUS'] = 200;
$_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1';
$_SERVER['REMOTE_PORT'] = $parsed['_remotePort'] ?? 0;
$_SERVER['REQUEST_TIME'] = time();
$_SERVER['REQUEST_TIME_FLOAT']= microtime(true);
// ── HTTPS detection (direct TLS or proxy header) ──
$isHttps = !empty(self::$tlsSocket);
$fwdProto = $parsed['headers']['x-forwarded-proto'] ?? '';
if (strtolower($fwdProto) === 'https') $isHttps = true;
// CloudFront
$cfProto = $parsed['headers']['cloudfront-forwarded-proto'] ?? '';
if (strtolower($cfProto) === 'https') $isHttps = true;
// Cloudflare
$cfVisitor = $parsed['headers']['cf-visitor'] ?? '';
if (strpos($cfVisitor, '"https"') !== false) $isHttps = true;
$_SERVER['REQUEST_SCHEME'] = $isHttps ? 'https' : 'http';
$_SERVER['HTTPS'] = $isHttps ? 'on' : '';
// ── Request headers → HTTP_* ────────────────────
// All request headers become HTTP_HEADERNAME (uppercase, hyphens→underscores)
foreach ($parsed['headers'] as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
// Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec)
if (isset($parsed['headers']['content-type']))
$_SERVER['CONTENT_TYPE'] = $parsed['headers']['content-type'];
if (isset($parsed['headers']['content-length']))
$_SERVER['CONTENT_LENGTH'] = $parsed['headers']['content-length'];
// ── Basic auth parsing ──────────────────────────
$auth = $parsed['headers']['authorization'] ?? '';
if (stripos($auth, 'Basic ') === 0) {
$decoded = base64_decode(substr($auth, 6));
if ($decoded && strpos($decoded, ':') !== false) {
list($user, $pass) = explode(':', $decoded, 2);
$_SERVER['PHP_AUTH_USER'] = $user;
$_SERVER['PHP_AUTH_PW'] = $pass;
$_SERVER['AUTH_TYPE'] = 'Basic';
}
} elseif (stripos($auth, 'Bearer ') === 0) {
$_SERVER['HTTP_AUTHORIZATION'] = $auth; // already set by loop
$_SERVER['AUTH_TYPE'] = 'Bearer';
}
// ── $_COOKIE ────────────────────────────────────
$_COOKIE = array();
$cookieHeader = $parsed['headers']['cookie'] ?? '';
if ($cookieHeader) {
$pairs = explode(';', $cookieHeader);
foreach ($pairs as $pair) {
$pair = trim($pair);
if ($pair === '') continue;
$eqPos = strpos($pair, '=');
if ($eqPos === false) continue;
$name = urldecode(trim(substr($pair, 0, $eqPos)));
$value = urldecode(trim(substr($pair, $eqPos + 1)));
$_COOKIE[$name] = $value;
}
}
// ── $_GET, $_POST, $_FILES, $_REQUEST ───────────
$_GET = $_POST = $_REQUEST = $_FILES = array();
if ($parsed['query']) parse_str($parsed['query'], $_GET);
$ct = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
$rawBody = $parsed['body'] ?? '';
if (strpos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($rawBody, $_POST);
} elseif (strpos($ct, 'application/json') !== false) {
$_POST = json_decode($rawBody, true) ?: array();
} elseif (strpos($ct, 'multipart/form-data') !== false) {
$origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? '';
self::parseMultipart($origCt, $rawBody, $_POST, $_FILES);
}
$_REQUEST = array_merge($_COOKIE, $_GET, $_POST); // PHP default order
// Make raw body available
Q_Request::$input = $rawBody;
// Clear any stale headers and output from previous in-process requests,
// then start fresh output buffering. This prevents "headers already sent"
// errors when scripts call header() after prior output leaked through.
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();
try {
if (class_exists('Q_Dispatcher', false)) {
// Full Qbix Platform mode
Q_Dispatcher::dispatch();
} else {
// Standalone mode — execute PHP script directly
$scriptPath = $parsed['_scriptPath'] ?? $_SERVER['SCRIPT_FILENAME'];
if (is_file($scriptPath)) {
include $scriptPath;
} else {
$status = 404;
echo 'Not Found';
}
}
$headers = Q::getResponseHeaders();
$code = http_response_code();
if ($code && $code !== 200) $status = $code;
if (Q::$_responseCode !== 200) $status = Q::$_responseCode;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
echo json_encode(array('error' => $e->getMessage()));
$headers['Content-Type'] = 'application/json';
}
$body = ob_get_clean();
header_remove();
list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved;
// Process Merkle cache headers (strips X-Q-Cache-* from response)
if (Q_WebServer_Cache_Components::enabled()) {
$pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? '');
Q_WebServer_Cache_Components::processResponseHeaders($pageKey, $headers);
}
return compact('status', 'body', 'headers');
}
// ── Request parsing ──────────────────────────────────
static function parseRequest($raw)
{
$headerEnd = strpos($raw, "\r\n\r\n");
$headerBlock = substr($raw, 0, $headerEnd);
$body = substr($raw, $headerEnd + 4);
// Fast request line parse
$rlEnd = strpos($headerBlock, "\r\n");
$requestLine = $rlEnd !== false ? substr($headerBlock, 0, $rlEnd) : $headerBlock;
if (!preg_match('#^(\w+)\s+([^\s]+)\s+HTTP/(\d\.\d)#', $requestLine, $m)) {
return array(
'method' => 'GET', 'uri' => '/', 'path' => '/',
'query' => '', 'headers' => array(), 'body' => '',
'httpVersion' => '1.0', '_malformed' => true
);
}
$method = strtoupper($m[1]);
$uri = $m[2];
$httpVersion = $m[3];
// Fast path parsing — avoid parse_url for simple paths
$qPos = strpos($uri, '?');
if ($qPos !== false) {
$path = urldecode(substr($uri, 0, $qPos));
$query = substr($uri, $qPos + 1);
} else {
$path = urldecode($uri);
$query = '';
}
// Collapse double slashes
if (strpos($path, '//') !== false) {
$path = preg_replace('#/+#', '/', $path);
}
// Fast header parsing — scan for common headers first
$headers = array();
$pos = $rlEnd !== false ? $rlEnd + 2 : strlen($headerBlock);
$len = strlen($headerBlock);
while ($pos < $len) {
$nlPos = strpos($headerBlock, "\r\n", $pos);
if ($nlPos === false) $nlPos = $len;
$colonPos = strpos($headerBlock, ':', $pos);
if ($colonPos !== false && $colonPos < $nlPos) {
$k = strtolower(substr($headerBlock, $pos, $colonPos - $pos));
$v = ltrim(substr($headerBlock, $colonPos + 1, $nlPos - $colonPos - 1));
$headers[$k] = $v;
}
$pos = $nlPos + 2;
}
// HTTP/1.0 defaults to Connection: close
if ($httpVersion === '1.0' && !isset($headers['connection'])) {
$headers['connection'] = 'close';
}
return compact('method', 'uri', 'path', 'query', 'headers', 'body', 'httpVersion');
}
/**
* Parse multipart/form-data body into $_POST and $_FILES arrays.
* Handles file uploads by writing to temp files (same as php-fpm).
* @method parseMultipart
* @static
* @param {string} $contentType Full Content-Type header value
* @param {string} $body Raw request body
* @param {array} &$post Populated with form field values
* @param {array} &$files Populated with file upload entries
*/
static function parseMultipart($contentType, $body, &$post, &$files)
{
// Extract boundary from Content-Type
if (!preg_match('/boundary=(?:"([^"]+)"|([^\s;]+))/i', $contentType, $bm)) {
return;
}
$boundary = '--' . ($bm[1] ?: $bm[2]);
$endBoundary = $boundary . '--';
$parts = explode($boundary, $body);
array_shift($parts); // before first boundary
foreach ($parts as $part) {
$part = ltrim($part, "\r\n");
if ($part === '--' || $part === "--\r\n" || $part === '') continue;
if (strpos($part, '--') === 0) continue; // end boundary
// Split headers from body
$headerEnd = strpos($part, "\r\n\r\n");
if ($headerEnd === false) continue;
$headerBlock = substr($part, 0, $headerEnd);
$partBody = substr($part, $headerEnd + 4);
// Remove trailing \r\n
if (substr($partBody, -2) === "\r\n") {
$partBody = substr($partBody, 0, -2);
}
// Parse part headers
$partHeaders = array();
foreach (explode("\r\n", $headerBlock) as $line) {
$colonPos = strpos($line, ':');
if ($colonPos !== false) {
$k = strtolower(trim(substr($line, 0, $colonPos)));
$v = trim(substr($line, $colonPos + 1));
$partHeaders[$k] = $v;
}
}
$disp = $partHeaders['content-disposition'] ?? '';
if (strpos($disp, 'form-data') === false) continue;
// Extract name
$name = null;
if (preg_match('/\bname="([^"]*)"/', $disp, $nm)) {
$name = $nm[1];
} elseif (preg_match("/\bname='([^']*)'/", $disp, $nm)) {
$name = $nm[1];
}
if ($name === null) continue;
// Check if it's a file upload
$filename = null;
if (preg_match('/\bfilename="([^"]*)"/', $disp, $fm)) {
$filename = $fm[1];
} elseif (preg_match("/\bfilename='([^']*)'/", $disp, $fm)) {
$filename = $fm[1];
}
if ($filename !== null) {
// File upload — write to temp file
$tmpPath = tempnam(sys_get_temp_dir(), 'qbix_upload_');
file_put_contents($tmpPath, $partBody);
$fileEntry = array(
'name' => $filename,
'type' => $partHeaders['content-type'] ?? 'application/octet-stream',
'tmp_name' => $tmpPath,
'error' => UPLOAD_ERR_OK,
'size' => strlen($partBody),
);
// Handle array notation: files[0], files[photo], etc.
if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) {
$files[$am[1]]['name'][$am[2]] = $fileEntry['name'];
$files[$am[1]]['type'][$am[2]] = $fileEntry['type'];
$files[$am[1]]['tmp_name'][$am[2]] = $fileEntry['tmp_name'];
$files[$am[1]]['error'][$am[2]] = $fileEntry['error'];
$files[$am[1]]['size'][$am[2]] = $fileEntry['size'];
} else {
$files[$name] = $fileEntry;
}
} else {
// Regular form field
// Handle array notation: tags[], data[key], etc.
if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) {
if ($am[2] === '') {
$post[$am[1]][] = $partBody;
} else {
$post[$am[1]][$am[2]] = $partBody;
}
} else {
$post[$name] = $partBody;
}
}
}
}
// ── Response helpers ─────────────────────────────────
static function sendResponse($client, $status, $body, $type = 'text/plain; charset=utf-8', $extra = array())
{
static $reasons = array(
200=>'OK', 301=>'Moved Permanently', 304=>'Not Modified',
400=>'Bad Request', 403=>'Forbidden', 404=>'Not Found',
413=>'Payload Too Large', 429=>'Too Many Requests',
431=>'Request Header Fields Too Large',
500=>'Internal Server Error', 502=>'Bad Gateway'
);
self::$lastStatus = $status;
self::$lastBody = $body;
$body = (string) $body;
$conn = $extra['Connection'] ?? 'keep-alive';
unset($extra['Connection']);
$out = "HTTP/1.1 $status " . ($reasons[$status] ?? 'OK')
. "\r\nContent-Type: $type\r\nContent-Length: " . strlen($body)
. "\r\nConnection: $conn\r\n";
foreach ($extra as $k => $v) $out .= "$k: $v\r\n";
@fwrite($client, $out . "\r\n" . $body);
}
private static function sendRedirect($client, $loc) {
@fwrite($client, "HTTP/1.1 301 Moved Permanently\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
self::$lastStatus = 301;
}
private static function sendNotModified($client, $etag, $mtime, $keepAlive = false) {
$conn = $keepAlive ? 'keep-alive' : 'close';
@fwrite($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n"
. "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n"
. "Cache-Control: public, max-age=0, must-revalidate\r\nContent-Length: 0\r\nConnection: $conn\r\n\r\n");
self::$lastStatus = 304;
}
private static function render404($path)
{
$safe = htmlspecialchars($path, ENT_QUOTES);
return "<!DOCTYPE html><html><head><title>404</title>"
. "<style>body{font-family:sans-serif;padding:40px;text-align:center;color:#666}"
. "h1{font-size:72px;color:#ddd}p{margin-top:12px}</style></head>"
. "<body><h1>404</h1><p>{$safe} not found</p></body></html>";
}
// ── Path resolution ──────────────────────────────────
/**
* Parse a Cookie header string into an associative array
* @method parseCookieHeader
* @static
* @param {string} $header The raw Cookie header value
* @return {array} name => value pairs
*/
static function parseCookieHeader($header)
{
$cookies = array();
if (empty($header)) return $cookies;
$pairs = explode(';', $header);
foreach ($pairs as $pair) {
$pair = trim($pair);
if ($pair === '') continue;
$eq = strpos($pair, '=');
if ($eq === false) {
$cookies[$pair] = '';
} else {
$name = trim(substr($pair, 0, $eq));
$value = trim(substr($pair, $eq + 1));
$cookies[$name] = urldecode($value);
}
}
return $cookies;
}
/**
* Check rate limit for a client IP. Returns true if allowed, false if over limit.
* Configured via Q.webserver.rateLimit:
* { "enabled": true, "requests": 100, "window": 60, "burstRequests": 20, "burstWindow": 1 }
* @method checkRateLimit
* @static
* @param {string} $ip Client IP address
* @return {boolean} true if request is allowed
*/
static function checkRateLimit($ip)
{
static $rateLimitEnabled = null;
if ($rateLimitEnabled === null) $rateLimitEnabled = Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false);
if (!$rateLimitEnabled) {
return true;
}
$now = time();
static $maxReqs = null;
if ($maxReqs === null) $maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100);
static $window = null;
if ($window === null) $window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60);
static $burstReqs = null;
if ($burstReqs === null) $burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20);
static $burstWindow = null;
if ($burstWindow === null) $burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1);
// Clean old entries
if (!isset(self::$rateLimitData[$ip])) {
self::$rateLimitData[$ip] = array();
}
$hits = &self::$rateLimitData[$ip];
$cutoff = $now - $window;
$hits = array_filter($hits, function ($t) use ($cutoff) {
return $t >= $cutoff;
});
// Check window limit
if (count($hits) >= $maxReqs) {
return false;
}
// Check burst limit
$burstCutoff = $now - $burstWindow;
$recent = array_filter($hits, function ($t) use ($burstCutoff) {
return $t >= $burstCutoff;
});
if (count($recent) >= $burstReqs) {
return false;
}
$hits[] = $now;
// Periodic cleanup: remove IPs not seen in the last window
if (mt_rand(0, 99) < 5) { // 5% chance per request
foreach (self::$rateLimitData as $k => $v) {
if (empty($v) || max($v) < $cutoff) {
unset(self::$rateLimitData[$k]);
}
}
}
return true;
}
private static function resolveStatic($urlPath)
{
// Path resolution cache — avoids repeated realpath() syscalls
static $pathCache = array();
if (isset($pathCache[$urlPath])) {
$cached = $pathCache[$urlPath];
// Quick mtime check for invalidation (cheaper than realpath)
if ($cached === null || file_exists($cached)) {
return $cached;
}
unset($pathCache[$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) {
// Cache negative results too (404s won't re-stat)
if (count($pathCache) < 10000) $pathCache[$urlPath] = null;
return null;
}
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
$root = rtrim(self::$rootDir, DS);
if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) {
$pathCache[$urlPath] = null;
return null; // path traversal
}
$result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null;
if (count($pathCache) < 10000) $pathCache[$urlPath] = $result;
return $result;
}
private static function closeClient($key)
{
if (isset(self::$clientWatchers[$key])) {
Q_Evented::cancel(self::$clientWatchers[$key]);
unset(self::$clientWatchers[$key]);
}
if (isset(self::$timeoutWatchers[$key])) {
Q_Evented::cancel(self::$timeoutWatchers[$key]);
unset(self::$timeoutWatchers[$key]);
}
if (isset(self::$clients[$key])) {
@fclose(self::$clients[$key]);
unset(self::$clients[$key]);
}
unset(self::$buffers[$key], self::$clientInfo[$key],
self::$keepAliveCount[$key]);
}
// ── State ────────────────────────────────────────────
private static $socket = null;
private static $tlsSocket = null;
private static $tlsWatcher = null;
private static $tlsPending = array();
private static $httpsPort = 0;
static $clients = array();
static $clientWatchers = array();
private static $buffers = array();
private static $clientInfo = array(); // key => [ip, connectTime]
private static $keepAliveCount = array(); // key => int
private static $timeoutWatchers = array(); // key => evented timer id
private static $acceptWatcher = null;
private static $running = false;
private static $lastStatus = 200;
private static $lastBody = '';
static $allowedExtensions = array(
'html','htm','txt','md','json','xml','yaml','yml','csv','tsv','log',
'css','js','mjs','map','wasm',
'png','gif','webp','jpg','jpeg','svg','bmp','ico','avif',
'woff','woff2','ttf','otf',
'mp3','wav','ogg','mp4','webm',
'pdf','zip'
);
private static $rateLimitData = array(); // ip => [timestamps]
// ── Static file response cache ──────────────────────
// Caches full response bytes (headers+body) keyed by fsPath.
// Invalidated on mtime change. Saves stat/read/header-build per request.
private static $fileCache = array(); // fsPath => [mtime, size, etag, responses => [connType => bytes]]
private static $fileCacheSize = 0; // total bytes in cache
private static $fileCacheMaxSize = 67108864; // 64MB default, configurable
private static $fileCacheMaxFile = 1048576; // don't cache files > 1MB
private static $fileCacheCheckInterval = 1; // seconds between mtime checks
private static $fileCacheLastCheck = 0;
}
<?php
/**
* @module Q
*/
/**
* Maintains a versioned, diffable cache of hashed content.
*
* Provides the scan → hash → snapshot → diff lifecycle used by
* scripts/Q/urls.php (static-file cache-busting) and by the
* IndieWeb plugin (feed generation from rendered HTML), but is
* generic enough for any workflow that needs to detect content
* changes, store snapshots, and compute incremental diffs.
*
* Directory structure it manages:
*
* $configDir/
* $name.php ↠var_export array for fast include()
* $name/
* entries/
* {timestamp}.json ↠permanent snapshots
* latest.json ↠copy of most recent snapshot
* diffs/
* {timestamp}.json ↠diff from that snapshot to current
*
* @class Q_Snapshot
*/
class Q_Snapshot
{
/**
* @property $name
* @type string
*/
public $name;
/**
* @property $configDir
* @type string
*/
public $configDir;
/**
* @property $webDir
* @type string|null
*/
public $webDir;
/**
* @property $time
* @type integer
*/
public $time;
/**
* @property $earliest
* @type integer
*/
public $earliest;
/**
* @property $previous
* @type array|null
*/
public $previous;
protected $entriesDir;
protected $diffsDir;
protected $parentDir;
/**
* @method __construct
* @param {string} $name Identifier like 'urls' or 'feeds'
* @param {string} $configDir Where to store snapshots
* @param {string|null} [$webDir=null] Symlink target for web access
*/
function __construct($name, $configDir, $webDir = null)
{
$this->name = $name;
$this->configDir = $configDir;
$this->webDir = $webDir;
$this->time = time();
$this->parentDir = $configDir . DS . $name;
$this->entriesDir = $this->parentDir . DS . 'entries';
$this->diffsDir = $this->parentDir . DS . 'diffs';
foreach (array(
$configDir, $this->parentDir,
$this->entriesDir, $this->diffsDir
) as $dir) {
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
}
if ($webDir && is_dir($this->parentDir) && !file_exists($webDir)) {
Q_Utils::symlink($this->parentDir, $webDir);
}
$this->earliest = $this->time;
$this->previous = null;
$json = file_get_contents($this->entriesDir . DS . 'latest.json');
if ($json !== false) {
$this->previous = Q::json_decode($json, true);
if (!empty($this->previous['@earliest'])) {
$this->earliest = $this->previous['@earliest'];
}
}
}
/**
* @method hash
* @static
* @param {string} $content
* @param {string} [$algo='sha256']
* @return {string} base64-encoded hash
*/
static function hash($content, $algo = 'sha256')
{
return base64_encode(hash($algo, $content, true));
}
/**
* Check whether content has changed since last snapshot.
* @method changed
* @param {string} $key Path into the tree
* @param {string} $hash base64 hash of current content
* @param {integer|null} [$mtime=null] File mtime for fast skip
* @return {boolean}
*/
function changed($key, $hash, $mtime = null)
{
if ($mtime !== null && $mtime <= $this->earliest) {
return false;
}
if ($this->previous) {
$parts = is_array($key) ? $key : explode(DS, $key);
$prev = $this->previous;
foreach ($parts as $part) {
if (!isset($prev[$part])) return true;
$prev = $prev[$part];
}
if (is_array($prev) && isset($prev['h']) && $prev['h'] === $hash) {
return false;
}
}
return true;
}
/**
* Save a result tree as the current snapshot.
* @method save
* @param {array} $result
* @return {Q_Snapshot}
*/
function save(array $result)
{
$result['@timestamp'] = $this->time;
if (empty($result['@earliest'])) {
$result['@earliest'] = $this->earliest;
}
$json = Q::json_encode($result);
file_put_contents($this->entriesDir . DS . $this->time . '.json', $json);
file_put_contents($this->entriesDir . DS . 'latest.json', $json);
$export = Q::var_export($result);
file_put_contents($this->configDir . DS . $this->name . '.php', "<?php\nreturn $export;");
$this->previous = $result;
return $this;
}
/**
* Generate diff files from every historical snapshot to current.
* @method diffs
* @param {array} $currentResult
* @return {integer} Number of diffs generated
*/
function diffs(array $currentResult)
{
$files = glob($this->diffsDir . DS . '*');
foreach ($files as $file) {
if (is_file($file)) unlink($file);
}
$currentTree = new Q_Tree($currentResult);
$filenames = glob($this->entriesDir . DS . '*');
$i = 0;
$n = count($filenames) - 1;
foreach ($filenames as $g) {
$b = basename($g);
if ($b === 'latest.json') continue;
$t = new Q_Tree();
$t->load($g);
$diff = $t->diff($currentTree, false);
$diff->set('@timestamp', $this->time);
$diff->save($this->diffsDir . DS . $b);
++$i;
echo "\033[100D";
echo "Generated $i of $n diff files ";
}
return $i;
}
/**
* Load cached snapshot from the PHP file (for runtime).
* @method load
* @return {array|null}
*/
function load()
{
$f = $this->configDir . DS . $this->name . '.php';
return file_exists($f) ? include($f) : null;
}
/**
* Update a single key without full rescan.
* @method update
* @param {string} $key
* @param {string} $hash
* @param {array} [$metadata=array()]
* @return {boolean}
*/
function update($key, $hash, array $metadata = array())
{
if (!$this->changed($key, $hash)) return false;
$cached = $this->load() ?: array();
$value = array_merge(array('t' => $this->time, 'h' => $hash), $metadata);
$tree = new Q_Tree($cached);
$parts = explode(DS, $key);
$parts[] = $value;
call_user_func_array(array($tree, 'set'), $parts);
$this->save($tree->getAll());
return true;
}
function entriesDir() { return $this->entriesDir; }
function diffsDir() { return $this->diffsDir; }
}
<?php
abstract class Q_Evented_Driver
{
abstract function onReadable($stream, callable $cb);
abstract function onWritable($stream, callable $cb);
abstract function delay($sec, callable $cb);
abstract function repeat($sec, callable $cb);
abstract function defer(callable $cb);
abstract function onSignal($sig, callable $cb);
abstract function cancel($id);
abstract function disable($id);
abstract function enable($id);
abstract function run();
abstract function tick($timeout = 0);
abstract function stop();
abstract function running();
}
<?php
/**
* @module Q
*/
/**
* Built-in event loop using stream_select(). Zero dependencies.
* Handles stream watching, timers, deferred callbacks, signals.
*
* @class Q_Evented_StreamSelect
* @extends Q_Evented_Driver
*/
class Q_Evented_StreamSelect extends Q_Evented_Driver
{
protected $running = false;
protected $nextId = 1;
protected $readers = array(); // id => [stream, callback]
protected $writers = array(); // id => [stream, callback]
protected $timers = array(); // id => [fireAt, interval, callback]
protected $deferred = array(); // id => callback
protected $signals = array(); // id => [signal, callback]
protected $disabled = array(); // id => true
protected $streamToReaders = array();
protected $streamToWriters = array();
function onReadable($stream, callable $cb)
{
$id = 'r' . ($this->nextId++);
$this->readers[$id] = array($stream, $cb);
$this->streamToReaders[(int)$stream][$id] = true;
return $id;
}
function onWritable($stream, callable $cb)
{
$id = 'w' . ($this->nextId++);
$this->writers[$id] = array($stream, $cb);
$this->streamToWriters[(int)$stream][$id] = true;
return $id;
}
function delay($sec, callable $cb)
{
$id = 'd' . ($this->nextId++);
$this->timers[$id] = array(
'fireAt' => microtime(true) + $sec,
'interval' => 0, 'callback' => $cb
);
return $id;
}
function repeat($sec, callable $cb)
{
$id = 't' . ($this->nextId++);
$this->timers[$id] = array(
'fireAt' => microtime(true) + $sec,
'interval' => $sec, 'callback' => $cb
);
return $id;
}
function defer(callable $cb)
{
$id = 'f' . ($this->nextId++);
$this->deferred[$id] = $cb;
return $id;
}
function onSignal($sig, callable $cb)
{
if (!function_exists('pcntl_signal')) {
throw new Exception("Signal handling requires pcntl extension");
}
$id = 's' . ($this->nextId++);
$this->signals[$id] = array($sig, $cb);
$signals = &$this->signals;
$disabled = &$this->disabled;
pcntl_signal($sig, function ($s) use (&$signals, &$disabled) {
foreach ($signals as $sid => $entry) {
if ($entry[0] === $s && empty($disabled[$sid])) {
$entry[1]($s);
}
}
});
return $id;
}
function cancel($id)
{
if (isset($this->readers[$id])) {
$key = (int)$this->readers[$id][0];
unset($this->readers[$id], $this->streamToReaders[$key][$id]);
if (empty($this->streamToReaders[$key])) unset($this->streamToReaders[$key]);
}
if (isset($this->writers[$id])) {
$key = (int)$this->writers[$id][0];
unset($this->writers[$id], $this->streamToWriters[$key][$id]);
if (empty($this->streamToWriters[$key])) unset($this->streamToWriters[$key]);
}
unset($this->timers[$id], $this->deferred[$id],
$this->signals[$id], $this->disabled[$id]);
}
function disable($id) { $this->disabled[$id] = true; }
function enable($id) { unset($this->disabled[$id]); }
function running() { return $this->running; }
function stop() { $this->running = false; }
function run()
{
$this->running = true;
while ($this->running && $this->hasWatchers()) {
$this->tick(null);
}
$this->running = false;
}
function tick($timeout = 0)
{
// 1. Deferred callbacks
if (!empty($this->deferred)) {
$batch = $this->deferred;
$this->deferred = array();
foreach ($batch as $id => $cb) {
if (empty($this->disabled[$id])) $cb();
}
}
// 2. Timers
$now = microtime(true);
$nextTimer = null;
foreach ($this->timers as $id => $t) {
if (!empty($this->disabled[$id])) continue;
if ($now >= $t['fireAt']) {
$t['callback']();
if ($t['interval'] > 0) {
$this->timers[$id]['fireAt'] = $now + $t['interval'];
} else {
unset($this->timers[$id]);
}
} else {
$rem = $t['fireAt'] - $now;
if ($nextTimer === null || $rem < $nextTimer) $nextTimer = $rem;
}
}
// 3. Signals
if (function_exists('pcntl_signal_dispatch')) pcntl_signal_dispatch();
// 4. Stream select
$read = $write = array();
foreach ($this->readers as $id => $e) {
if (empty($this->disabled[$id])) $read[] = $e[0];
}
foreach ($this->writers as $id => $e) {
if (empty($this->disabled[$id])) $write[] = $e[0];
}
if (empty($read) && empty($write)) {
if ($nextTimer !== null) {
$sleep = ($timeout !== null) ? min($nextTimer, $timeout) : $nextTimer;
if ($sleep > 0) usleep((int)($sleep * 1000000));
}
return;
}
$wait = $timeout;
if ($nextTimer !== null) {
$wait = ($wait !== null) ? min($wait, $nextTimer) : $nextTimer;
}
$sec = ($wait !== null) ? (int)$wait : null;
$usec = ($wait !== null) ? (int)(($wait - (int)$wait) * 1000000) : null;
$except = null;
$n = @stream_select($read, $write, $except, $sec, $usec);
if ($n === false) return;
foreach ($read as $stream) {
$key = (int)$stream;
if (!isset($this->streamToReaders[$key])) continue;
foreach ($this->streamToReaders[$key] as $id => $_) {
if (empty($this->disabled[$id]) && isset($this->readers[$id])) {
$this->readers[$id][1]($stream);
}
}
}
foreach ($write as $stream) {
$key = (int)$stream;
if (!isset($this->streamToWriters[$key])) continue;
foreach ($this->streamToWriters[$key] as $id => $_) {
if (empty($this->disabled[$id]) && isset($this->writers[$id])) {
$this->writers[$id][1]($stream);
}
}
}
}
protected function hasWatchers()
{
return !empty($this->readers) || !empty($this->writers)
|| !empty($this->timers) || !empty($this->deferred)
|| !empty($this->signals);
}
}
<?php
/**
* @module Q
*/
use Revolt\EventLoop;
/**
* Event loop driver backed by Revolt (amphp's event loop).
* Install via: composer require revolt/event-loop
*
* @class Q_Evented_Revolt
* @extends Q_Evented_Driver
*/
class Q_Evented_Revolt extends Q_Evented_Driver
{
protected $running = false;
function onReadable($stream, callable $cb)
{
return EventLoop::onReadable($stream, function ($id, $s) use ($cb) {
$cb($s);
});
}
function onWritable($stream, callable $cb)
{
return EventLoop::onWritable($stream, function ($id, $s) use ($cb) {
$cb($s);
});
}
function delay($seconds, callable $cb)
{
return EventLoop::delay($seconds, function () use ($cb) {
$cb();
});
}
function repeat($seconds, callable $cb)
{
return EventLoop::repeat($seconds, function () use ($cb) {
$cb();
});
}
function defer(callable $cb)
{
return EventLoop::defer(function () use ($cb) {
$cb();
});
}
function onSignal($signal, callable $cb)
{
return EventLoop::onSignal($signal, function ($id, $s) use ($cb) {
$cb($s);
});
}
function cancel($id) { EventLoop::cancel($id); }
function disable($id) { EventLoop::disable($id); }
function enable($id) { EventLoop::enable($id); }
function run()
{
$this->running = true;
EventLoop::run();
$this->running = false;
}
function tick($timeout = 0)
{
EventLoop::delay($timeout ?: 0.0, function () {});
EventLoop::run();
}
function stop()
{
$this->running = false;
}
function running()
{
return $this->running;
}
}
<?php
/**
* Standalone Q shim for Qbix Server.
*
* Provides the core Q framework functionality needed to run
* the server and user PHP scripts without the full Qbix Platform.
* When running inside the full Platform (--app mode), this file
* is never loaded — the real Q class takes over.
*
* Includes:
* - Autoloader for both underscore (Q_WebServer) and namespace (MyApp\User) styles
* - Q::ifset() for safe nested array/object access
* - Q::event() with handlers/ folder convention
* - Q::view() for rendering PHP templates
* - Q_Config for JSON config file loading
*
* @module Q
*/
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
class Q
{
/**
* Directories to search for classes/ and handlers/
* Set by the server at startup based on --root and project structure
* @property $paths
* @type array
* @static
*/
static $paths = array();
/**
* Safe nested array/object access. Returns $default if any key is missing.
*
* Q::ifset($arr, 'key1', 'key2', $default)
* Q::ifset($obj, 'prop', $default)
*
* @method ifset
* @static
* @param {&mixed} $ref The array or object to traverse
* @return {mixed}
*/
static function ifset(&$ref)
{
$count = func_num_args();
if ($count <= 2) {
$args = func_get_args();
$def = isset($args[1]) ? $args[1] : null;
return isset($ref) ? $ref : $def;
}
$args = func_get_args();
$def = end($args);
$path = array_slice($args, 1, -1);
return self::getObject($ref, $path, $def);
}
/**
* Get a value deep inside an array or object.
*
* Q::getObject($data, ['users', 'alice', 'email'], 'default')
*
* @method getObject
* @static
* @param {&mixed} $ref The array or object to traverse
* @param {array} $path Array of keys/properties to follow
* @param {mixed} $def Default if path not found
* @return {mixed}
*/
static function getObject(&$ref, $path, $def = null)
{
$cur = $ref;
foreach ($path as $key) {
if (is_array($cur)) {
if (!array_key_exists($key, $cur)) return $def;
$cur = $cur[$key];
} elseif (is_object($cur)) {
if (!isset($cur->$key)) return $def;
$cur = $cur->$key;
} else {
return $def;
}
}
return $cur;
}
/**
* Set a value deep inside a nested array, creating intermediate arrays as needed.
*
* Q::setObject(['users', 'alice', 'email'], 'alice@example.com', $data)
*
* @method setObject
* @static
* @param {array} $path
* @param {mixed} $value
* @param {&array} $dest The target array (modified by reference)
*/
static function setObject($path, $value, &$dest)
{
if (is_string($path)) $path = array($path);
$ref = &$dest;
foreach ($path as $key) {
if (!isset($ref[$key]) || !is_array($ref[$key])) {
$ref[$key] = array();
}
$ref = &$ref[$key];
}
$ref = $value;
}
/**
* JSON encode with unescaped slashes
* @method json_encode
* @static
*/
static function json_encode($value, $options = 0)
{
return json_encode($value, $options | JSON_UNESCAPED_SLASHES);
}
/**
* JSON decode wrapper
* @method json_decode
* @static
*/
static function json_decode($json, $assoc = false, $depth = 512, $options = 0)
{
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 ────────────────────────────────────
/**
* Fire an event. Looks for handler functions in handlers/ directory.
*
* Handler for "MyApp/feed/post" lives at:
* handlers/MyApp/feed/post.php
* And defines:
* function MyApp_feed_post($params) { ... }
*
* @method event
* @static
* @param {string} $eventName e.g. "MyApp/feed/post"
* @param {array} $params Parameters passed to the handler
* @param {string|boolean} $pure false=run handler, 'before'=before hooks only,
* 'after'=after hooks only, true=both hooks but skip main handler
* @param {boolean} $skipIncludes If true, only call already-defined functions
* @param {mixed} &$result Reference for handlers to modify
* @return {mixed} Whatever the handler returned
*/
static function event(
$eventName,
$params = array(),
$pure = false,
$skipIncludes = false,
&$result = null)
{
if (!is_string($eventName) || !$eventName) return null;
if (!is_array($params)) $params = array();
// Before hooks
if ($pure !== 'after') {
$handlers = Q_Config::get('Q', 'handlersBeforeEvent', $eventName, array());
if (is_string($handlers)) $handlers = array($handlers);
if (is_array($handlers)) {
foreach ($handlers as $handler) {
$r = self::handle($handler, $params, $skipIncludes, $result);
if ($r === false) return $result;
}
}
}
// Main handler
if (!$pure) {
$result = self::handle($eventName, $params, $skipIncludes, $result);
}
// After hooks
if ($pure !== 'before') {
$handlers = Q_Config::get('Q', 'handlersAfterEvent', $eventName, array());
if (is_string($handlers)) $handlers = array($handlers);
if (is_array($handlers)) {
foreach ($handlers as $handler) {
$r = self::handle($handler, $params, $skipIncludes, $result);
if ($r === false) return $result;
}
}
}
return $result;
}
/**
* Check if a handler exists for an event name
* @method canHandle
* @static
* @param {string} $eventName
* @return {boolean}
*/
static function canHandle($eventName)
{
$parts = explode('/', $eventName);
$funcName = str_replace('-', '_', implode('_', $parts));
if (function_exists($funcName)) return true;
// Try to load from handlers/ directory
$relPath = 'handlers' . DS . implode(DS, $parts) . '.php';
foreach (self::$paths as $base) {
$full = $base . DS . $relPath;
if (file_exists($full)) {
include_once $full;
return function_exists($funcName);
}
}
return false;
}
/**
* Execute a handler function. Loads from handlers/ directory if needed.
* If $eventName starts with http:// or https://, POSTs params as JSON
* to that URL (remote handler / webhook).
* @method handle
* @static
* @param {string} $eventName
* @param {array} &$params
* @param {boolean} $skipIncludes
* @param {mixed} &$result
* @return {mixed}
*/
protected static function handle(
$eventName, &$params = array(), $skipIncludes = false, &$result = null)
{
if (!$eventName) return null;
// Remote handler — POST params as JSON to URL
if (strncmp($eventName, 'http://', 7) === 0
|| strncmp($eventName, 'https://', 8) === 0
) {
return self::handleRemote($eventName, $params, $result);
}
$parts = explode('/', $eventName);
$funcName = str_replace('-', '_', implode('_', $parts));
if (!function_exists($funcName)) {
if ($skipIncludes) return null;
// Try to load from handlers/ directory
$relPath = 'handlers' . DS . implode(DS, $parts) . '.php';
$loaded = false;
foreach (self::$paths as $base) {
$full = $base . DS . $relPath;
if (file_exists($full)) {
include_once $full;
$loaded = true;
break;
}
}
if (!$loaded || !function_exists($funcName)) {
return null; // no handler found — that's OK
}
}
$args = array(&$params, &$result);
return call_user_func_array($funcName, $args);
}
/**
* POST event params as JSON to a remote URL.
* Used for webhook-style handlers configured in Q.handlersAfterEvent.
* Non-blocking: uses a short timeout so it doesn't slow down the request.
* @method handleRemote
* @static
* @param {string} $url
* @param {array} &$params
* @param {mixed} &$result
* @return {mixed}
*/
protected static function handleRemote($url, &$params, &$result)
{
$json = json_encode($params, JSON_UNESCAPED_SLASHES);
$opts = array('http' => array(
'method' => 'POST',
'header' => "Content-Type: application/json\r\n"
. "Content-Length: " . strlen($json) . "\r\n"
. "User-Agent: QbixServer/1.0\r\n",
'content' => $json,
'timeout' => 5,
'ignore_errors' => true,
));
$ctx = stream_context_create($opts);
$response = @file_get_contents($url, false, $ctx);
if ($response !== false) {
$decoded = json_decode($response, true);
if ($decoded !== null) {
$result = $decoded;
}
}
return $result;
}
/**
* Render a PHP view file. Searches views/ directories in $paths.
*
* echo Q::view('MyApp/feed/page.php', ['items' => $items]);
*
* @method view
* @static
* @param {string} $viewName Path relative to views/ directory
* @param {array} $params Variables extracted into the view scope
* @return {string} Rendered HTML
*/
static function view($viewName, $params = array())
{
$viewPath = str_replace('/', DS, $viewName);
foreach (self::$paths as $base) {
$full = $base . DS . 'views' . DS . $viewPath;
if (file_exists($full)) {
extract($params);
ob_start();
include $full;
return ob_get_clean();
}
}
return "<!-- view not found: $viewName -->";
}
// ── Autoloader ──────────────────────────────────────
/**
* Autoloader that handles both conventions:
* Q_WebServer → classes/Q/WebServer.php (underscore)
* MyApp\User → classes/MyApp/User.php (namespace)
* MyApp_Helper → classes/MyApp/Helper.php (underscore)
*
* Searches the src/ directory (for Q_ server classes) and all
* directories in Q::$paths (for user classes).
*
* @method autoload
* @static
* @param {string} $className
*/
static function autoload($className)
{
// Split on both \ and _ to get path parts
$parts = array();
foreach (explode('\\', $className) as $nsPart) {
$parts = array_merge($parts, explode('_', $nsPart));
}
$relPath = implode(DS, $parts) . '.php';
// 1. Search src/ directory (for Q_* server classes)
$srcPath = dirname(__FILE__) . DS . $relPath;
if (file_exists($srcPath)) {
require_once $srcPath;
return;
}
// 2. Search project classes/ directories
foreach (self::$paths as $base) {
$full = $base . DS . 'classes' . DS . $relPath;
if (file_exists($full)) {
require_once $full;
// If loaded via underscore but also accessible via namespace, alias
$underscoreName = implode('_', $parts);
$namespaceName = implode('\\', $parts);
if ($underscoreName !== $namespaceName) {
if (class_exists($underscoreName, false)
&& !class_exists($namespaceName, false)
) {
class_alias($underscoreName, $namespaceName);
} elseif (class_exists($namespaceName, false)
&& !class_exists($underscoreName, false)
) {
class_alias($namespaceName, $underscoreName);
}
}
return;
}
}
}
/**
* Initialize Q paths from the project root directory.
* Called by the server at startup.
* @method init
* @static
* @param {string} $projectRoot The project root (parent of web/)
*/
static function init($projectRoot)
{
$projectRoot = rtrim($projectRoot, DS);
if (!in_array($projectRoot, self::$paths)) {
self::$paths[] = $projectRoot;
}
}
}
spl_autoload_register(array('Q', 'autoload'));
// ── Q_Socket ────────────────────────────────────────
/**
* PHP API for WebSocket handlers — sending messages, managing rooms.
*
* Each WebSocket connection gets one PHP process. The server dispatches
* messages via Q::event() to handlers. Handlers use Q_Socket to send
* data back. Static variables in handlers persist across messages
* (same process) and are wiped on disconnect (process dies).
*
* @class Q_Socket
*/
class Q_Socket
{
/** @var resource IPC pipe to parent */
static $_pipe = null;
/** @var integer Current client's socket key */
static $_socketId = null;
/** @var integer|null Ack ID from current message */
static $_ack = null;
/** @var boolean True when running in-process (no fork) */
static $_directMode = false;
/** @var array Buffered outbound commands */
static $_buffer = array();
/**
* Send data to the client that owns this connection.
*/
static function reply($data)
{
self::send(self::$_socketId, $data);
}
/**
* Send data to a specific connected client.
*/
static function send($socketId, $data)
{
self::_command(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data));
}
/**
* Broadcast to all clients in a room/channel.
*/
static function broadcast($room, $data)
{
self::_command(array('cmd' => 'broadcast', 'room' => $room, 'data' => $data));
}
/**
* Broadcast to ALL connected WebSocket clients.
*/
static function broadcastAll($data)
{
self::_command(array('cmd' => 'broadcastAll', 'data' => $data));
}
/**
* Subscribe a client to a room/channel.
*/
static function join($socketId, $room)
{
self::_command(array('cmd' => 'join', 'socketId' => $socketId, 'room' => $room));
}
/**
* Unsubscribe a client from a room/channel.
*/
static function leave($socketId, $room)
{
self::_command(array('cmd' => 'leave', 'socketId' => $socketId, 'room' => $room));
}
/**
* Buffer a command or execute directly in-process.
*/
private static function _command($cmd)
{
if (self::$_directMode) {
Q_WebSocket::executeCommand($cmd);
} else {
self::$_buffer[] = $cmd;
}
}
/**
* Flush buffered commands to the IPC pipe.
* Called automatically after each handler invocation.
*/
static function flush()
{
if (!self::$_pipe || empty(self::$_buffer)) return;
$out = '';
foreach (self::$_buffer as $cmd) {
$out .= json_encode($cmd, JSON_UNESCAPED_SLASHES) . "\n";
}
@fwrite(self::$_pipe, $out);
self::$_buffer = array();
}
}
// ── 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.
*
* @class Q_Request
*/
class Q_Request
{
/**
* Raw request body. Set by the server before your script runs.
* Use this instead of php://input (which doesn't work in our model).
* @property $input
* @type string
* @static
*/
static $input = '';
/**
* Get the HTTP method (GET, POST, PUT, DELETE, etc.)
* @method method
* @static
* @return {string}
*/
static function method()
{
return $_SERVER['REQUEST_METHOD'] ?? 'GET';
}
/**
* Get the raw request body.
* @method input
* @static
* @return {string}
*/
static function input()
{
return self::$input;
}
/**
* Get the request body parsed as JSON.
* @method json
* @static
* @param {boolean} $assoc Return associative array (default true)
* @return {array|object|null}
*/
static function json($assoc = true)
{
return json_decode(self::$input, $assoc);
}
/**
* Get the full request URL.
* @method url
* @static
* @param {boolean} $querystring Include query string (default true)
* @return {string}
*/
static function url($querystring = true)
{
$scheme = ($_SERVER['REQUEST_SCHEME'] ?? 'http');
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
$uri = $querystring
? ($_SERVER['REQUEST_URI'] ?? '/')
: ($_SERVER['SCRIPT_NAME'] ?? '/');
return $scheme . '://' . $host . $uri;
}
/**
* Get the URL path (without query string).
* @method path
* @static
* @return {string}
*/
static function path()
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$qPos = strpos($uri, '?');
return $qPos !== false ? substr($uri, 0, $qPos) : $uri;
}
/**
* Get a request header value.
* @method header
* @static
* @param {string} $name Header name (case-insensitive)
* @return {string|null}
*/
static function header($name)
{
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return $_SERVER[$key] ?? null;
}
/**
* Get the client's IP address (resolved through proxy headers by the server).
* @method ip
* @static
* @return {string}
*/
static function ip()
{
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
/**
* Check if the request is an AJAX/XHR request.
* @method isAjax
* @static
* @return {boolean}
*/
static function isAjax()
{
return strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest';
}
/**
* Get uploaded files. Convenience wrapper around $_FILES.
* @method files
* @static
* @param {string|null} $name Specific file input name, or null for all
* @return {array|null}
*/
static function files($name = null)
{
if ($name === null) return $_FILES;
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
* @static
* @return {string}
*/
static function contentType()
{
return $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '';
}
/**
* Check if the request body is JSON.
* @method isJson
* @static
* @return {boolean}
*/
static function isJson()
{
return strpos(strtolower(self::contentType()), 'application/json') !== false;
}
/**
* Get a value from $_GET, $_POST, or $_REQUEST with a default.
* @method special
* @static
* @param {string} $name
* @param {mixed} $default
* @return {mixed}
*/
static function special($name, $default = null)
{
return $_REQUEST[$name] ?? $default;
}
}
// ── Q_Config ────────────────────────────────────────
/**
* JSON config file loader with deep merge.
* Compatible with the full Qbix Platform's Q_Config API.
*
* @class Q_Config
*/
class Q_Config
{
private static $data = array();
/**
* Load and merge a JSON config file
* @method load
* @static
* @param {string} $path Path to JSON file
*/
static function load($path)
{
if (!file_exists($path)) return;
$json = json_decode(file_get_contents($path), true);
if (is_array($json)) {
self::$data = self::merge(self::$data, $json);
}
}
/**
* Set a config value programmatically.
* Q_Config::set('Q', 'webserver', 'port', 8080)
* @method set
* @static
*/
static function set(/* key1, key2, ..., value */)
{
$args = func_get_args();
$value = array_pop($args);
$ref = &self::$data;
foreach ($args as $key) {
if (!isset($ref[$key]) || !is_array($ref[$key])) {
$ref[$key] = array();
}
$ref = &$ref[$key];
}
$ref = $value;
}
/**
* Get a config value with a default.
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
* Last argument is the default.
* @method get
* @static
* @return {mixed}
*/
static function get(/* key1, key2, ..., default */)
{
$args = func_get_args();
$default = array_pop($args);
$ref = self::$data;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
return $default;
}
$ref = $ref[$key];
}
return $ref;
}
/**
* Get a config value or throw if missing.
* Q_Config::expect('Q', 'app')
* @method expect
* @static
* @return {mixed}
* @throws {Exception}
*/
static function expect(/* key1, key2, ... */)
{
$args = func_get_args();
$ref = self::$data;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
throw new Exception("Missing config: " . implode('.', $args));
}
$ref = $ref[$key];
}
return $ref;
}
/**
* Get all config data
* @method getAll
* @static
* @return {array}
*/
static function getAll()
{
return self::$data;
}
/**
* Deep merge: arrays merge recursively, scalars overwrite.
* @method merge
* @static
* @private
*/
private static function merge($base, $overlay)
{
foreach ($overlay as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
$base[$key] = self::merge($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
}
,©®Î~UÜäô+sµNàIgÿy÷®ÄÙðOÃP´GBMB