Files
webserver/qbixserver.phar
T

6388 lines
196 KiB
Plaintext
Raw Normal View History

#!/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;
}