Files
webserver/qbixserver.phar
T
Gregory Magarshak eaa711a4e5 Initial release — pure PHP web server, 55-73% of nginx throughput
Standalone server with zero dependencies beyond PHP 8.1+.
Static files with in-memory response cache, keep-alive, TCP_NODELAY,
gzip/brotli, WebSocket, live dashboard, rate limiting.

Includes PHAR builder and GitHub Actions CI for static binaries
(Linux x86_64, Linux ARM64, macOS x86_64, macOS Apple Silicon).
2026-07-20 10:26:59 -04:00

6388 lines
196 KiB
Plaintext
Raw 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('qbix-server.phar');
// Bootstrap
require 'phar://qbix-server.phar/Q.php';
// Parse args the same way as server.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 qbix-server.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(); ?>
tqbix-server.pharQ/FileCache.php× × T1פQ/WebServer/Pool.php^.^.^ýfp¤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.php))âG¹ì¤Q/WebServer/Log.phpK
K
ý«È¤Q/WebServer/Headers.phpÆ-Æ-Ò€?ò¤Q/WebSocket.php\\¤
Q/Evented.phpè(¬r¤Q/WebServer.php%Ê<AM¤Q/Snapshot.phpÝÝ“£ëk¤Q/Evented/Driver.php''ûhšÇ¤Q/Evented/StreamSelect.phpÁ¦.¤Q/Evented/Revolt.phpåå<Âé6¤Q.php·
·
탈¤<?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'
));
fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg);
}
/**
* 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) {
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) {
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: stats tracking, 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,
);
static $recentRequests = array();
static function init() { self::$stats['startTime'] = time(); }
static function recordRequest($method, $uri, $status, $ms)
{
self::$stats['requests']++;
if ($status < 300) self::$stats['status2xx']++;
elseif ($status < 400) self::$stats['status3xx']++;
elseif ($status < 500) self::$stats['status4xx']++;
else self::$stats['status5xx']++;
$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;
return array(
'uptime' => self::fmtUp($up), 'uptimeSec' => $up,
'requests' => self::$stats['requests'],
'status2xx' => self::$stats['status2xx'],
'status3xx' => self::$stats['status3xx'],
'status4xx' => self::$stats['status4xx'],
'status5xx' => self::$stats['status5xx'],
'memory' => round(memory_get_usage(true)/1048576, 1),
'memoryPeak' => round(memory_get_peak_usage(true)/1048576, 1),
'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'in-process',
'wsClients' => Q_WebSocket::clientCount(),
'cache' => Q_WebServer_Cache::stats(),
'components' => Q_WebServer_Cache_Components::enabled()
? Q_WebServer_Cache_Components::stats() : null,
);
}
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";
if ($s < 3600) return floor($s/60).'m '.($s%60).'s';
return floor($s/3600).'h '.floor(($s%3600)/60).'m';
}
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</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--sfc:#1a1d27;--bdr:#2a2d3a;--txt:#e1e4ed;--dim:#6b7089;
--ac:#7c8aff;--grn:#4ade80;--yel:#fbbf24;--red:#f87171;--cyn:#22d3ee}
body{font-family:'SF Mono','Fira Code',Consolas,monospace;background:var(--bg);
color:var(--txt);padding:24px;font-size:13px}
h1{font-size:18px;font-weight:600;margin-bottom:24px;color:var(--ac)}
h1 span{color:var(--dim);font-weight:400;font-size:13px;margin-left:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
.card{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;padding:16px}
.card .l{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
.card .v{font-size:24px;font-weight:700}
.card .s{font-size:11px;color:var(--dim);margin-top:4px}
.lc{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;overflow:hidden}
.lh{padding:12px 16px;border-bottom:1px solid var(--bdr);display:flex;justify-content:space-between;align-items:center}
.lh h2{font-size:13px;font-weight:600}
.lb{height:50vh;overflow-y:auto;padding:4px 0}
.le{padding:3px 16px;font-size:12px;display:flex;gap:12px;border-bottom:1px solid rgba(255,255,255,.03)}
.le:hover{background:rgba(255,255,255,.02)}
.lt{color:var(--dim);min-width:64px}.ls{min-width:28px;font-weight:700;text-align:right}
.lm{min-width:48px;color:var(--cyn)}.lu{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ld{color:var(--dim);min-width:60px;text-align:right}
.s2{color:var(--grn)}.s3{color:var(--yel)}.s4,.s5{color:var(--red)}
.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)}
@media(max-width:600px){body{padding:12px}.grid{grid-template-columns:repeat(2,1fr)}.lb{height:60vh}}
</style></head><body>
<h1>Qbix Server <span id="up"></span></h1>
<div class="grid">
<div class="card"><div class="l">Requests</div><div class="v" id="sr">0</div><div class="s" id="srps"></div></div>
<div class="card"><div class="l">Workers</div><div class="v" id="sw">—</div></div>
<div class="card"><div class="l">Memory</div><div class="v" id="sm">—</div><div class="s" id="smp"></div></div>
<div class="card"><div class="l">Status</div><div class="v" style="font-size:13px;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> err</div></div>
</div>
<div class="lc"><div class="lh"><h2>Live Requests</h2>
<div class="ws"><span class="wd" id="wd"></span><span id="wl">connecting</span></div></div>
<div class="lb" id="log"></div></div>
<script>
var S=$stats,R=$recent,L=document.getElementById('log');
function U(s){S=s;document.getElementById('sr').textContent=s.requests.toLocaleString();
document.getElementById('sw').textContent=s.workers;
document.getElementById('sm').textContent=s.memory+' MB';
document.getElementById('smp').textContent='peak '+s.memoryPeak+' MB';
document.getElementById('s2').textContent=s.status2xx;
document.getElementById('s3').textContent=s.status3xx;
document.getElementById('s4').textContent=s.status4xx;
document.getElementById('up').textContent=s.uptime;
document.getElementById('srps').textContent=(s.uptimeSec>0?(s.requests/s.uptimeSec).toFixed(1):'0')+' req/s'}
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">'+
e.uri.replace(/</g,'&lt;')+'</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';document.getElementById('wl').textContent='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';
document.getElementById('wl').textContent='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';
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";
}
@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)
{
// Check configured mappings first
$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);
// Ensure we don't escape the mapped directory
if ($real && strpos($real, realpath($diskPath)) === 0) {
return $real;
}
return null;
}
}
// Default: resolve relative to APP_DIR (not web root —
// the point is to serve files OUTSIDE the web root)
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) {
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.
* Works on the same port as Q_WebServer — HTTP requests are
* served normally, WebSocket upgrades are handed off here.
*
* Client-side uses the browser's native WebSocket API:
* var ws = new WebSocket('ws://localhost:8080/my/path');
*
* Server-side:
* // In a Q_Evented loop, after detecting Upgrade header:
* Q_WebSocket::upgrade($socket, $headers, function ($socket, $msg) {
* // handle incoming message
* });
*
* // Broadcast to all connected clients (or a channel):
* Q_WebSocket::broadcast(array('type' => 'update', 'data' => $data));
* Q_WebSocket::broadcastTo('dashboard', array('type' => 'stats'));
*
* @class Q_WebSocket
*/
class Q_WebSocket
{
const GUID = '258EAFA5-E914-47DA-95CA-5AB5DC587B41';
/**
* Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage]
* @property $clients
* @static
*/
static $clients = array();
/**
* Channel → subscriber map. channel => [socketKey => true]
* @property $channels
* @static
*/
static $channels = array();
/**
* Upgrade an HTTP connection to WebSocket.
* Performs the RFC 6455 handshake and registers the socket
* with Q_Evented for non-blocking frame reads.
*
* @method upgrade
* @static
* @param {resource} $socket The TCP socket (from Q_WebServer)
* @param {array} $headers Lowercase HTTP headers from the request
* @param {callable|null} [$onMessage=null] function($socketKey, $message)
* called when client sends a text frame
* @param {string|null} [$channel=null] Auto-subscribe to this channel
* @return {boolean} true if upgrade succeeded
*/
static function upgrade($socket, $headers, $onMessage = null, $channel = null)
{
$key = $headers['sec-websocket-key'] ?? '';
if (!$key) return false;
$accept = base64_encode(sha1($key . self::GUID, true));
$resp = "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\n"
. "Connection: Upgrade\r\n"
. "Sec-WebSocket-Accept: $accept\r\n\r\n";
fwrite($socket, $resp);
$sk = (int) $socket;
self::$clients[$sk] = array(
'socket' => $socket,
'watcher' => null,
'channels' => array(),
'buffer' => '',
'onMessage' => $onMessage
);
self::$clients[$sk]['watcher'] = Q_Evented::onReadable(
$socket,
function ($s) { Q_WebSocket::onData($s); }
);
if ($channel) {
self::subscribe($sk, $channel);
}
return true;
}
/**
* Handle incoming data on a WebSocket connection.
* Parses frames, dispatches text messages, handles
* ping/pong and close.
*
* @method onData
* @static
* @param {resource} $socket
*/
static function onData($socket)
{
$sk = (int) $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 (strlen(self::$clients[$sk]['buffer']) >= 2) {
$frame = self::decodeFrame(self::$clients[$sk]['buffer']);
if ($frame === null) break; // incomplete
self::$clients[$sk]['buffer'] = $frame['remaining'];
switch ($frame['opcode']) {
case 0x1: // Text frame
$cb = self::$clients[$sk]['onMessage'];
if ($cb) {
$cb($sk, $frame['payload']);
}
break;
case 0x8: // Close
self::encodeAndSend($socket, 0x8, '');
self::disconnect($sk);
return;
case 0x9: // Ping → Pong
self::encodeAndSend($socket, 0xA, $frame['payload']);
break;
case 0xA: // Pong — ignore
break;
}
}
}
// ── Sending ──────────────────────────────────────────
/**
* Send a text message to a specific client.
* @method send
* @static
* @param {integer} $socketKey
* @param {array|string} $data If array, JSON-encoded
*/
static function send($socketKey, $data)
{
if (!isset(self::$clients[$socketKey])) return;
$text = is_string($data) ? $data : json_encode($data);
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $text);
}
/**
* Broadcast to ALL connected clients.
* @method broadcast
* @static
* @param {array|string} $data
*/
static function broadcast($data)
{
$text = is_string($data) ? $data : json_encode($data);
foreach (self::$clients as $sk => $c) {
if (is_resource($c['socket'])) {
self::encodeAndSend($c['socket'], 0x1, $text);
} else {
self::disconnect($sk);
}
}
}
/**
* Broadcast to clients subscribed to a channel.
* @method broadcastTo
* @static
* @param {string} $channel
* @param {array|string} $data
*/
static function broadcastTo($channel, $data)
{
if (empty(self::$channels[$channel])) return;
$text = is_string($data) ? $data : json_encode($data);
foreach (self::$channels[$channel] as $sk => $_) {
if (!isset(self::$clients[$sk]) || !is_resource(self::$clients[$sk]['socket'])) {
unset(self::$channels[$channel][$sk]);
continue;
}
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $text);
}
}
// ── Channels ─────────────────────────────────────────
static function subscribe($socketKey, $channel)
{
self::$channels[$channel][$socketKey] = true;
self::$clients[$socketKey]['channels'][$channel] = true;
}
static function unsubscribe($socketKey, $channel)
{
unset(self::$channels[$channel][$socketKey]);
unset(self::$clients[$socketKey]['channels'][$channel]);
}
// ── Connection management ────────────────────────────
static function disconnect($sk)
{
if (!isset(self::$clients[$sk])) return;
$w = self::$clients[$sk]['watcher'];
if ($w) Q_Evented::cancel($w);
foreach (self::$clients[$sk]['channels'] as $ch => $_) {
unset(self::$channels[$ch][$sk]);
}
@fclose(self::$clients[$sk]['socket']);
unset(self::$clients[$sk]);
}
static function disconnectAll()
{
foreach (array_keys(self::$clients) as $sk) {
self::disconnect($sk);
}
}
static function clientCount()
{
return count(self::$clients);
}
// ── RFC 6455 frame encoding/decoding ─────────────────
/**
* Decode one frame from a buffer.
* @return {array|null} [opcode, payload, remaining] or null if incomplete
*/
static function decodeFrame(&$buf)
{
$len = strlen($buf);
if ($len < 2) return null;
$b0 = ord($buf[0]);
$b1 = ord($buf[1]);
$opcode = $b0 & 0x0F;
$masked = ($b1 & 0x80) !== 0;
$payloadLen = $b1 & 0x7F;
$offset = 2;
if ($payloadLen === 126) {
if ($len < 4) return null;
$payloadLen = unpack('n', substr($buf, 2, 2))[1];
$offset = 4;
} elseif ($payloadLen === 127) {
if ($len < 10) return null;
$payloadLen = unpack('J', substr($buf, 2, 8))[1];
$offset = 10;
}
$totalNeeded = $offset + ($masked ? 4 : 0) + $payloadLen;
if ($len < $totalNeeded) return null;
if ($masked) {
$mask = substr($buf, $offset, 4);
$offset += 4;
$payload = substr($buf, $offset, $payloadLen);
for ($i = 0; $i < $payloadLen; $i++) {
$payload[$i] = chr(ord($payload[$i]) ^ ord($mask[$i % 4]));
}
} else {
$payload = substr($buf, $offset, $payloadLen);
}
return array(
'opcode' => $opcode,
'payload' => $payload,
'remaining' => substr($buf, $offset + $payloadLen)
);
}
/**
* Encode and send a frame (server→client, unmasked).
*/
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);
}
}
<?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");
}
$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";
}
}
// ── 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();
});
}
Q_Evented::run();
}
// ── Connection handling ──────────────────────────────
static function onAccept($socket)
{
// Max connections check
$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
$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']);
// Determine keep-alive before handling request
$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 ──────────────────────────
$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. 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. Clean URL → route through index.php
$indexPhp = self::$rootDir . 'index.php';
if (is_file($indexPhp)) {
return self::handlePhp($client, $parsed, $indexPhp);
}
// 7. Not found
self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8');
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)
{
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;
}
// In-process: run through Headers for X-Accel-Redirect + compression
$parsed['_scriptPath'] = $scriptPath;
$response = self::dispatchToQ($parsed);
Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']);
self::$lastStatus = $response['status'] ?? 200;
// Store in cache if cacheable
Q_WebServer_Cache::put($parsed, $response);
return false;
}
// ── Static file serving ──────────────────────────────
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
$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)
{
$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);
$_SERVER['REQUEST_METHOD'] = $parsed['method'];
$_SERVER['REQUEST_URI'] = $parsed['uri'];
$_SERVER['QUERY_STRING'] = $parsed['query'];
$_SERVER['SCRIPT_NAME'] = '/' . basename($parsed['_scriptPath'] ?? 'index.php');
$_SERVER['SCRIPT_FILENAME'] = $parsed['_scriptPath'] ?? self::$rootDir . 'index.php';
$host = $parsed['headers']['host'] ?? 'localhost';
$_SERVER['SERVER_NAME'] = explode(':', $host)[0]; // strip port from Host header
$_SERVER['SERVER_PORT'] = self::$port;
$_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS);
foreach ($parsed['headers'] as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
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'];
$_GET = $_POST = $_REQUEST = array();
if ($parsed['query']) parse_str($parsed['query'], $_GET);
$ct = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
if (strpos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($parsed['body'], $_POST);
} elseif (strpos($ct, 'application/json') !== false) {
$_POST = json_decode($parsed['body'], true) ?: array();
}
$_REQUEST = array_merge($_GET, $_POST);
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';
}
}
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 json_encode(array('error' => $e->getMessage()));
$headers['Content-Type'] = 'application/json';
}
$body = ob_get_clean();
header_remove();
list($_SERVER, $_GET, $_POST, $_REQUEST) = $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');
}
// ── 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)
{
if (!Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false)) {
return true;
}
$now = time();
$maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100);
$window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60);
$burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20);
$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)
{
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
$fsPath = realpath(self::$rootDir . $rel);
if (!$fsPath) return null;
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
$root = rtrim(self::$rootDir, DS);
if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) {
return null; // path traversal
}
return (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null;
}
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
class Q_Evented_Revolt extends Q_Evented_Driver
{
protected $running = false;
function onReadable($s, callable $cb) {
return \Revolt\EventLoop::onReadable($s, function($id,$s)use($cb){ $cb($s); });
}
function onWritable($s, callable $cb) {
return \Revolt\EventLoop::onWritable($s, function($id,$s)use($cb){ $cb($s); });
}
function delay($sec, callable $cb) {
return \Revolt\EventLoop::delay($sec, function()use($cb){ $cb(); });
}
function repeat($sec, callable $cb) {
return \Revolt\EventLoop::repeat($sec, function()use($cb){ $cb(); });
}
function defer(callable $cb) {
return \Revolt\EventLoop::defer(function()use($cb){ $cb(); });
}
function onSignal($sig, callable $cb) {
return \Revolt\EventLoop::onSignal($sig, function($id,$s)use($cb){ $cb($s); });
}
function cancel($id) { \Revolt\EventLoop::cancel($id); }
function disable($id) { \Revolt\EventLoop::disable($id); }
function enable($id) { \Revolt\EventLoop::enable($id); }
function run() { $this->running = true; \Revolt\EventLoop::run(); $this->running = false; }
function tick($t = 0) { \Revolt\EventLoop::delay($t?:0.0,function(){}); \Revolt\EventLoop::run(); }
function stop() { $this->running = false; }
function running() { return $this->running; }
}
<?php
/**
* Minimal Q shim for standalone Qbix Server.
*
* Provides just enough of the Q framework for Q_WebServer and its
* dependencies to function without the full Qbix Platform.
* When running inside the full Platform, this file is never loaded —
* the real Q class takes over.
*/
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
class Q
{
/**
* Safe nested array access. Returns $default if any key is missing.
* Signature: Q::ifset($arr, 'key1', 'key2', ..., $default)
*/
static function ifset(&$arr)
{
$args = func_get_args();
array_shift($args); // remove $arr
$default = array_pop($args); // last arg is default
$ref = &$arr;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
return $default;
}
$ref = &$ref[$key];
}
return $ref;
}
/**
* JSON encode with error handling
*/
static function json_encode($value, $options = 0)
{
return json_encode($value, $options | JSON_UNESCAPED_SLASHES);
}
/**
* Fire an event. No-op in standalone mode.
*/
static function event($name, $params = array(), $type = '')
{
// No event system in standalone mode
return null;
}
/**
* Autoloader for Q_* classes
*/
static function autoload($className)
{
if (strpos($className, 'Q_') !== 0 && $className !== 'Q_Config') return;
$path = str_replace('_', DS, $className) . '.php';
$full = dirname(__FILE__) . DS . $path;
if (file_exists($full)) {
require_once $full;
}
}
}
spl_autoload_register(array('Q', 'autoload'));
/**
* Minimal Q_Config — reads JSON config files merged together.
*/
class Q_Config
{
private static $data = array();
private static $loaded = false;
/**
* Load config from JSON file(s)
*/
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);
}
self::$loaded = true;
}
/**
* Set a config value programmatically
*/
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 default.
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
*/
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.
*/
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
*/
static function getAll()
{
return self::$data;
}
/**
* Deep merge arrays (scalars overwrite, arrays merge recursively)
*/
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;
}
}
òžå.ãÇY;¡ æéö¿³¡XÑaYÎE-æ>GBMB