#!/usr/bin/env php null, 'app' => null, 'host' => '0.0.0.0', 'port' => 8080, 'workers' => 0, 'config' => null, 'pid' => null, 'debug' => false, ); define('QBIX_SERVER_VERSION', '1.0.0'); foreach ($argv as $i => $arg) { if ($i === 0) continue; if ($arg === '--help' || $arg === '-h') { echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n\n"; echo "Usage: php qbixserver.phar [options]\n\n"; echo " --root=DIR Document root (default: ./web)\n"; echo " --app=DIR Qbix app directory\n"; echo " --host=IP Bind address (default: 0.0.0.0)\n"; echo " --port=PORT Listen port (default: 8080)\n"; echo " --workers=N Pre-fork workers (default: 0)\n"; echo " --config=FILE JSON config file\n"; echo " --version Print version\n"; exit(0); } if ($arg === '--version' || $arg === '-v') { echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n"; exit(0); } if ($arg === '--debug') { $opts['debug'] = true; continue; } if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) $opts[$m[1]] = $m[2]; } // Qbix app mode if ($opts['app']) { $appDir = realpath($opts['app']); $qInc = $appDir . '/scripts/Q.inc.php'; if (!$qInc) $qInc = $appDir . '/../Platform/scripts/Q.inc.php'; if (file_exists($qInc)) { define('APP_DIR', $appDir); require_once $qInc; } $webDir = $appDir . '/web'; } else { $webDir = $opts['root'] ?: (getcwd() . '/web'); } $webDir = realpath($webDir); if (!$webDir || !is_dir($webDir)) { fwrite(STDERR, "Error: document root not found\n"); exit(1); } if ($opts['config']) Q_Config::load($opts['config']); Q_WebServer::$onRequest = function ($method, $uri, $status, $ms) { $c = array(2=>"\033[32m",3=>"\033[33m",4=>"\033[31m",5=>"\033[31m"); echo date('H:i:s').' '.($c[(int)($status/100)]??'').$status."\033[0m $method $uri ({$ms}ms)\n"; }; echo "\n Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n"; echo " http://{$opts['host']}:{$opts['port']} Root: " . basename($webDir) . "\n\n"; Q_WebServer::start($webDir, $opts['host'], (int)$opts['port'], (int)$opts['workers']); Q_WebServer::run(); __HALT_COMPILER(); ?> ?qbixserver.pharQ/Scheduler.phpÏÏ6!ßæ¤Q/FileCache.php× × T1פQ/WebServer/Pool.php‡/‡/£e‹Š¤Q/WebServer/Proxy.phpAA”YcM¤Q/WebServer/Panel.php§§/-»¤ Q/WebServer/Cache/Components.php77µÐa¤Q/WebServer/Certs.php".".YF“ª¤Q/WebServer/Cache.phpJ$J$Ô¿¨¤Q/WebServer/Dashboard.php99öº¤Q/WebServer/Log.phpK K ý«È¤Q/WebServer/Headers.phpÛ1Û1–o¤Q/HotReload.phpËË+£ää¤ Q/socket.jsx x ŸlÒç¤Q/WebSocket.phppp¡"nä¤ Q/Uri.php‚‚ÊrÁ^¤Q/socket.io.jsï¶ï¶Ì’‚í¤ Q/Evented.phpè(¬r¤Q/WebServer.phpÑ_Ñ_A?y0¤Q/Snapshot.phpÝÝ“£ëk¤Q/Evented/Driver.php''ûhšÇ¤Q/Evented/StreamSelect.phpÁ¦.¤Q/Evented/Revolt.phpu¬t¤Q.phpʈʈ¨¬ª¤ timestamp of last run */ static $lastRun = array(); /** @var float Server start time */ static $startTime = 0; /** @var string The HH:MM at startup (to skip on restart) */ static $startMinute = ''; /** * Initialize the scheduler with task definitions. * Marks the current minute as "already checked" to avoid * re-firing tasks if the server restarts mid-minute. */ static function init($schedule) { self::$startTime = microtime(true); self::$startMinute = date('H:i'); foreach ($schedule as $name => $def) { if (empty($def['handler'])) continue; self::$tasks[$name] = $def; // For interval tasks, set lastRun to now so first fire // is one full interval after startup if (isset($def['every'])) { self::$lastRun[$name] = self::$startTime; } // For time-based tasks, mark current minute as run // to prevent re-fire on restart if (isset($def['times'])) { if (in_array(self::$startMinute, $def['times'])) { self::$lastRun[$name] = self::$startTime; } } } } /** * Called every second by the event loop. * Checks each task and fires if due. */ static function tick() { $now = microtime(true); $minute = date('H:i'); $wday = strtolower(date('D')); // mon, tue, wed... $mday = (int) date('j'); // 1-31 foreach (self::$tasks as $name => $def) { // Interval-based: "every" seconds if (isset($def['every'])) { $last = self::$lastRun[$name] ?? 0; if ($now - $last >= $def['every']) { self::run($name, $def); } continue; } // Time-based: check if current HH:MM matches if (isset($def['times'])) { if (!in_array($minute, $def['times'])) continue; // Already ran this minute? $last = self::$lastRun[$name] ?? 0; if ($now - $last < 60) continue; // Weekday filter if (isset($def['weekdays'])) { $allowed = array_map('strtolower', $def['weekdays']); // Accept both "mon" and "monday" style $wdayFull = strtolower(date('l')); if (!in_array($wday, $allowed) && !in_array($wdayFull, $allowed)) { continue; } } // Monthday filter if (isset($def['monthdays'])) { if (!in_array($mday, $def['monthdays'])) continue; } self::run($name, $def); } } } /** * Run a task by forking a child process. * Sets lastRun BEFORE launching to err on the side of skipping. */ static function run($name, $def) { $handler = $def['handler']; // Mark as run BEFORE fork — if we crash, we skip rather than double-run self::$lastRun[$name] = microtime(true); if (!function_exists('pcntl_fork')) { // No fork — run in-process (blocks event loop briefly) $result = null; Q::event($handler, array('task' => $name, 'scheduled' => true), false, false, $result); return; } $pid = pcntl_fork(); if ($pid === 0) { // CHILD $result = null; try { Q::event($handler, array('task' => $name, 'scheduled' => true), false, false, $result); } catch (\Throwable $e) { // Task failed — log but don't crash fwrite(STDERR, date('H:i:s') . " scheduler: $name failed: " . $e->getMessage() . "\n"); } exit(0); } elseif ($pid > 0) { // PARENT — track for timeout enforcement Q_WebServer::$workerPids[$pid] = microtime(true); pcntl_waitpid($pid, $st, WNOHANG); } } } [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; } } [pid, socket, busy] protected $workerClients = array(); // index => HTTP client socket protected $workerBuffers = array(); // index => partial response data protected $watchers = array(); // index => Q_Evented watcher id protected $pending = array(); // queued [client, parsed, scriptPath] protected $nextIndex = 0; /** * @method __construct * @param {integer} [$size=4] */ function __construct($size = null) { if (!function_exists('pcntl_fork')) { throw new Exception( "Q_WebServer_Pool requires pcntl extension. " . "Use --workers=0 or Caddy/nginx + php-fpm." ); } $this->targetSize = $size ?: (int) Q_Config::get( 'Q', 'webserver', 'workers', 4 ); pcntl_signal(SIGCHLD, SIG_DFL); for ($i = 0; $i < $this->targetSize; $i++) { $this->forkWorker(); } } /** * Fork one worker. Child inherits parent's loaded state * via copy-on-write. * @method forkWorker * @return {integer} Worker index */ protected function forkWorker() { $pair = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP ); if (!$pair) throw new Exception("socketpair failed"); $pid = pcntl_fork(); if ($pid === -1) throw new Exception("fork failed"); if ($pid === 0) { // ── CHILD: wait for one request, handle, exit ── fclose($pair[0]); self::childRun($pair[1]); exit(0); } // ── PARENT ── fclose($pair[1]); $sock = $pair[0]; stream_set_blocking($sock, false); $index = $this->nextIndex++; $this->workers[$index] = array( 'pid' => $pid, 'socket' => $sock, 'busy' => false ); $pool = $this; $this->watchers[$index] = Q_Evented::onReadable( $sock, function ($s) use ($pool, $index) { $pool->onWorkerData($index, $s); } ); Q_Evented::disable($this->watchers[$index]); return $index; } // ── Child process ──────────────────────────────────── /** * Child: block on socket, read one request, execute, respond, die. */ protected static function childRun($socket) { stream_set_blocking($socket, true); // Read length-prefixed request $hdr = self::readExact($socket, 4); if ($hdr === false) return; $len = unpack('N', $hdr)[1]; if ($len > 10485760) return; $json = self::readExact($socket, $len); if ($json === false) return; $req = json_decode($json, true); if (!$req) { self::writeMsg($socket, 500, 'Bad message', array()); return; } // Execute the PHP script $resp = self::executeScript($req); self::writeMsg($socket, $resp['status'], $resp['body'], $resp['headers']); fclose($socket); } /** * Set up superglobals and include the PHP script. * The script (index.php, action.php, etc.) internally calls * Q_WebController::execute() or Q_ActionController::execute(). */ protected static function executeScript($req) { $_SERVER['REQUEST_METHOD'] = $req['method']; $_SERVER['REQUEST_URI'] = $req['uri']; $_SERVER['QUERY_STRING'] = $req['query'] ?? ''; $_SERVER['SCRIPT_FILENAME'] = $req['scriptFilename']; $_SERVER['SCRIPT_NAME'] = $req['scriptName'] ?? '/index.php'; $_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? ''; $_SERVER['SERVER_NAME'] = $req['headers']['host'] ?? 'localhost'; $_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080'; $_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1'; foreach ($req['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } if (isset($req['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $req['headers']['content-type']; if (isset($req['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length']; $_GET = $_POST = $_REQUEST = array(); if (!empty($req['query'])) parse_str($req['query'], $_GET); $ct = strtolower($req['headers']['content-type'] ?? ''); $raw = $req['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($raw, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($raw, true) ?: array(); } $_REQUEST = array_merge($_GET, $_POST); // php://input workaround for forked processes $GLOBALS['_Q_RAW_INPUT'] = $raw; ob_start(); $status = 200; $headers = array(); try { include($req['scriptFilename']); foreach (headers_list() as $h) { if (strpos($h, ':') !== false) { list($k, $v) = explode(':', $h, 2); $headers[trim($k)] = trim($v); } } $code = http_response_code(); if ($code) $status = $code; } catch (\Throwable $e) { $status = 500; ob_clean(); echo $e->getMessage(); } $body = ob_get_clean(); return compact('status', 'body', 'headers'); } // ── Parent-side dispatch ───────────────────────────── /** * Send a request to an idle worker. Queues if all busy. */ function dispatch($client, $parsed, $scriptPath) { $idle = $this->findIdle(); if ($idle === null) { $this->pending[] = array($client, $parsed, $scriptPath); return; } $this->sendTo($idle, $client, $parsed, $scriptPath); } protected function sendTo($index, $client, $parsed, $scriptPath) { $this->workers[$index]['busy'] = true; $this->workerClients[$index] = $client; $this->workerBuffers[$index] = ''; $this->workerRequestHeaders[$index] = $parsed['headers']; Q_Evented::enable($this->watchers[$index]); $msg = json_encode(array( 'method' => $parsed['method'], 'uri' => $parsed['uri'], 'path' => $parsed['path'], 'query' => $parsed['query'], 'headers' => $parsed['headers'], 'body' => $parsed['body'], 'scriptFilename' => $scriptPath, 'scriptName' => '/' . basename($scriptPath), 'documentRoot' => Q_WebServer::$rootDir ?? '', 'serverPort' => (string)($_SERVER['SERVER_PORT'] ?? '8080'), 'remoteAddr' => '127.0.0.1' )); $written = @fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg); if ($written === false || $written === 0) { // Worker died before receiving the request — recycle and re-queue $this->pending[] = array($client, $parsed, $scriptPath); $this->recycle($index, true); } } /** * Called when data or EOF arrives from a worker. */ function onWorkerData($index, $sock) { $chunk = @fread($sock, 65536); if ($chunk === false || $chunk === '') { // Worker exited (expected after one request) $this->recycle($index, true); return; } $this->workerBuffers[$index] .= $chunk; $buf = $this->workerBuffers[$index]; if (strlen($buf) < 4) return; $len = unpack('N', substr($buf, 0, 4))[1]; if (strlen($buf) < 4 + $len) return; // Got complete response $json = substr($buf, 4, $len); $response = json_decode($json, true); // Check for cache messages piggybacked on the response if ($response && !empty($response['_cacheMessages'])) { foreach ($response['_cacheMessages'] as $msg) { Q_WebServer_Cache_Components::processChildMessage($msg); } unset($response['_cacheMessages']); } $client = $this->workerClients[$index] ?? null; if ($response && $client && is_resource($client)) { $this->sendHttp($client, $response, $index); } $this->recycle($index, false); } /** * Clean up a finished worker: close socket, reap pid, * fork replacement, process pending queue. */ protected function recycle($index, $isEof) { if (isset($this->watchers[$index])) { Q_Evented::cancel($this->watchers[$index]); unset($this->watchers[$index]); } // EOF with no response → 502 if ($isEof && isset($this->workerClients[$index]) && empty($this->workerBuffers[$index]) ) { $c = $this->workerClients[$index]; if (is_resource($c)) { Q_WebServer::sendResponse($c, 502, 'Worker died'); @fclose($c); } } elseif (isset($this->workerClients[$index])) { @fclose($this->workerClients[$index]); } if (isset($this->workers[$index])) { @fclose($this->workers[$index]['socket']); pcntl_waitpid($this->workers[$index]['pid'], $st, WNOHANG); } unset($this->workers[$index], $this->workerClients[$index], $this->workerBuffers[$index], $this->workerRequestHeaders[$index]); // Immediately fork replacement $newIdx = $this->forkWorker(); // Drain pending queue if (!empty($this->pending)) { $next = array_shift($this->pending); $this->sendTo($newIdx, $next[0], $next[1], $next[2]); } } /** * We need the original request headers for compression * negotiation. Store them alongside the client. * @property $workerRequestHeaders */ protected $workerRequestHeaders = array(); protected function sendHttp($client, $resp, $index) { $reqHeaders = $this->workerRequestHeaders[$index] ?? array(); Q_WebServer_Headers::processResponse($client, $resp, $reqHeaders); } protected function findIdle() { foreach ($this->workers as $i => $w) { if (!$w['busy']) return $i; } return null; } /** * Graceful shutdown: SIGTERM all workers, wait up to $timeout seconds, * then SIGKILL any remaining. * @param {float} $timeout Seconds to wait after SIGTERM before SIGKILL */ function shutdown($timeout = 3.0) { // Cancel watchers and close sockets foreach ($this->workers as $i => $w) { if (isset($this->watchers[$i])) { Q_Evented::cancel($this->watchers[$i]); } @fclose($w['socket']); } // Send SIGTERM to all workers foreach ($this->workers as $w) { if (function_exists("posix_kill")) posix_kill($w["pid"], SIGTERM); } // Wait for workers to exit gracefully $deadline = microtime(true) + $timeout; $remaining = $this->workers; while (!empty($remaining) && microtime(true) < $deadline) { foreach ($remaining as $i => $w) { $result = pcntl_waitpid($w['pid'], $st, WNOHANG); if ($result > 0 || $result === -1) { unset($remaining[$i]); } } if (!empty($remaining)) { usleep(50000); // 50ms } } // SIGKILL any workers that didn't exit in time foreach ($remaining as $w) { if (function_exists("posix_kill")) posix_kill($w["pid"], SIGKILL); pcntl_waitpid($w['pid'], $st, 0); } $this->workers = array(); } function idleCount() { $n = 0; foreach ($this->workers as $w) if (!$w['busy']) $n++; return $n; } // ── Wire helpers ───────────────────────────────────── protected static function readExact($sock, $n) { $buf = ''; while (strlen($buf) < $n) { $c = fread($sock, $n - strlen($buf)); if ($c === false || $c === '') return false; $buf .= $c; } return $buf; } protected static function writeMsg($sock, $status, $body, $headers) { $j = json_encode(compact('status', 'body', 'headers')); fwrite($sock, pack('N', strlen($j)) . $j); } } = 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); } } '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 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' Qbix Control Panel

Qbix Server

Running
Apps
Scripts
Plugins
System

Your Apps

HTML; } } 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(), ); } } 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'] ?? '', ); } } 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) { $host = $parsed['headers']['host'] ?? ''; $parts = $host . $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, ); } } 0, 'requests' => 0, 'status2xx' => 0, 'status3xx' => 0, 'status4xx' => 0, 'status5xx' => 0, 'phpRequests' => 0, 'staticRequests' => 0, 'bytesOut' => 0, 'totalMs' => 0, 'slowest' => 0, 'slowestUri' => '', ); static $recentRequests = array(); static $topPaths = array(); // path => [count, totalMs] static $statusCodes = array(); // code => count static $rpsHistory = array(); // [timestamp => count] for sparkline static function init() { self::$stats['startTime'] = time(); } static function recordRequest($method, $uri, $status, $ms, $bytes = 0, $isPhp = false) { self::$stats['requests']++; self::$stats['totalMs'] += $ms; self::$stats['bytesOut'] += $bytes; if ($isPhp) self::$stats['phpRequests']++; else self::$stats['staticRequests']++; if ($ms > self::$stats['slowest']) { self::$stats['slowest'] = $ms; self::$stats['slowestUri'] = $uri; } if ($status < 300) self::$stats['status2xx']++; elseif ($status < 400) self::$stats['status3xx']++; elseif ($status < 500) self::$stats['status4xx']++; else self::$stats['status5xx']++; // Per-status tracking if (!isset(self::$statusCodes[$status])) self::$statusCodes[$status] = 0; self::$statusCodes[$status]++; // Top paths $pathKey = $method . ' ' . strtok($uri, '?'); if (!isset(self::$topPaths[$pathKey])) self::$topPaths[$pathKey] = array(0, 0); self::$topPaths[$pathKey][0]++; self::$topPaths[$pathKey][1] += $ms; // RPS history (per-second bucket) $sec = time(); if (!isset(self::$rpsHistory[$sec])) self::$rpsHistory[$sec] = 0; self::$rpsHistory[$sec]++; // Keep last 60 seconds $cutoff = $sec - 60; foreach (self::$rpsHistory as $t => $c) { if ($t < $cutoff) unset(self::$rpsHistory[$t]); else break; } $entry = array('time' => date('H:i:s'), 'method' => $method, 'uri' => $uri, 'status' => $status, 'ms' => $ms); self::$recentRequests[] = $entry; if (count(self::$recentRequests) > 200) array_shift(self::$recentRequests); Q_WebSocket::broadcastTo('dashboard', array( 'type' => 'request', 'entry' => $entry, 'stats' => self::getStats() )); } static function getStats() { $up = time() - self::$stats['startTime']; $pool = Q_WebServer::$pool; $reqs = self::$stats['requests']; $avgMs = $reqs > 0 ? round(self::$stats['totalMs'] / $reqs, 1) : 0; $rps = $up > 0 ? round($reqs / $up, 1) : 0; // Current RPS (last 5 seconds) $now = time(); $recent5 = 0; for ($i = 1; $i <= 5; $i++) { $recent5 += self::$rpsHistory[$now - $i] ?? 0; } $currentRps = round($recent5 / 5, 1); // Top 10 paths by count $topPaths = self::$topPaths; uasort($topPaths, function($a, $b) { return $b[0] - $a[0]; }); $topPaths = array_slice($topPaths, 0, 10, true); $topFormatted = array(); foreach ($topPaths as $path => $data) { $topFormatted[] = array( 'path' => $path, 'count' => $data[0], 'avgMs' => $data[0] > 0 ? round($data[1] / $data[0], 1) : 0, ); } // RPS sparkline data (last 60 seconds) $sparkline = array(); for ($i = 59; $i >= 0; $i--) { $sparkline[] = self::$rpsHistory[$now - $i] ?? 0; } // Connection counts $wsConnections = count(Q_WebSocket::$workers); $wsRooms = count(Q_WebSocket::$roomWorkers); $activeRooms = array(); foreach (Q_WebSocket::$roomWorkers as $name => $rw) { $activeRooms[] = array( 'name' => $name, 'members' => count($rw['members'] ?? array()), ); } return array( 'uptime' => self::fmtUp($up), 'uptimeSec' => $up, 'requests' => $reqs, 'rps' => $rps, 'currentRps' => $currentRps, 'avgMs' => $avgMs, 'slowest' => self::$stats['slowest'], 'slowestUri' => self::$stats['slowestUri'], 'status2xx' => self::$stats['status2xx'], 'status3xx' => self::$stats['status3xx'], 'status4xx' => self::$stats['status4xx'], 'status5xx' => self::$stats['status5xx'], 'statusCodes' => self::$statusCodes, 'phpRequests' => self::$stats['phpRequests'], 'staticRequests' => self::$stats['staticRequests'], 'bytesOut' => self::$stats['bytesOut'], 'bytesFormatted' => self::fmtBytes(self::$stats['bytesOut']), 'memory' => round(memory_get_usage(true)/1048576, 1), 'memoryPeak' => round(memory_get_peak_usage(true)/1048576, 1), 'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'fork', 'wsClients' => Q_WebSocket::clientCount(), 'wsConnections' => $wsConnections, 'wsRooms' => $wsRooms, 'activeRooms' => $activeRooms, 'connections' => count(Q_WebServer::$clients), 'topPaths' => $topFormatted, 'sparkline' => $sparkline, 'cache' => Q_WebServer_Cache::stats(), 'components' => Q_WebServer_Cache_Components::enabled() ? Q_WebServer_Cache_Components::stats() : null, 'php' => PHP_VERSION, 'os' => PHP_OS, ); } static function handle($client, $parsed) { $p = $parsed['path']; if ($p === '/Q/dashboard' || $p === '/Q/dashboard/') { Q_WebServer::sendResponse($client, 200, self::renderHtml($parsed), 'text/html; charset=utf-8'); return true; } if ($p === '/Q/stats') { Q_WebServer::sendResponse($client, 200, json_encode(self::getStats()), 'application/json'); return true; } return false; } static function fmtUp($s) { if ($s < 60) return "{$s}s"; $d = floor($s/86400); $h = floor(($s%86400)/3600); $m = floor(($s%3600)/60); if ($d > 0) return "{$d}d {$h}h {$m}m"; if ($h > 0) return "{$h}h {$m}m"; return "{$m}m ".($s%60).'s'; } static function fmtBytes($b) { if ($b < 1024) return $b . ' B'; if ($b < 1048576) return round($b/1024, 1) . ' KB'; if ($b < 1073741824) return round($b/1048576, 1) . ' MB'; return round($b/1073741824, 2) . ' GB'; } static function renderHtml($parsed) { $stats = json_encode(self::getStats()); $recent = json_encode(array_slice(self::$recentRequests, -50)); $host = $parsed['headers']['host'] ?? 'localhost:8080'; $wsUrl = "ws://$host/Q/ws"; return << Qbix Server Dashboard

Qbix Server

· PHP · · connecting
Total requests
0
0 avg req/s
Current RPS
0
last 5 sec
Avg response
0ms
slowest: 0ms
Memory
—
peak —
Workers
—
0 PHP / 0 static
WebSocket
0
0 rooms
Data out
0
0 connections
Status codes
0 ok · 0 redir · 0 4xx · 0 5xx
Throughput last 60s
Top paths
Active rooms
No active rooms
Live requests 0 total
HTML; } } 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; } } } $v) { if (strtolower($k) === 'x-accel-redirect') { $accelPath = $v; } } if ($accelPath) { // Strip internal headers from response $headers = self::stripInternal($headers); // Resolve the internal path $fsPath = self::resolveAccelPath($accelPath); if ($fsPath && is_file($fsPath)) { // Serve the file, keeping Content-Type and other // headers the PHP script set self::serveAccelFile($client, $fsPath, $headers, $requestHeaders); return true; } // Path not found — send 404 Q_WebServer::sendResponse($client, 404, 'X-Accel-Redirect: file not found'); return true; } // ── Strip internal headers ─────────────────────── $headers = self::stripInternal($headers); // ── Compression ────────────────────────────────── $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $body = self::maybeCompress($body, $ct, $requestHeaders, $headers); // ── Send response ──────────────────────────────── $headers['Content-Length'] = strlen($body); $headers['Connection'] = 'close'; // Merge Q_Response cookies into Set-Cookie headers if (class_exists('Q_Response', false)) { $cookieHeaders = Q_Response::cookieHeaders(); foreach ($cookieHeaders as $ch) { $headers['Set-Cookie'] = $ch; // last one wins for single-value } } static $reasons = array( 200=>'OK', 201=>'Created', 204=>'No Content', 301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified', 400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden', 404=>'Not Found', 405=>'Method Not Allowed', 413=>'Payload Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway', 503=>'Service Unavailable', ); $reason = $reasons[$status] ?? 'OK'; $out = "HTTP/1.1 $status $reason\r\n"; foreach ($headers as $k => $v) { $out .= "$k: $v\r\n"; } // Multiple Set-Cookie headers (can't use the associative array for dupes) if (class_exists('Q_Response', false)) { $cookieHeaders = Q_Response::cookieHeaders(); if (count($cookieHeaders) > 1) { // Remove the single Set-Cookie we added above $out = preg_replace("/Set-Cookie:.*\r\n/", "", $out); foreach ($cookieHeaders as $ch) { $out .= "Set-Cookie: $ch\r\n"; } } } @fwrite($client, $out . "\r\n" . $body); return true; } /** * Serve a file via X-Accel-Redirect, applying compression * if appropriate. Merges headers the PHP script set * (Content-Type, Cache-Control, etc.) with file serving headers. * * @method serveAccelFile * @static */ static function serveAccelFile($client, $fsPath, $phpHeaders, $requestHeaders) { clearstatcache(true, $fsPath); $size = filesize($fsPath); $mtime = filemtime($fsPath); $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // Start with headers from PHP, fill in defaults $headers = $phpHeaders; if (!self::hasHeader($headers, 'Content-Type')) { $headers['Content-Type'] = Q_WebServer::mimeType($ext); } if (!self::hasHeader($headers, 'Cache-Control')) { $headers['Cache-Control'] = 'public, max-age=0, must-revalidate'; } // ETag / Last-Modified $etag = '"' . dechex($mtime) . '-' . dechex($size) . '"'; $headers['ETag'] = $etag; $headers['Last-Modified'] = gmdate('D, d M Y H:i:s', $mtime) . ' GMT'; // Check for pre-compressed version $compressed = self::findPreCompressed($fsPath, $requestHeaders); if ($compressed) { $headers['Content-Encoding'] = $compressed['encoding']; $headers['Content-Length'] = $compressed['size']; $headers['Vary'] = 'Accept-Encoding'; $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; fwrite($client, $out . "\r\n"); $fp = fopen($compressed['path'], 'rb'); while (!feof($fp)) { $data = fread($fp, 65536); if ($data === false || @fwrite($client, $data) === false) break; } fclose($fp); return; } // Check if we should compress on-the-fly $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $shouldCompress = self::shouldCompress($ct, $size, $requestHeaders); if ($shouldCompress && $size < 5242880) { // < 5MB: read + compress + send $body = file_get_contents($fsPath); $body = self::maybeCompress($body, $ct, $requestHeaders, $headers); $headers['Content-Length'] = strlen($body); $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; @fwrite($client, $out . "\r\n" . $body); return; } // No compression — stream directly $headers['Content-Length'] = $size; $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; fwrite($client, $out . "\r\n"); $fp = fopen($fsPath, 'rb'); while (!feof($fp)) { $data = fread($fp, 65536); if ($data === false || @fwrite($client, $data) === false) break; } fclose($fp); } /** * Check for pre-compressed .gz or .br sibling files * (like nginx gzip_static / brotli_static). * * @method findPreCompressed * @static * @param {string} $fsPath Original file path * @param {array} $requestHeaders * @return {array|null} [path, encoding, size] or null */ static function findPreCompressed($fsPath, $requestHeaders) { $accept = strtolower($requestHeaders['accept-encoding'] ?? ''); // Prefer brotli over gzip if (strpos($accept, 'br') !== false) { $brPath = $fsPath . '.br'; if (file_exists($brPath)) { clearstatcache(true, $brPath); // Only use if not older than original if (filemtime($brPath) >= filemtime($fsPath)) { return array( 'path' => $brPath, 'encoding' => 'br', 'size' => filesize($brPath) ); } } } if (strpos($accept, 'gzip') !== false) { $gzPath = $fsPath . '.gz'; if (file_exists($gzPath)) { clearstatcache(true, $gzPath); if (filemtime($gzPath) >= filemtime($fsPath)) { return array( 'path' => $gzPath, 'encoding' => 'gzip', 'size' => filesize($gzPath) ); } } } return null; } /** * Maybe compress a response body (gzip on-the-fly). * Modifies $headers by reference to add Content-Encoding + Vary. * * @method maybeCompress * @static * @param {string} $body * @param {string} $contentType * @param {array} $requestHeaders * @param {array} &$headers Response headers (modified) * @return {string} Possibly compressed body */ static function maybeCompress($body, $contentType, $requestHeaders, &$headers) { if (!self::shouldCompress($contentType, strlen($body), $requestHeaders)) { return $body; } $accept = strtolower($requestHeaders['accept-encoding'] ?? ''); // Try gzip (universally supported, no ext needed) if (strpos($accept, 'gzip') !== false && function_exists('gzencode')) { $compressed = gzencode($body, 6); if ($compressed !== false && strlen($compressed) < strlen($body)) { $headers['Content-Encoding'] = 'gzip'; $headers['Vary'] = 'Accept-Encoding'; return $compressed; } } return $body; } /** * Check whether a response should be compressed. * * @method shouldCompress * @static * @param {string} $contentType * @param {integer} $bodySize * @param {array} $requestHeaders * @return {boolean} */ static function shouldCompress($contentType, $bodySize, $requestHeaders) { if ($bodySize < self::$compressMinSize) return false; if (empty($requestHeaders['accept-encoding'])) return false; // Check content type (strip charset parameter) $baseType = strtolower(strtok($contentType, ';')); return in_array($baseType, self::$compressibleTypes); } /** * Resolve an X-Accel-Redirect path to a filesystem path. * * Supports two patterns: * /Q/internal/... → maps to configured internal directory * /absolute/path → maps relative to document root * * Config: Q.webserver.accel.mappings = { "/Q/internal": "/path/on/disk" } * * @method resolveAccelPath * @static * @param {string} $accelPath The X-Accel-Redirect value * @return {string|null} Filesystem path or null */ static function resolveAccelPath($accelPath) { // 1. Check configured mappings first (absolute path overrides) $mappings = Q_Config::get('Q', 'webserver', 'accel', 'mappings', array()); foreach ($mappings as $prefix => $diskPath) { if (strpos($accelPath, $prefix) === 0) { $relative = substr($accelPath, strlen($prefix)); $fsPath = rtrim($diskPath, DS) . DS . ltrim(str_replace('/', DS, $relative), DS); $real = realpath($fsPath); if ($real && strpos($real, realpath($diskPath)) === 0) { return $real; } return null; } } // 2. Default: resolve relative to APP_DIR (project root). // By convention, private files live in files/ (sibling of web/). // X-Accel-Redirect: /files/private/doc.pdf // → APP_DIR/files/private/doc.pdf if (defined('APP_DIR')) { $fsPath = APP_DIR . DS . ltrim(str_replace('/', DS, $accelPath), DS); $real = realpath($fsPath); if ($real && strpos($real, realpath(APP_DIR)) === 0) { // Ensure we're NOT serving from web/ — that defeats the purpose if (isset(Q_WebServer::$rootDir)) { $webRoot = realpath(rtrim(Q_WebServer::$rootDir, DS)); if ($webRoot && strpos($real, $webRoot) === 0) { return null; // don't serve public files via accel } } return $real; } } return null; } /** * Strip internal/server-directive headers from a response. * * @method stripInternal * @static * @param {array} $headers * @return {array} Cleaned headers */ static function stripInternal($headers) { $result = array(); foreach ($headers as $k => $v) { if (!in_array(strtolower($k), self::$internalHeaders)) { $result[$k] = $v; } } return $result; } /** * Check if a header exists (case-insensitive). */ static function hasHeader($headers, $name) { $lower = strtolower($name); foreach ($headers as $k => $v) { if (strtolower($k) === $lower) return true; } return false; } } mtime snapshot */ static $snapshot = array(); /** @var float Last full scan time */ static $lastScan = 0; /** @var array Directories to watch */ static $watchDirs = array(); /** @var boolean Whether a restart is pending */ static $restarting = false; /** * Initialize: snapshot all watched files. */ static function init() { // Watch standard directories relative to each registered path foreach (Q::$paths as $base) { foreach (array('classes', 'handlers', 'config') as $dir) { $full = $base . DS . $dir; if (is_dir($full)) { self::$watchDirs[] = $full; } } } if (empty(self::$watchDirs)) return; self::$snapshot = self::scan(); self::$lastScan = microtime(true); } /** * Check for changes. Called every 2 seconds by the event loop. */ static function check() { if (self::$restarting || empty(self::$watchDirs)) return; $current = self::scan(); $changes = self::diff($current); if (empty($changes)) { self::$snapshot = $current; return; } // Categorize changes $needsRestart = false; foreach ($changes as $file => $type) { $rel = self::relativePath($file); if (strpos($rel, 'classes' . DS) === 0 || strpos($rel, 'config' . DS) === 0) { $needsRestart = true; } $label = ($type === 'added') ? "\033[32m+\033[0m" : (($type === 'removed') ? "\033[31m-\033[0m" : "\033[33m~\033[0m"); fwrite(STDERR, date('H:i:s') . " hot-reload: $label $rel\n"); } self::$snapshot = $current; if ($needsRestart) { self::restart(); } } /** * Scan all watched directories, return path => mtime map. */ static function scan() { $files = array(); foreach (self::$watchDirs as $dir) { self::scanDir($dir, $files); } return $files; } private static function scanDir($dir, &$files) { $entries = @scandir($dir); if (!$entries) return; foreach ($entries as $e) { if ($e[0] === '.') continue; $path = $dir . DS . $e; if (is_dir($path)) { self::scanDir($path, $files); } elseif (is_file($path)) { $files[$path] = @filemtime($path); } } } /** * Compare current scan to snapshot, return changed files. */ static function diff($current) { $changes = array(); // Modified or added foreach ($current as $path => $mtime) { if (!isset(self::$snapshot[$path])) { $changes[$path] = 'added'; } elseif ($mtime !== self::$snapshot[$path]) { $changes[$path] = 'modified'; } } // Removed foreach (self::$snapshot as $path => $mtime) { if (!isset($current[$path])) { $changes[$path] = 'removed'; } } return $changes; } /** * Get a human-readable relative path. */ static function relativePath($path) { foreach (Q::$paths as $base) { if (strpos($path, $base . DS) === 0) { return substr($path, strlen($base) + 1); } } return basename($path); } /** * Graceful restart — re-exec the server process. */ static function restart() { self::$restarting = true; fwrite(STDERR, date('H:i:s') . " hot-reload: restarting server...\n"); // Re-exec: replace current process with a fresh one // This preserves the original command-line arguments $args = $_SERVER['argv'] ?? array(); $php = PHP_BINARY; if (function_exists('pcntl_exec')) { pcntl_exec($php, $args); // If pcntl_exec fails, fall through } // Fallback: signal the event loop to stop, then exec fwrite(STDERR, date('H:i:s') . " hot-reload: stopping for manual restart\n"); Q_Evented::stop(); } } /** * QSocket — minimal WebSocket client for Qbix Server. * ~80 lines. No dependencies. Plain JSON over bare WebSocket. * * var socket = new QSocket('/ws'); * socket.on('chat/message', function(data) { ... }); * socket.emit('chat/message', {text: 'hi'}, function(res) { ... }); */ (function(root) { 'use strict'; function QSocket(path, opts) { opts = opts || {}; var self = this; self._l = {}; self._h = {}; self._a = {}; self._n = 0; self._q = []; self._rc = opts.reconnect !== false; self._d = opts.delay || 1000; self._md = opts.maxDelay || 30000; self._cd = self._d; var loc = root.location || {}; var proto = (loc.protocol === 'https:') ? 'wss://' : 'ws://'; self._url = (path.indexOf('ws') === 0) ? path : proto + loc.host + path; self._open = function() { var ws = self.ws = new WebSocket(self._url); ws.onopen = function() { self._cd = self._d; while (self._q.length) ws.send(self._q.shift()); self._emit('connect'); }; ws.onmessage = function(e) { var m; try { m = JSON.parse(e.data); } catch(x) { return; } // Ack response if (m.ack != null && !m.event && self._a[m.ack]) { self._a[m.ack](m.data); delete self._a[m.ack]; return; } // Server RPC call (event + ack, no pending callback) if (m.event && m.ack != null && !self._a[m.ack]) { var h = self._h[m.event], r; try { r = h ? h(m.data) : null; } catch(x) { r = null; } if (r && typeof r.then === 'function') { r.then(function(v) { self._ack(m.ack, v); }) ['catch'](function() { self._ack(m.ack, null); }); } else { self._ack(m.ack, r); } return; } if (m.event) self._emit(m.event, m.data); }; ws.onclose = function() { self._emit('disconnect'); if (self._rc) { self._cd = Math.min(self._cd * 1.5, self._md); setTimeout(self._open, self._cd); } }; ws.onerror = function() { ws.close(); }; }; self._emit = function(ev, d) { var ls = self._l[ev]; if (!ls) return; for (var i = 0; i < ls.length; i++) ls[i](d); }; self._ack = function(id, d) { self._send(JSON.stringify({ack: id, data: d})); }; self._send = function(s) { if (self.ws && self.ws.readyState === 1) self.ws.send(s); else self._q.push(s); }; self._open(); } QSocket.prototype.on = function(ev, fn) { if (!this._l[ev]) this._l[ev] = []; this._l[ev].push(fn); return this; }; QSocket.prototype.off = function(ev, fn) { if (!fn) { delete this._l[ev]; return this; } if (this._l[ev]) this._l[ev] = this._l[ev].filter(function(f) { return f !== fn; }); return this; }; QSocket.prototype.emit = function(ev, data, cb) { var m = {event: ev, data: data != null ? data : null}; if (typeof cb === 'function') { m.ack = ++this._n; this._a[m.ack] = cb; } this._send(JSON.stringify(m)); return this; }; QSocket.prototype.handle = function(method, fn) { this._h[method] = fn; return this; }; QSocket.prototype.close = function() { this._rc = false; if (this.ws) this.ws.close(); }; if (typeof module !== 'undefined' && module.exports) module.exports = QSocket; else root.QSocket = QSocket; })(typeof window !== 'undefined' ? window : this); [socket, watcher, channels, buffer, onMessage] */ static $clients = array(); /** Channel/room subscriptions. channelName => [socketKey => true] */ static $channels = array(); /** Connection workers. socketKey => [pid, pipe, watcher] */ static $workers = array(); /** Room workers. roomName => [pid, pipe, watcher, members => [socketKey => true], tick => ms] */ static $roomWorkers = array(); /** Cached room patterns from config */ static $roomPatterns = null; // ── Upgrade + framing (unchanged) ─────────────── static function upgrade($socket, $headers, $onMessage = null, $channel = null, $path = '/') { $key = $headers['sec-websocket-key'] ?? null; if (!$key) return false; $accept = base64_encode(sha1($key . self::GUID, true)); $resp = "HTTP/1.1 101 Switching Protocols\r\n" . "Upgrade: websocket\r\nConnection: Upgrade\r\n" . "Sec-WebSocket-Accept: $accept\r\n" . "Server: QbixServer\r\n\r\n"; @fwrite($socket, $resp); $sk = (int) $socket; $watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) { Q_WebSocket::onData($sk, $sock); }); // Socket.IO clients connect to configured path (default /socket.io) // Set Q.socket.io to false to disable Socket.IO protocol $ioPath = Q_Config::get('Q', 'socket', 'io', '/socket.io'); $proto = ($ioPath !== false && strpos($path, $ioPath) === 0) ? 'socketio' : 'json'; self::$clients[$sk] = array( 'socket' => $socket, 'watcher' => $watcher, 'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage, 'protocol' => $proto, ); // Socket.IO: send Engine.IO OPEN handshake if ($proto === 'socketio') { $sid = base_convert(mt_rand(1000000, 9999999) . $sk, 10, 36); $handshake = '0' . json_encode(array( 'sid' => $sid, 'upgrades' => array(), 'pingInterval' => 25000, 'pingTimeout' => 20000, 'maxPayload' => 1000000, )); self::sendRaw($sk, $handshake); } if ($channel) self::subscribe($sk, $channel); return true; } static function onData($sk, $socket) { if (!isset(self::$clients[$sk])) return; $data = @fread($socket, 65536); if ($data === false || $data === '') { self::disconnect($sk); return; } self::$clients[$sk]['buffer'] .= $data; while (($frame = self::decodeFrame(self::$clients[$sk]['buffer'])) !== null) { switch ($frame['opcode']) { case 0x1: // text $cb = self::$clients[$sk]['onMessage']; if ($cb) $cb($sk, $frame['payload']); break; case 0x2: // binary — ignore break; case 0x8: // close self::disconnect($sk); return; case 0x9: // ping → pong self::encodeAndSend(self::$clients[$sk]['socket'], 0xA, $frame['payload']); break; case 0xA: // pong — ignore break; } } } static function decodeFrame(&$buffer) { $len = strlen($buffer); if ($len < 2) return null; $b0 = ord($buffer[0]); $b1 = ord($buffer[1]); $opcode = $b0 & 0x0F; $masked = ($b1 >> 7) & 1; $payloadLen = $b1 & 0x7F; $offset = 2; if ($payloadLen === 126) { if ($len < 4) return null; $payloadLen = unpack('n', substr($buffer, 2, 2))[1]; $offset = 4; } elseif ($payloadLen === 127) { if ($len < 10) return null; $payloadLen = unpack('J', substr($buffer, 2, 8))[1]; $offset = 10; } if ($masked) { if ($len < $offset + 4 + $payloadLen) return null; $mask = substr($buffer, $offset, 4); $offset += 4; $payload = ''; $raw = substr($buffer, $offset, $payloadLen); for ($i = 0; $i < $payloadLen; $i++) { $payload .= chr(ord($raw[$i]) ^ ord($mask[$i % 4])); } } else { if ($len < $offset + $payloadLen) return null; $payload = substr($buffer, $offset, $payloadLen); } $buffer = substr($buffer, $offset + $payloadLen); return array('opcode' => $opcode, 'payload' => $payload); } // ── Sending ───────────────────────────────────── static function send($socketKey, $data) { if (!isset(self::$clients[$socketKey])) return; $encoded = self::encodeSend($socketKey, $data); self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $encoded); } static function broadcast($data) { foreach (self::$clients as $sk => $c) { $encoded = self::encodeSend($sk, $data); self::encodeAndSend($c['socket'], 0x1, $encoded); } } static function broadcastTo($channel, $data) { if (!isset(self::$channels[$channel])) return; foreach (self::$channels[$channel] as $sk => $_) { if (isset(self::$clients[$sk])) { $encoded = self::encodeSend($sk, $data); self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $encoded); } } } static function subscribe($sk, $channel, $data = array()) { if (!isset(self::$channels[$channel])) self::$channels[$channel] = array(); self::$channels[$channel][$sk] = true; if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true; // If a room worker exists for this channel, notify it self::notifyRoomJoin($channel, $sk, $data); } static function unsubscribe($sk, $channel, $data = array()) { unset(self::$channels[$channel][$sk]); if (empty(self::$channels[$channel])) unset(self::$channels[$channel]); if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]); self::notifyRoomLeave($channel, $sk, $data); } static function disconnect($sk) { if (!isset(self::$clients[$sk])) return; self::notifyDisconnect($sk); $w = self::$clients[$sk]['watcher']; if ($w) Q_Evented::cancel($w); foreach (self::$clients[$sk]['channels'] as $ch => $_) { unset(self::$channels[$ch][$sk]); self::notifyRoomLeave($ch, $sk); } @fclose(self::$clients[$sk]['socket']); unset(self::$clients[$sk]); } static function encodeAndSend($socket, $opcode, $payload) { $len = strlen($payload); $frame = chr(0x80 | $opcode); if ($len < 126) { $frame .= chr($len); } elseif ($len < 65536) { $frame .= chr(126) . pack('n', $len); } else { $frame .= chr(127) . pack('J', $len); } $frame .= $payload; @fwrite($socket, $frame); } // ── Connection worker (process-per-socket) ────── static function dispatchEvent($socketKey, $raw, $path = '/') { $proto = self::$clients[$socketKey]['protocol'] ?? 'json'; if ($proto === 'socketio') { $msg = self::parseSocketIO($socketKey, $raw); if ($msg === null) return; // handled internally (ping/pong/connect) } else { // Bare WebSocket — plain JSON $msg = json_decode($raw, true); if (!$msg) return; // Ack-only response (client responding to server RPC) if (isset($msg['ack']) && !isset($msg['event'])) { self::handleRpcResponse($msg['ack'], $msg['data'] ?? null); return; } if (empty($msg['event'])) return; } $event = $msg['event']; // Check if this event should go to a room worker instead if (isset(self::$clients[$socketKey])) { foreach (self::$clients[$socketKey]['channels'] as $ch => $_) { if (isset(self::$roomWorkers[$ch])) { // Forward to room worker with sender info $msg['_socketId'] = $socketKey; self::sendToRoomWorker($ch, $msg); return; } } } // Default: per-connection worker if (!isset(self::$workers[$socketKey])) { self::spawnWorker($socketKey, $path); } if (!isset(self::$workers[$socketKey])) return; $json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $packet = pack('N', strlen($json)) . $json; $written = @fwrite(self::$workers[$socketKey]['pipe'], $packet); if ($written === false || $written === 0) { self::cleanupWorker($socketKey); self::spawnWorker($socketKey, $path); if (isset(self::$workers[$socketKey])) { @fwrite(self::$workers[$socketKey]['pipe'], $packet); } } } // ── Socket.IO protocol support ────────────────── /** * Parse a Socket.IO/Engine.IO message. Returns normalized internal * format or null if the message was handled internally (ping, connect). */ static function parseSocketIO($socketKey, $raw) { if ($raw === '') return null; $eioType = $raw[0]; switch ($eioType) { case '2': // Engine.IO ping self::sendRaw($socketKey, '3'); // pong return null; case '3': // Engine.IO pong return null; case '5': // Engine.IO upgrade return null; case '4': // Engine.IO message → Socket.IO packet break; default: return null; } // Strip Engine.IO prefix "4" $sio = substr($raw, 1); if ($sio === '' || $sio === false) return null; $sioType = $sio[0]; $rest = substr($sio, 1); // Extract namespace from packet (before comma or ack digits) $ns = ''; if (isset($rest[0]) && $rest[0] === '/') { $commaPos = strpos($rest, ','); if ($commaPos !== false) { $ns = substr($rest, 1, $commaPos - 1); // strip leading / $rest = substr($rest, $commaPos + 1); } } switch ($sioType) { case '0': // CONNECT to namespace $sid = base_convert(mt_rand(1000000, 9999999) . microtime(true) * 1000, 10, 36); $nsPrefix = $ns ? '/' . $ns . ',' : ''; // Try connect handler (optional — auto-accepts if no handler) $connectEvent = $ns ? $ns . '/connect' : 'connect'; if (Q::canHandle($connectEvent)) { // Return as event so it dispatches to the handler return array('event' => $connectEvent, 'data' => array(), '_ns' => $ns, '_nsConnect' => true, '_nsSid' => $sid); } // Auto-accept: send CONNECT ack self::sendRaw($socketKey, '40' . $nsPrefix . '{"sid":"' . $sid . '"}'); // Store namespace membership if (!isset(self::$clients[$socketKey]['namespaces'])) { self::$clients[$socketKey]['namespaces'] = array(); } self::$clients[$socketKey]['namespaces'][$ns] = true; return null; case '1': // DISCONNECT from namespace $disconnectEvent = $ns ? $ns . '/disconnect' : 'disconnect'; if (isset(self::$clients[$socketKey]['namespaces'])) { unset(self::$clients[$socketKey]['namespaces'][$ns]); } return array('event' => '_disconnect', 'data' => array(), '_ns' => $ns); case '2': // EVENT (possibly with ack) // Extract optional ack ID (digits before JSON array) $ackId = null; $i = 0; while ($i < strlen($rest) && ctype_digit($rest[$i])) $i++; if ($i > 0) { $ackId = (int) substr($rest, 0, $i); $rest = substr($rest, $i); } $arr = json_decode($rest, true); if (!is_array($arr) || empty($arr)) return null; $eventName = array_shift($arr); $data = isset($arr[0]) ? $arr[0] : array(); // Prepend namespace to event name if ($ns) $eventName = $ns . '/' . $eventName; $msg = array('event' => $eventName, 'data' => $data); if ($ackId !== null) $msg['ack'] = $ackId; return $msg; case '3': // ACK (client responding to server RPC) $i = 0; while ($i < strlen($rest) && ctype_digit($rest[$i])) $i++; $ackId = ($i > 0) ? (int) substr($rest, 0, $i) : null; $rest = substr($rest, $i); $arr = json_decode($rest, true); $result = (is_array($arr) && !empty($arr)) ? $arr[0] : null; if ($ackId !== null) { self::handleRpcResponse($ackId, $result); } return null; default: return null; } } /** * Send raw text frame to a WebSocket client (no JSON wrapping). * Used for Socket.IO protocol frames. */ static function sendRaw($socketKey, $text) { if (!isset(self::$clients[$socketKey]['socket'])) return; self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $text); } /** * Send Engine.IO ping to all Socket.IO clients. * Called on a 25s timer by the parent process. */ static function pingSocketIO() { foreach (self::$clients as $sk => $c) { if (($c['protocol'] ?? 'json') === 'socketio') { self::sendRaw($sk, '2'); } } } /** * Send a Socket.IO ACK response: 43[data] */ /** * Send a Socket.IO ACK response: 43[data] * or bare JSON: {"ack": ackId, "data": ...} */ static function sendAck($socketKey, $ackId, $data) { $proto = self::$clients[$socketKey]['protocol'] ?? 'json'; if ($proto === 'socketio') { self::sendRaw($socketKey, '43' . $ackId . json_encode(array($data), JSON_UNESCAPED_SLASHES)); } else { self::send($socketKey, array('ack' => $ackId, 'data' => $data)); } } /** * Encode outgoing data for the client's protocol. */ static function encodeSend($socketKey, $data) { $proto = self::$clients[$socketKey]['protocol'] ?? 'json'; if ($proto === 'socketio') { $event = $data['event'] ?? 'message'; $payload = $data['data'] ?? $data; $arr = array($event, $payload); return '42' . json_encode($arr, JSON_UNESCAPED_SLASHES); } // Bare WebSocket — plain JSON return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } static function spawnWorker($socketKey, $path) { if (!function_exists('pcntl_fork')) return; $pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET; $pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); if (!$pair) return; $pid = pcntl_fork(); if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; } if ($pid === 0) { // ── CHILD: connection message loop ── fclose($pair[0]); $pipe = $pair[1]; Q_Socket::$_pipe = $pipe; $socket = new Q_Socket($socketKey); $connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null); if ($connectHandler) { Q::event($connectHandler, array( 'socket' => $socket, 'path' => $path, 'event' => '_connect', 'data' => array(), )); Q_Socket::flush(); } while (true) { // Check message queue first (filled by __call when it // reads non-RPC messages while waiting for a response) if (!empty(Q_Socket::$_messageQueue)) { $msg = array_shift(Q_Socket::$_messageQueue); } else { $header = @fread($pipe, 4); if ($header === false || $header === '' || strlen($header) < 4) break; $len = unpack('N', $header)[1]; if ($len <= 0 || $len > 10485760) break; $json = ''; while (strlen($json) < $len) { $chunk = @fread($pipe, $len - strlen($json)); if ($chunk === false || $chunk === '') break 2; $json .= $chunk; } $msg = json_decode($json, true); if (!$msg) continue; } $event = $msg['event'] ?? ''; if ($event === '_disconnect') break; $mapped = Q_Config::get('Q', 'webserver', 'sockets', 'events', $event, $event); Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null; $result = null; $params = array( 'socket' => $socket, 'path' => $path, 'event' => $event, 'data' => $msg['data'] ?? array(), ); Q::event($mapped, $params, false, false, $result); if (Q_Socket::$_ack !== null && $result !== null) { Q_Socket::_cmd(array( 'cmd' => 'ack', 'socketId' => $socket->id, 'ackId' => Q_Socket::$_ack, 'data' => $result, )); } Q_Socket::flush(); } $disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null); if ($disconnectHandler) { Q::event($disconnectHandler, array( 'socket' => $socket, 'event' => '_disconnect', 'data' => array(), )); Q_Socket::flush(); } fclose($pipe); exit(0); } // ── PARENT ── fclose($pair[1]); stream_set_blocking($pair[0], false); $ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($socketKey) { $data = @fread($pipe, 65536); if ($data === false || $data === '') { Q_WebSocket::cleanupWorker($socketKey); return; } $lines = explode("\n", trim($data)); foreach ($lines as $line) { if ($line === '') continue; $cmd = json_decode($line, true); if ($cmd) Q_WebSocket::executeCommand($cmd); } }); self::$workers[$socketKey] = array( 'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher, ); } static function cleanupWorker($socketKey) { if (!isset(self::$workers[$socketKey])) return; $w = self::$workers[$socketKey]; if ($w['watcher']) Q_Evented::cancel($w['watcher']); @fclose($w['pipe']); if ($w['pid'] > 0 && function_exists('posix_kill')) { posix_kill($w['pid'], SIGTERM); pcntl_waitpid($w['pid'], $st, WNOHANG); } unset(self::$workers[$socketKey]); } static function notifyDisconnect($socketKey) { if (!isset(self::$workers[$socketKey])) return; $json = json_encode(array('event' => '_disconnect', 'data' => array())); $packet = pack('N', strlen($json)) . $json; @fwrite(self::$workers[$socketKey]['pipe'], $packet); self::cleanupWorker($socketKey); } // ── Room workers (process-per-room) ───────────── /** * Get room patterns from config. Cached. * Config format: * Q.webserver.sockets.rooms.$pattern = {handler, tick?} * e.g. "game/$id" => {"handler": "game/room", "tick": 100} * @method getRoomPatterns * @static */ static function getRoomPatterns() { if (self::$roomPatterns !== null) return self::$roomPatterns; self::$roomPatterns = Q_Config::get('Q', 'webserver', 'sockets', 'rooms', array()); return self::$roomPatterns; } /** * Check if a room name matches a configured room pattern. * Returns the config (handler, tick) or null. * @method matchRoomPattern * @static */ static function matchRoomPattern($roomName) { $patterns = self::getRoomPatterns(); if (empty($patterns)) return null; $segments = explode('/', $roomName); foreach ($patterns as $pattern => $config) { $pSegments = explode('/', $pattern); if (count($pSegments) !== count($segments)) continue; $match = true; $params = array(); for ($i = 0; $i < count($pSegments); $i++) { $ps = $pSegments[$i]; if (isset($ps[0]) && ($ps[0] === '$' || $ps[0] === ':')) { $params[substr($ps, 1)] = $segments[$i]; } elseif ($ps !== $segments[$i]) { $match = false; break; } } if ($match) { return array_merge((array) $config, array('_params' => $params, '_pattern' => $pattern)); } } return null; } /** * Spawn a room worker process. * @method spawnRoomWorker * @static */ static function spawnRoomWorker($roomName, $config) { if (!function_exists('pcntl_fork')) return; if (isset(self::$roomWorkers[$roomName])) return; $pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET; $pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); if (!$pair) return; $handler = $config['handler'] ?? ''; $tick = isset($config['tick']) ? (int) $config['tick'] : 0; $params = $config['_params'] ?? array(); $pid = pcntl_fork(); if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; } if ($pid === 0) { // ── CHILD: room message loop ── fclose($pair[0]); $pipe = $pair[1]; Q_Socket::$_pipe = $pipe; // Set up tick timer if configured $tickCallback = null; if ($tick > 0) { $tickCallback = function () use ($handler, $roomName, $params, $pipe) { Q_Socket::$_ack = null; $result = null; $room = new Q_Room($roomName, 0, $params); $p = array_merge($params, array( 'room' => $room, 'event' => '_tick', 'data' => array(), )); Q::event($handler . '/tick', $p, false, false, $result); Q_Socket::flush(); }; } // Fire _init event $result = null; $room = new Q_Room($roomName, 0, $params); Q::event($handler . '/init', array_merge($params, array( 'room' => $room, 'event' => '_init', 'data' => array(), )), false, false, $result); Q_Socket::flush(); // Message loop with optional tick stream_set_blocking($pipe, false); $lastTick = microtime(true); while (true) { $read = array($pipe); $write = $except = null; $timeout = $tick > 0 ? max(0.001, ($tick / 1000.0) - (microtime(true) - $lastTick)) : 1.0; $ready = @stream_select($read, $write, $except, (int) $timeout, (int) (($timeout - (int) $timeout) * 1000000)); // Tick if ($tick > 0 && (microtime(true) - $lastTick) * 1000 >= $tick) { $lastTick = microtime(true); if ($tickCallback) $tickCallback(); } if ($ready === false) break; if ($ready === 0) continue; // Read length-prefixed messages $raw = @fread($pipe, 65536); if ($raw === false || $raw === '') break; // May contain multiple messages $buf = $raw; while (strlen($buf) >= 4) { $len = unpack('N', substr($buf, 0, 4))[1]; if ($len <= 0 || $len > 10485760) { $buf = ''; break; } if (strlen($buf) < 4 + $len) break; $json = substr($buf, 4, $len); $buf = substr($buf, 4 + $len); $msg = json_decode($json, true); if (!$msg) continue; $event = $msg['event'] ?? ''; if ($event === '_shutdown') break 2; Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null; $senderSocketId = $msg['_socketId'] ?? 0; $result = null; $room = new Q_Room($roomName, $senderSocketId, $params); $p = array_merge($params, array( 'room' => $room, 'event' => $event, 'data' => $msg['data'] ?? array(), )); // Lifecycle events: _join → handler/join // User events: message → handler/message $eventPath = $handler . '/' . ltrim($event, '_'); Q::event($eventPath, $p, false, false, $result); if (Q_Socket::$_ack !== null && $result !== null) { Q_Socket::_cmd(array( 'cmd' => 'ack', 'socketId' => $room->socketId, 'ackId' => Q_Socket::$_ack, 'data' => $result, )); } Q_Socket::flush(); } } // Fire _destroy event $room = new Q_Room($roomName, 0, $params); Q::event($handler . '/destroy', array_merge($params, array( 'room' => $room, 'event' => '_destroy', 'data' => array(), )), false, false, $result); Q_Socket::flush(); fclose($pipe); exit(0); } // ── PARENT ── fclose($pair[1]); stream_set_blocking($pair[0], false); $ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($roomName) { $data = @fread($pipe, 65536); if ($data === false || $data === '') { Q_WebSocket::cleanupRoomWorker($roomName); return; } $lines = explode("\n", trim($data)); foreach ($lines as $line) { if ($line === '') continue; $cmd = json_decode($line, true); if ($cmd) Q_WebSocket::executeCommand($cmd); } }); self::$roomWorkers[$roomName] = array( 'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher, 'members' => array(), ); } /** * Send a message to a room worker. * @method sendToRoomWorker * @static */ static function sendToRoomWorker($roomName, $msg) { if (!isset(self::$roomWorkers[$roomName])) return; $json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $packet = pack('N', strlen($json)) . $json; @fwrite(self::$roomWorkers[$roomName]['pipe'], $packet); } /** * Notify room worker when a socket joins. * @method notifyRoomJoin * @static */ static function notifyRoomJoin($channel, $socketKey, $data = array()) { $config = self::matchRoomPattern($channel); if (!$config) return; // Spawn room worker if not running if (!isset(self::$roomWorkers[$channel])) { self::spawnRoomWorker($channel, $config); } if (!isset(self::$roomWorkers[$channel])) return; self::$roomWorkers[$channel]['members'][$socketKey] = true; self::sendToRoomWorker($channel, array( 'event' => '_join', 'data' => $data, '_socketId' => $socketKey, )); } /** * Notify room worker when a socket leaves. * @method notifyRoomLeave * @static */ static function notifyRoomLeave($channel, $socketKey, $data = array()) { if (!isset(self::$roomWorkers[$channel])) return; unset(self::$roomWorkers[$channel]['members'][$socketKey]); self::sendToRoomWorker($channel, array( 'event' => '_leave', 'data' => $data, '_socketId' => $socketKey, )); // Shut down room if empty if (empty(self::$roomWorkers[$channel]['members'])) { self::sendToRoomWorker($channel, array( 'event' => '_shutdown', 'data' => array(), )); self::cleanupRoomWorker($channel); } } /** * Clean up a room worker. * @method cleanupRoomWorker * @static */ static function cleanupRoomWorker($roomName) { if (!isset(self::$roomWorkers[$roomName])) return; $w = self::$roomWorkers[$roomName]; if ($w['watcher']) Q_Evented::cancel($w['watcher']); @fclose($w['pipe']); if ($w['pid'] > 0 && function_exists('posix_kill')) { posix_kill($w['pid'], SIGTERM); pcntl_waitpid($w['pid'], $st, WNOHANG); } unset(self::$roomWorkers[$roomName]); } // ── In-process fallback (Windows) ─────────────── static function dispatchEventInProcess($eventName, $params, $socketKey, $ack) { Q_Socket::$_directMode = true; Q_Socket::$_ack = $ack; $socket = new Q_Socket($socketKey); $params['socket'] = $socket; $result = null; Q::event($eventName, $params, false, false, $result); if ($ack !== null && $result !== null) { self::send($socketKey, array('ack' => $ack, 'data' => $result)); } Q_Socket::$_directMode = false; } // ── IPC command execution ─────────────────────── static function clientCount() { return count(self::$clients); } static function executeCommand($cmd) { switch ($cmd['cmd'] ?? '') { case 'send': self::send($cmd['socketId'], $cmd['data']); break; case 'broadcast': self::broadcastTo($cmd['room'], $cmd['data']); break; case 'broadcastAll': self::broadcast($cmd['data']); break; case 'join': self::subscribe($cmd['socketId'], $cmd['room'], $cmd['data'] ?? array()); break; case 'leave': self::unsubscribe($cmd['socketId'], $cmd['room'], $cmd['data'] ?? array()); break; case 'ack': self::sendAck($cmd['socketId'], $cmd['ackId'], $cmd['data']); break; case 'rpc': self::handleRpc($cmd); break; } } // ── Server→Client RPC ─────────────────────────── /** @internal Maps rpcAckId → ['pipe' => resource, 'rpcId' => int] */ static $pendingRpc = array(); /** @internal Counter for server→client ack IDs */ static $rpcAckCounter = 0; /** * Handle an RPC request from a child process. * Sends the method call to the client with an ack ID, then routes * the client's ack response back to the child's IPC pipe. */ static function handleRpc($cmd) { $socketKey = $cmd['socketId']; $method = $cmd['method']; $data = $cmd['data'] ?? array(); $rpcId = $cmd['rpcId']; // Generate a unique ack ID for server→client $ackId = ++self::$rpcAckCounter; // Find which child pipe to route the response back to $childPipe = null; if (isset(self::$workers[$socketKey])) { $childPipe = self::$workers[$socketKey]['pipe']; } if (!$childPipe) return; // Store mapping so we can route the ack response back self::$pendingRpc[$ackId] = array( 'pipe' => $childPipe, 'rpcId' => $rpcId, ); // Send RPC call to client with ack ID $proto = self::$clients[$socketKey]['protocol'] ?? 'json'; if ($proto === 'socketio') { // Socket.IO: 42["method", data] $payload = '42' . $ackId . json_encode(array($method, $data), JSON_UNESCAPED_SLASHES); self::sendRaw($socketKey, $payload); } else { // Bare: {"event":"method", "data":..., "ack": ackId} self::send($socketKey, array('event' => $method, 'data' => $data, 'ack' => $ackId)); } } /** * Route an ack response from a client back to the child that * initiated the RPC call. * @return boolean True if this was an RPC ack and was handled */ static function handleRpcResponse($ackId, $result) { if (!isset(self::$pendingRpc[$ackId])) return false; $pending = self::$pendingRpc[$ackId]; unset(self::$pendingRpc[$ackId]); // Send response back to child via IPC pipe $response = json_encode(array( '_rpc' => $pending['rpcId'], 'result' => $result, ), JSON_UNESCAPED_SLASHES); $packet = pack('N', strlen($response)) . $response; @fwrite($pending['pipe'], $packet); return true; } } fields[$name] ?? null; } function __set($name, $value) { $this->fields[$name] = $value; } function __isset($name) { return isset($this->fields[$name]); } function toArray() { return $this->fields; } /** * Create a Q_Uri from an array of fields. * @method from * @static */ static function from($fields) { $uri = new self(); if (is_array($fields)) { $uri->fields = $fields; } return $uri; } /** * Get merged routes from Q/routes@start, Q/routes, Q/routes@end. * Same merge order as the full Platform. Memoized. * @method getRoutes * @static * @return {array} */ static function getRoutes() { if (isset(self::$routesCache)) { return self::$routesCache; } $routesStart = Q_Config::get('Q', 'routes@start', array()); $routes = Q_Config::get('Q', 'routes', array()); $routesEnd = Q_Config::get('Q', 'routes@end', array()); // Reverse order within each block (later plugins override earlier) $result = array(); foreach (array($routesStart, $routes, $routesEnd) as $source) { if (!is_array($source)) continue; $keys = array_keys($source); $vals = array_values($source); $keys = array_reverse($keys); $vals = array_reverse($vals); foreach ($keys as $i => $k) { if (!isset($result[$k])) { $result[$k] = $vals[$i]; } } } self::$routesCache = $result; return $result; } /** * Clear all memoized routing state. * Call when config changes (e.g. --hot reload). * @method clearRouteCache * @static */ static function clearRouteCache() { self::$routesCache = null; self::$compiledPatterns = array(); self::$pathCache = array(); } /** * Route a URL path to a Q_Uri using configured routes. * Results are memoized — the same path always returns the same URI. * @method fromPath * @static * @param {string} $path URL path (e.g. "api/users/42") * @return {Q_Uri|null} */ static function fromPath($path) { $path = trim($path, '/'); if (isset(self::$pathCache[$path])) { return self::$pathCache[$path]; } $segments = $path !== '' ? explode('/', $path) : array(); $routes = self::getRoutes(); if (empty($routes)) { self::$pathCache[$path] = null; return null; } foreach ($routes as $pattern => $fields) { if (!isset($fields)) continue; // disabled route $matched = self::matchSegments($pattern, $segments); if ($matched === false) continue; // Check regex constraints on matched values $valid = true; foreach ($matched as $k => $v) { if (isset($fields[$k]) && is_string($fields[$k])) { if (!preg_match('/' . $fields[$k] . '/', $v)) { $valid = false; break; } } } // Special condition handler (same as Platform) if ($valid && !empty($fields[''])) { $params = array( 'uriFields' => $matched, 'routeFields' => $fields, 'fields' => array_merge($fields, $matched), 'pattern' => $pattern, ); if (false === Q::event($fields[''], $params, false, false, $params)) { $valid = false; } } if (!$valid) continue; // Merge route defaults with matched values $uriFields = array(); foreach ($fields as $k => $v) { if ($k === '' || is_int($k)) continue; $uriFields[$k] = $v; } $uriFields = array_merge($uriFields, $matched); $uri = new self(); $uri->fields = $uriFields; $uri->route = $pattern; self::$pathCache[$path] = $uri; return $uri; } self::$pathCache[$path] = null; return null; } /** * Compile a route pattern into a reusable structure. * Same implementation as the full Platform's Q_Uri::compilePattern(). * Memoized by pattern string — compiled once, reused forever. * @method compilePattern * @static * @protected */ protected static function compilePattern($pattern) { if (isset(self::$compiledPatterns[$pattern])) { return self::$compiledPatterns[$pattern]; } $route_segments = explode('/', $pattern); $tailArray = false; $tailField = null; $valid = true; if (substr($pattern, -2) === '[]') { $tailArray = true; $last_rs = end($route_segments); if (!isset($last_rs[0]) || !in_array($last_rs[0], self::$variablePrefixes)) { $valid = false; } else { $tailField = substr($last_rs, 1, -2); } $route_segments = array_slice($route_segments, 0, -1); } $segments = array(); foreach ($route_segments as $rs) { $rs_parts = explode('.', $rs); $parts = array(); foreach ($rs_parts as $part) { if (!isset($part[0]) || !in_array($part[0], self::$variablePrefixes)) { $parts[] = array( 'var' => false, 'literal' => str_replace( self::$escapedVariablePrefixes, self::$variablePrefixes, $part ) ); } else { $parts[] = array( 'var' => true, 'field' => substr($part, 1) ); } } $segments[] = $parts; } $compiled = array( 'valid' => $valid, 'segments' => $segments, 'count' => count($segments), 'tailArray' => $tailArray, 'tailField' => $tailField, ); self::$compiledPatterns[$pattern] = $compiled; return $compiled; } /** * Match URL segments against a compiled route pattern. * Same implementation as the full Platform's Q_Uri::matchSegments(). * @method matchSegments * @static * @protected */ protected static function matchSegments($pattern, $segments) { if (!$pattern && $pattern !== '0') { return count($segments) === 0 ? array() : false; } $compiled = self::compilePattern($pattern); if (!$compiled['valid']) return false; $count = $compiled['count']; $segCount = count($segments); if ($compiled['tailArray']) { if ($count >= $segCount) return false; } else { if ($count !== $segCount) return false; } $args = array(); $cs = $compiled['segments']; for ($i = 0; $i < $count; $i++) { $rs_parts = $cs[$i]; $rs_parts_count = count($rs_parts); $segment = urldecode($segments[$i]); $s_parts = explode('.', $segment, $rs_parts_count); if (count($s_parts) < $rs_parts_count) return false; for ($j = 0; $j < $rs_parts_count; $j++) { $p = $rs_parts[$j]; if (!$p['var']) { if ($s_parts[$j] !== $p['literal']) return false; continue; } $args[$p['field']] = $s_parts[$j]; } } if ($compiled['tailArray']) { $args[$compiled['tailField']] = array(); for (; $i < $segCount; $i++) { $args[$compiled['tailField']][] = urldecode($segments[$i]); } } return $args; } } /*! * Socket.IO v4.8.1 * (c) 2014-2024 Guillermo Rauch * Released under the MIT License. */ !function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); //# sourceMappingURL=socket.io.min.js.map 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; } 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::onSignal(SIGHUP, function () { echo "\n Reloading (SIGHUP)...\n"; self::stop(); Q_Evented::stop(); // Re-exec with same arguments $args = $_SERVER['argv'] ?? array(); if (function_exists('pcntl_exec')) { pcntl_exec(PHP_BINARY, $args); } }); // Reap zombie children from fork-per-request PHP execution Q_Evented::onSignal(SIGCHLD, function () { while (($pid = pcntl_waitpid(-1, $st, WNOHANG)) > 0) { unset(Q_WebServer::$workerPids[$pid]); } }); } // Engine.IO ping timer — keeps Socket.IO connections alive Q_Evented::repeat(25, function () { Q_WebSocket::pingSocketIO(); }); // Request timeout — kill workers that exceed the configured limit $timeout = Q_Config::get('Q', 'webserver', 'requestTimeout', 30); if ($timeout > 0) { Q_Evented::repeat(1, function () use ($timeout) { $now = microtime(true); foreach (Q_WebServer::$workerPids as $pid => $start) { if ($now - $start > $timeout) { @posix_kill($pid, SIGKILL); unset(Q_WebServer::$workerPids[$pid]); } } }); } // Scheduler — run tasks on intervals or at specific times $schedule = Q_Config::get('Q', 'scheduler', array()); if (!empty($schedule)) { Q_Scheduler::init($schedule); Q_Evented::repeat(1, function () { Q_Scheduler::tick(); }); } // Hot reload — watch for file changes in classes/, handlers/, config/ if (Q_Config::get('Q', 'webserver', 'hotReload', false)) { Q_HotReload::init(); Q_Evented::repeat(2, function () { Q_HotReload::check(); }); } Q_Evented::run(); } // ── Connection handling ────────────────────────────── static function onAccept($socket) { // Max connections check (cached) static $maxConn = null; if ($maxConn === null) $maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024); if (count(self::$clients) >= $maxConn) { $reject = @stream_socket_accept($socket, 0); if ($reject) { @fwrite($reject, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($reject); } return; } $client = @stream_socket_accept($socket, 0); if (!$client) return; stream_set_blocking($client, false); // Disable Nagle's algorithm — eliminates 40ms delayed ACK on keep-alive if (function_exists('socket_import_stream')) { $rawSocket = socket_import_stream($client); if ($rawSocket) { socket_set_option($rawSocket, SOL_TCP, TCP_NODELAY, 1); } } $key = (int) $client; self::$clients[$key] = $client; self::$buffers[$key] = ''; self::$keepAliveCount[$key] = 0; // Store remote IP for logging + proxy resolution $peer = stream_socket_get_name($client, true); $ip = $peer ? explode(':', $peer)[0] : '0.0.0.0'; self::$clientInfo[$key] = array( 'ip' => $ip, 'connectTime' => microtime(true) ); // Rate limit check if (!self::checkRateLimit($ip)) { @fwrite($client, "HTTP/1.1 429 Too Many Requests\r\n" . "Retry-After: 60\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($client); unset(self::$clients[$key], self::$buffers[$key], self::$keepAliveCount[$key], self::$clientInfo[$key]); return; } self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { Q_WebServer::onClientData($c); } ); // Read timeout — close if no complete request within N seconds static $readTimeout = null; if ($readTimeout === null) $readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30); self::$timeoutWatchers[$key] = Q_Evented::delay($readTimeout, function () use ($key) { Q_WebServer::closeClient($key); }); } static function onClientData($client) { $key = (int) $client; if (!isset(self::$clients[$key])) return; // Check if we already have a complete request from pipelining $buf = self::$buffers[$key] ?? ''; $havePipelined = ($buf !== '' && strpos($buf, "\r\n\r\n") !== false); if (!$havePipelined) { $chunk = @fread($client, 65536); if ($chunk === false || $chunk === '') { self::closeClient($key); return; } self::$buffers[$key] .= $chunk; $buf = self::$buffers[$key]; } // Wait for complete headers $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd === false) { if (strlen($buf) > 65536) self::closeClient($key); return; } // Wait for complete body on POST/PUT/PATCH $firstChar = $buf[0]; if ($firstChar === 'P') { // POST, PUT, PATCH all start with P $cl = 0; if (preg_match('/content-length:\s*(\d+)/i', $buf, $m)) { $cl = (int) $m[1]; } if ($cl > 10485760) { self::sendResponse($client, 413, 'Payload Too Large'); self::closeClient($key); return; } if (strlen($buf) - $headerEnd - 4 < $cl) return; } // Cancel read timeout (request received) if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } // Calculate consumed bytes for pipelining support $headerEnd = strpos($buf, "\r\n\r\n"); $bodyLen = 0; $firstChar = $buf[0]; if ($firstChar === 'P') { // POST/PUT/PATCH if (preg_match('/content-length:\s*(\d+)/i', $buf, $clm)) { $bodyLen = (int) $clm[1]; } } $consumed = $headerEnd + 4 + $bodyLen; $start = microtime(true); $parsed = self::parseRequest($buf); // Reject malformed request lines if (!empty($parsed['_malformed'])) { self::sendResponse($client, 400, 'Bad Request'); self::closeClient($key); return; } // Reject oversized headers (>64KB total) $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd > 65536) { self::sendResponse($client, 431, 'Request Header Fields Too Large', 'text/plain; charset=utf-8', array('Connection' => 'close')); self::closeClient($key); return; } // Resolve proxy headers for real client IP $directIp = self::$clientInfo[$key]['ip'] ?? '0.0.0.0'; $parsed['clientIp'] = Q_WebServer_Proxy::clientIp($directIp, $parsed['headers']); $parsed['_remoteAddr'] = $parsed['clientIp']; $peer = stream_socket_get_name($client, true); $parsed['_remotePort'] = $peer ? (int) substr(strrchr($peer, ':'), 1) : 0; // Determine keep-alive before handling request static $maxKeepAlive = null; if ($maxKeepAlive === null) $maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100); $connHeader = strtolower($parsed['headers']['connection'] ?? 'keep-alive'); self::$keepAliveCount[$key] = (self::$keepAliveCount[$key] ?? 0) + 1; $parsed['_keepAlive'] = ($connHeader !== 'close') && self::$keepAliveCount[$key] < $maxKeepAlive; try { $savedRoot = self::$rootDir; $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; } finally { // Restore rootDir after vhost override if (self::$rootDir !== $savedRoot) { self::$rootDir = $savedRoot; } } $ms = round((microtime(true) - $start) * 1000, 1); if ($keepOpen) { // WebSocket upgraded — Q_WebSocket owns this socket now if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); return; } // Stats + logging Q_WebServer_Dashboard::recordRequest( $parsed['method'], $parsed['uri'], self::$lastStatus, $ms ); if (self::$onRequest) { (self::$onRequest)($parsed['method'], $parsed['uri'], self::$lastStatus, $ms); } // Log to file $bodyLen = strlen(self::$lastBody ?? ''); Q_WebServer_Log::access( $parsed['clientIp'], $parsed['method'], $parsed['uri'], self::$lastStatus, $bodyLen, $parsed['headers']['referer'] ?? '', $parsed['headers']['user-agent'] ?? '', $ms ); // ── Keep-alive decision ────────────────────────── static $keepAliveTimeout = null; if ($keepAliveTimeout === null) $keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15); $shouldKeepAlive = !empty($parsed['_keepAlive']) && self::$lastStatus < 500; if ($shouldKeepAlive) { // Keep leftover data for pipelined requests $leftover = strlen($buf) > $consumed ? substr($buf, $consumed) : ''; self::$buffers[$key] = $leftover; // Set idle timeout — close if no new request arrives self::$timeoutWatchers[$key] = Q_Evented::delay( $keepAliveTimeout, function () use ($key) { Q_WebServer::closeClient($key); } ); // If there's already a complete request in the buffer, process it now if ($leftover !== '' && strpos($leftover, "\r\n\r\n") !== false) { Q_Evented::defer(function () use ($client) { Q_WebServer::onClientData($client); }); } } else { self::closeClient($key); } } // ── Request routing ────────────────────────────────── /** * Route a parsed request and return a response array. * * This is the clean interface that external HTTP drivers * (like amphp/http-server) call. Handles all routing: * blocked paths, static files, PHP dispatch, directory * listings, X-Accel-Redirect, compression. * * The built-in server uses handleRequest() which writes * directly to sockets. amphp calls route() and converts * the response to its own format. * * @method route * @static * @param {array} $parsed [method, uri, path, query, headers, body, clientIp] * @return {array} [status, headers, body] */ static function route($parsed) { $method = $parsed['method']; $path = $parsed['path']; // Reverse cache check (before any dispatch) $cached = Q_WebServer_Cache::get($parsed); if ($cached) return $cached; if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); return array('status'=>200, 'body'=>json_encode(array('status'=>'ok')+$stats), 'headers'=>array('Content-Type'=>'application/json')); } if ($path === '/Q/dashboard' || $path === '/Q/dashboard/') { return array('status'=>200, 'body'=>Q_WebServer_Dashboard::renderHtml($parsed), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } if (self::isBlocked($path)) { return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } $fsPath = self::resolveStatic($path); // Directory if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { return array('status'=>301, 'body'=>'', 'headers'=>array('Location'=>$path.'/')); } foreach (array('index.html','index.php') as $idx) { $ip = $fsPath.DS.$idx; if (is_file($ip)) { $fsPath = $ip; break; } } if (is_dir($fsPath)) { if (self::isIndexed($path)) { return array('status'=>200, 'body'=>self::renderDirectoryListing($fsPath, $path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8', 'Cache-Control'=>'no-store')); } return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } } // File if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); if ($ext === 'php') { // PHP dispatch (in-process — amphp uses fibers for concurrency) $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } if (in_array($ext, self::$allowedExtensions) && ($method === 'GET' || $method === 'HEAD') ) { return self::buildFileResponse($fsPath, $ext, $method, $parsed['headers']); } } // Clean URL → index.php if (is_file(self::$rootDir . 'index.php')) { $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } return array('status'=>404, 'body'=>self::render404($path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } /** * Process a PHP response: X-Accel-Redirect + compression. * Used by both route() and handlePhp(). */ static function processPhpResponse($response, $reqHeaders) { $headers = Q_WebServer_Headers::stripInternal($response['headers'] ?? array()); $body = $response['body'] ?? ''; // X-Accel-Redirect foreach ($response['headers'] ?? array() as $k => $v) { if (strtolower($k) === 'x-accel-redirect') { $af = Q_WebServer_Headers::resolveAccelPath($v); if ($af && is_file($af)) { $body = file_get_contents($af); $ext = strtolower(pathinfo($af, PATHINFO_EXTENSION)); if (!Q_WebServer_Headers::hasHeader($headers, 'Content-Type')) { $headers['Content-Type'] = self::mimeType($ext); } } $headers = Q_WebServer_Headers::stripInternal($headers); break; } } $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); return array('status'=>$response['status']??200, 'body'=>$body, 'headers'=>$headers); } /** * Build a static file response with ETag/compression. * Used by route() for amphp compatibility. */ static function buildFileResponse($fsPath, $ext, $method, $reqHeaders) { clearstatcache(true, $fsPath); $mtime = filemtime($fsPath); $size = filesize($fsPath); $ct = self::mimeType($ext); $headers = array( 'Content-Type' => $ct, 'ETag' => '"' . dechex($mtime) . '-' . dechex($size) . '"', 'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT', 'Cache-Control' => 'public, max-age=0, must-revalidate' ); $body = ($method === 'HEAD') ? '' : file_get_contents($fsPath); if ($method !== 'HEAD') { $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); } return array('status'=>200, 'body'=>$body, 'headers'=>$headers); } // ── Built-in server: socket-based routing ──────────── /** * Route a request (built-in server). * Returns true if the connection should stay open (WebSocket). * @return {boolean} */ private static function handleRequest($client, $parsed) { $method = $parsed['method']; $path = $parsed['path']; // Virtual hosts — override rootDir based on Host header $host = $parsed['headers']['host'] ?? ''; $host = strtolower(preg_replace('/:\d+$/', '', $host)); // strip port $hostConfig = Q_Config::get('Q', 'webserver', 'hosts', $host, null); if ($hostConfig && isset($hostConfig['root'])) { $vroot = realpath($hostConfig['root']); if ($vroot && is_dir($vroot)) { self::$rootDir = rtrim(str_replace(array('/', '\\'), DS, $vroot), DS) . DS; } } // 1. Dashboard + Panel + WebSocket + Health (/Q/*) // 1. Serve client JS files (before /Q/ check — socket.io.js is at /socket.io/) $jsMap = array(); $jsPath = Q_Config::get('Q', 'socket', 'js', '/Q/socket.js'); if ($jsPath !== false) $jsMap[$jsPath] = __DIR__ . DS . 'socket.js'; $ioPath = Q_Config::get('Q', 'socket', 'io', '/socket.io'); if ($ioPath !== false) $jsMap[$ioPath . '/socket.io.js'] = __DIR__ . DS . 'socket.io.js'; if (isset($jsMap[$path])) { $jsFile = $jsMap[$path]; if (file_exists($jsFile)) { self::sendResponse($client, 200, file_get_contents($jsFile), 'application/javascript', array('Cache-Control' => 'public, max-age=3600')); } else { self::sendResponse($client, 404, 'Not found'); } return false; } // 2. Dashboard + Panel + WebSocket + Health (/Q/*) if (strpos($path, '/Q/') === 0) { if ($path === '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], null, 'dashboard' ); return $upgraded; // true = keep open } if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); self::sendResponse($client, 200, json_encode(array('status' => 'ok') + $stats), 'application/json'); return false; } // Panel (control panel + API) $handled = Q_WebServer_Panel::handle($client, $parsed); if ($handled) return false; // Dashboard (live stats) $handled = Q_WebServer_Dashboard::handle($client, $parsed); if ($handled) return false; } // 2. WebSocket upgrade on any path $upgrade = strtolower($parsed['headers']['upgrade'] ?? ''); if ($upgrade === 'websocket' && $path !== '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], function ($sk, $msg) use ($path) { Q_WebSocket::dispatchEvent($sk, $msg, $path); }, null, $path ); return $upgraded; } // 3. Blocked paths if (self::isBlocked($path)) { self::sendResponse($client, 403, 'Forbidden'); return false; } // 3. Component cache check (Merkle tree — serves from cached slots) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); $cachedPage = Q_WebServer_Cache_Components::getPage($pageKey); if ($cachedPage !== null) { self::sendResponse($client, 200, $cachedPage, 'text/html; charset=utf-8', array('X-Cache' => 'HIT-COMPONENTS')); return false; } } // 4. Reverse cache check (before forking a worker) $cached = Q_WebServer_Cache::get($parsed); if ($cached) { self::sendResponse($client, $cached['status'], $cached['body'], $cached['headers']['Content-Type'] ?? 'text/html', $cached['headers']); return false; } // 4. Resolve filesystem path $fsPath = self::resolveStatic($path); // 4. Directory handling if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { self::sendRedirect($client, $path . '/'); return false; } // Check for index files foreach (array('index.html', 'index.php') as $idx) { $indexPath = $fsPath . DS . $idx; if (is_file($indexPath)) { $fsPath = $indexPath; break; } } if (is_dir($fsPath)) { // No index file → check if listings are enabled for this path if (self::isIndexed($path)) { $html = self::renderDirectoryListing($fsPath, $path); self::sendResponse($client, 200, $html, 'text/html; charset=utf-8', array('Cache-Control' => 'no-store')); } else { self::sendResponse($client, 403, 'Forbidden'); } return false; } } // 5. File handling if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // PHP scripts → worker pool or in-process if ($ext === 'php') { return self::handlePhp($client, $parsed, $fsPath); } // Static file if ($method === 'GET' || $method === 'HEAD') { self::serveStaticFile($client, $fsPath, $method, $parsed['headers'], !empty($parsed['_keepAlive'])); return false; } } // 6. Route dispatch — if Q.routes configured, match URL to handler // Q_Uri caches compiled patterns and path→URI results in memory. static $routingEnabled = null; if ($routingEnabled === null) { $routingEnabled = Q_Config::get('Q', 'routes', null) !== null && class_exists('Q_Uri', true); } if ($routingEnabled) { $uri = Q_Uri::fromPath($path); if ($uri && !empty($uri->module) && !empty($uri->action)) { return self::handleRoute($client, $parsed, $uri); } } // 7. Clean URL → route through index.php (if exists) $indexPhp = self::$rootDir . 'index.php'; if (is_file($indexPhp)) { return self::handlePhp($client, $parsed, $indexPhp); } // 8. Configurable fallback (SPA routing, custom 404 page, etc.) // Q.webserver.fallback can be: // - string: path to a static file relative to web/ (e.g. "index.html") // - object with "handler": event name to dispatch via Q::event() // - object with "file": static file + auto-detect Content-Type static $fallback = null; if ($fallback === null) $fallback = Q_Config::get('Q', 'webserver', 'fallback', null); if ($fallback !== null) { if (is_string($fallback)) { // Static file (SPA catch-all: serve index.html for all routes) $fbPath = self::$rootDir . str_replace('/', DS, $fallback); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } elseif (is_array($fallback)) { if (!empty($fallback['handler'])) { // Route to a handler — build a synthetic Q_Uri $uri = Q_Uri::from(array( 'module' => dirname($fallback['handler']), 'action' => basename($fallback['handler']), '_originalPath' => $path, )); if ($uri) { return self::handleRoute($client, $parsed, $uri); } } elseif (!empty($fallback['file'])) { $fbPath = self::$rootDir . str_replace('/', DS, $fallback['file']); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } } } // 9. Not found self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8'); return false; } /** * Handle a routed request via Q::event() dispatch pipeline. * Fires the same events as Qbix Platform's Q_Dispatcher: * {module}/{action}/validate → validate input * {module}/{action}/{method} → handle GET/POST/PUT/DELETE * {module}/{action}/response → render response * * @method handleRoute * @static * @private * @param {resource} $client * @param {array} $parsed * @param {Q_Uri} $uri * @return {boolean} */ private static function handleRoute($client, $parsed, $uri) { $module = $uri->module; $action = $uri->action; $routed = $uri->toArray(); $method = strtolower($parsed['method']); // get, post, put, delete // Set up superglobals $parsed['_scriptPath'] = ''; // no script — handler-based $saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE); $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SERVER_NAME'] = explode(':', $parsed['headers']['host'] ?? 'localhost')[0]; $_SERVER['SERVER_PORT'] = self::$port; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0'; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); $_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1'; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } $_GET = $_POST = $_REQUEST = $_FILES = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($parsed['headers']['content-type'] ?? ''); $rawBody = $parsed['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($rawBody, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($rawBody, true) ?: array(); } elseif (strpos($ct, 'multipart/form-data') !== false) { $origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''; self::parseMultipart($origCt, $rawBody, $_POST, $_FILES); } $_REQUEST = array_merge($_GET, $_POST); // Make raw body available Q_Request::$input = $rawBody; // If pcntl available, fork to isolate if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === 0) { // ── CHILD: run dispatch pipeline ── while (ob_get_level()) ob_end_clean(); ob_start(); $status = 200; $headers = array(); try { // 1. Validate Q::event("$module/$action/validate", $routed, false, true); // 2. Method handler (get, post, put, delete) if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } // 3. Response Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } elseif ($pid > 0) { @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); self::$workerPids[$pid] = microtime(true); pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; return false; } // Fork failed — fall through to in-process } // In-process fallback while (ob_get_level()) ob_end_clean(); header_remove(); http_response_code(200); ob_start(); $status = 200; $headers = array(); try { Q::event("$module/$action/validate", $routed, false, true); if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $status; Q_WebServer_Cache::put($parsed, $response); return false; } /** * Route a .php script to the worker pool or dispatch in-process. * @return {boolean} false (connection closes after response) */ private static function handlePhp($client, $parsed, $scriptPath) { // ── CGI carveout: check if this script should use php-cgi ── // Scripts matching Q.webserver.cgi.patterns run via php-cgi subprocess // where native header(), setcookie(), headers_list() all work. // Use for legacy/third-party code (WordPress, etc.) that calls header() directly. static $cgiPatterns = null; static $cgiBinary = null; if ($cgiPatterns === null) { $cgiPatterns = Q_Config::get('Q', 'webserver', 'cgi', 'patterns', array()); $cgiBinary = Q_Config::get('Q', 'webserver', 'cgi', 'binary', null); if (!$cgiBinary) { // Auto-detect php-cgi foreach (array('php-cgi', 'php-cgi8.3', 'php-cgi8.2', 'php-cgi8.1') as $bin) { $path = trim(shell_exec("which $bin 2>/dev/null") ?? ''); if ($path && is_executable($path)) { $cgiBinary = $path; break; } } } } if (!empty($cgiPatterns) && $cgiBinary) { $relPath = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen(self::$rootDir))), '/'); foreach ($cgiPatterns as $pattern) { if (@preg_match($pattern, $relPath)) { return self::handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary); } } } if (self::$pool) { self::$lastStatus = 200; self::$pool->dispatch($client, $parsed, $scriptPath); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); return false; } // No pool — fork a single child if pcntl is available. // This protects the server from exit()/die() in scripts // and prevents blocking the event loop during PHP execution. if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === -1) { // Fork failed — fall through to in-process execution } elseif ($pid === 0) { // ── CHILD: handle request, write response to client, exit ── $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } else { // ── PARENT: close client socket (child owns it now), reap later ── @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); self::$workerPids[$pid] = microtime(true); // Non-blocking reap — don't wait for child pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; return false; } } // Fallback: use proc_open to run PHP in a subprocess (Windows, // or fork failed). Safe against exit()/die() — the subprocess // dies, not the server. Slower than fork (no shared classes) // but still provides isolation. return self::handlePhpSubprocess($client, $parsed, $scriptPath); } /** * Execute a PHP script in a subprocess via proc_open. * Used on Windows (no pcntl_fork) or as fallback when fork fails. * The subprocess gets request data via stdin, returns response via stdout. * @method handlePhpSubprocess * @static * @private */ private static function handlePhpSubprocess($client, $parsed, $scriptPath) { // Build a small inline PHP worker that: // 1. Reads request JSON from stdin // 2. Sets up superglobals // 3. Loads Q.php for autoloader/events // 4. Includes the target script // 5. Writes response JSON to stdout $qFile = dirname(__FILE__) . DS . 'Q.php'; $workerCode = <<<'WORKER' 500,'body'=>'Bad request','headers'=>[]]); exit; } if (isset($req['qFile']) && file_exists($req['qFile'])) { require_once $req['qFile']; if (isset($req['projectRoot'])) Q::init($req['projectRoot']); } $_SERVER['REQUEST_METHOD'] = $req['method'] ?? 'GET'; $_SERVER['REQUEST_URI'] = $req['uri'] ?? '/'; $_SERVER['QUERY_STRING'] = $req['query'] ?? ''; $_SERVER['SCRIPT_FILENAME'] = $req['scriptPath'] ?? ''; $_SERVER['SCRIPT_NAME'] = '/' . basename($req['scriptPath'] ?? 'index.php'); $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; $_SERVER['PATH_TRANSLATED'] = $req['scriptPath'] ?? ''; $_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? ''; $_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME']; $_SERVER['SERVER_NAME'] = $req['serverName'] ?? 'localhost'; $_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080'; $_SERVER['SERVER_ADDR'] = '127.0.0.1'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0'; $_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1'; $_SERVER['REDIRECT_STATUS'] = 200; $_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1'; $_SERVER['REMOTE_PORT'] = $req['remotePort'] ?? 0; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); $_SERVER['REQUEST_SCHEME'] = ($req['https'] ?? false) ? 'https' : 'http'; $_SERVER['HTTPS'] = ($req['https'] ?? false) ? 'on' : ''; foreach ($req['headers'] ?? [] as $k=>$v) $_SERVER['HTTP_'.strtoupper(str_replace('-','_',$k))] = $v; if (isset($req['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $req['headers']['content-type']; if (isset($req['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length']; // Parse cookies $_COOKIE = []; $ck = $req['headers']['cookie'] ?? ''; if ($ck) { foreach (explode(';',$ck) as $p) { $p=trim($p); if(!$p)continue; $e=strpos($p,'='); if($e===false)continue; $_COOKIE[urldecode(trim(substr($p,0,$e)))]=urldecode(trim(substr($p,$e+1))); } } // Parse Basic auth $auth = $req['headers']['authorization'] ?? ''; if (stripos($auth,'Basic ')===0) { $d=base64_decode(substr($auth,6)); if($d&&strpos($d,':')!==false) { [$u,$pw]=explode(':',$d,2); $_SERVER['PHP_AUTH_USER']=$u; $_SERVER['PHP_AUTH_PW']=$pw; $_SERVER['AUTH_TYPE']='Basic'; } } $_GET = $_POST = $_REQUEST = $_FILES = []; if (!empty($req['query'])) parse_str($req['query'], $_GET); $ct = strtolower($_SERVER['CONTENT_TYPE'] ?? ''); $raw = $req['body'] ?? ''; if (strpos($ct,'application/x-www-form-urlencoded') !== false) parse_str($raw, $_POST); elseif (strpos($ct,'application/json') !== false) $_POST = json_decode($raw, true) ?: []; elseif (strpos($ct,'multipart/form-data') !== false) { $oct=$req['headers']['content-type']??''; Q_WebServer::parseMultipart($oct, $raw, $_POST, $_FILES); } $_REQUEST = array_merge($_COOKIE, $_GET, $_POST); if (class_exists('Q_Request',false)) Q_Request::$input = $raw; ob_start(); $status = 200; $headers = []; try { if (is_file($req['scriptPath'])) include $req['scriptPath']; else { $status = 404; echo 'Not Found'; } $headers = Q::getResponseHeaders(); $code = http_response_code(); if (Q::$_responseCode !== 200) $status = Q::$_responseCode; if ($code) $status = $code; } catch (Throwable $e) { $status = 500; ob_clean(); echo $e->getMessage(); $headers['Content-Type']='text/plain'; } $body = ob_get_clean(); echo json_encode(compact('status','body','headers'), JSON_UNESCAPED_SLASHES); WORKER; // Write worker code to a temp file (reuse across requests) static $workerFile = null; if (!$workerFile || !file_exists($workerFile)) { $workerFile = tempnam(sys_get_temp_dir(), 'qbix_worker_'); file_put_contents($workerFile, $workerCode); register_shutdown_function(function () use (&$workerFile) { @unlink($workerFile); }); } // Build request payload $host = $parsed['headers']['host'] ?? 'localhost'; $payload = json_encode(array( 'method' => $parsed['method'], 'uri' => $parsed['uri'], 'query' => $parsed['query'], 'headers' => $parsed['headers'], 'body' => $parsed['body'] ?? '', 'scriptPath' => $scriptPath, 'documentRoot'=> rtrim(self::$rootDir, DS), 'serverName' => explode(':', $host)[0], 'serverPort' => (string) self::$port, 'remoteAddr' => $parsed['_remoteAddr'] ?? '127.0.0.1', 'remotePort' => $parsed['_remotePort'] ?? 0, 'https' => !empty(self::$tlsSocket), 'qFile' => $qFile, 'projectRoot' => dirname(rtrim(self::$rootDir, DS)), ), JSON_UNESCAPED_SLASHES); // Launch subprocess $descriptors = array( 0 => array('pipe', 'r'), // stdin 1 => array('pipe', 'w'), // stdout 2 => array('pipe', 'w'), // stderr ); $phpBin = defined('PHP_BINARY') ? PHP_BINARY : 'php'; $process = proc_open($phpBin . ' ' . escapeshellarg($workerFile), $descriptors, $pipes); if (!is_resource($process)) { // proc_open failed — last resort, run in-process $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $response['status'] ?? 200; return false; } // Send request data to child's stdin fwrite($pipes[0], $payload); fclose($pipes[0]); // Read response from child's stdout $stdout = ''; while (!feof($pipes[1])) { $chunk = fread($pipes[1], 65536); if ($chunk === false || $chunk === '') break; $stdout .= $chunk; } fclose($pipes[1]); fclose($pipes[2]); proc_close($process); // Parse response $response = json_decode($stdout, true); if (!$response) { $response = array( 'status' => 502, 'body' => 'Worker subprocess failed', 'headers' => array('Content-Type' => 'text/plain'), ); } Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $response['status'] ?? 200; Q_WebServer_Cache::put($parsed, $response); return false; } /** * Execute a PHP script via php-cgi binary for full header() compatibility. * Used for legacy/third-party code (WordPress, Laravel, etc.) that calls * header() and setcookie() directly. The php-cgi binary outputs real HTTP * headers followed by the body — we parse and forward them. * * Slower than fork mode (no preload benefit) but 100% compatible. * * @method handlePhpCgi * @static * @private */ private static function handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary) { $host = $parsed['headers']['host'] ?? 'localhost'; $hostParts = explode(':', $host); $isHttps = !empty(self::$tlsSocket); $fwdProto = strtolower($parsed['headers']['x-forwarded-proto'] ?? ''); if ($fwdProto === 'https') $isHttps = true; $cfVisitor = $parsed['headers']['cf-visitor'] ?? ''; if (strpos($cfVisitor, '"https"') !== false) $isHttps = true; // Compute SCRIPT_NAME and PATH_INFO (frameworks need correct PATH_INFO) $requestPath = parse_url($parsed['uri'], PHP_URL_PATH) ?: '/'; $docRoot = rtrim(self::$rootDir, DS); $scriptRel = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen($docRoot))), '/'); $pathInfo = ''; if (strlen($requestPath) > strlen($scriptRel)) { $pathInfo = substr($requestPath, strlen($scriptRel)); } // Build CGI environment variables — full set matching nginx fastcgi_params $env = array( 'REDIRECT_STATUS' => '200', 'GATEWAY_INTERFACE' => 'CGI/1.1', 'SERVER_SOFTWARE' => 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0'), 'SERVER_PROTOCOL' => 'HTTP/' . ($parsed['httpVersion'] ?? '1.1'), 'SERVER_NAME' => $hostParts[0], 'SERVER_PORT' => isset($hostParts[1]) ? $hostParts[1] : (string) self::$port, 'SERVER_ADDR' => self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host, 'REQUEST_METHOD' => $parsed['method'], 'REQUEST_URI' => $parsed['uri'], 'QUERY_STRING' => $parsed['query'], 'SCRIPT_FILENAME' => $scriptPath, 'SCRIPT_NAME' => $scriptRel, 'PHP_SELF' => $scriptRel . $pathInfo, 'PATH_INFO' => $pathInfo, 'PATH_TRANSLATED' => $pathInfo ? $docRoot . $pathInfo : '', 'DOCUMENT_ROOT' => $docRoot, 'DOCUMENT_URI' => $scriptRel, 'REMOTE_ADDR' => $parsed['_remoteAddr'] ?? '127.0.0.1', 'REMOTE_PORT' => (string) ($parsed['_remotePort'] ?? 0), 'REQUEST_SCHEME' => $isHttps ? 'https' : 'http', 'HTTPS' => $isHttps ? 'on' : '', 'REQUEST_TIME' => (string) time(), 'REQUEST_TIME_FLOAT' => (string) microtime(true), ); // Forward ALL request headers as HTTP_* env vars foreach ($parsed['headers'] as $k => $v) { $envKey = 'HTTP_' . strtoupper(str_replace('-', '_', $k)); $env[$envKey] = $v; } // Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec) if (isset($parsed['headers']['content-type'])) { $env['CONTENT_TYPE'] = $parsed['headers']['content-type']; } if (isset($parsed['headers']['content-length'])) { $env['CONTENT_LENGTH'] = $parsed['headers']['content-length']; } // Basic auth $auth = $parsed['headers']['authorization'] ?? ''; if (stripos($auth, 'Basic ') === 0) { $decoded = base64_decode(substr($auth, 6)); if ($decoded && strpos($decoded, ':') !== false) { list($user, $pass) = explode(':', $decoded, 2); $env['PHP_AUTH_USER'] = $user; $env['PHP_AUTH_PW'] = $pass; $env['AUTH_TYPE'] = 'Basic'; } } // Inherit essential system env vars foreach (array('PATH', 'HOME', 'TEMP', 'TMP', 'TMPDIR', 'SYSTEMROOT') as $sysVar) { if (isset($_ENV[$sysVar])) $env[$sysVar] = $_ENV[$sysVar]; elseif (($v = getenv($sysVar)) !== false) $env[$sysVar] = $v; } // Launch php-cgi $descriptors = array( 0 => array('pipe', 'r'), // stdin (request body) 1 => array('pipe', 'w'), // stdout (CGI response) 2 => array('pipe', 'w'), // stderr (errors) ); $cwd = dirname($scriptPath); // run in the script's directory $process = proc_open($cgiBinary, $descriptors, $pipes, $cwd, $env); if (!is_resource($process)) { self::sendResponse($client, 502, 'CGI process failed to start'); return false; } // Non-blocking reads for timeout support stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); // Send request body to stdin if (!empty($parsed['body'])) { @fwrite($pipes[0], $parsed['body']); } fclose($pipes[0]); // Read CGI response with timeout static $timeout = null; if ($timeout === null) $timeout = Q_Config::get('Q', 'webserver', 'cgi', 'timeout', 30); $deadline = microtime(true) + $timeout; $stdout = ''; $stderr = ''; while (true) { $read = array($pipes[1], $pipes[2]); $write = $except = null; $remaining = max(0.1, $deadline - microtime(true)); if ($remaining <= 0) break; // timeout $ready = @stream_select($read, $write, $except, (int) $remaining, (int) (($remaining - (int) $remaining) * 1000000)); if ($ready === false) break; if ($ready === 0) continue; foreach ($read as $pipe) { $chunk = @fread($pipe, 65536); if ($chunk === false || $chunk === '') { if ($pipe === $pipes[1] && feof($pipes[1])) break 2; continue; } if ($pipe === $pipes[1]) $stdout .= $chunk; else $stderr .= $chunk; } } fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($process); // Log stderr if non-empty if ($stderr !== '') { Q_WebServer_Log::error("CGI stderr ($scriptPath): " . trim($stderr)); } // Handle timeout if (microtime(true) >= $deadline && $stdout === '') { self::sendResponse($client, 504, 'CGI process timed out'); return false; } // Handle empty response if ($stdout === '') { $status = ($exitCode !== 0) ? 500 : 200; $response = array('status' => $status, 'body' => '', 'headers' => array()); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $status; return false; } // Parse CGI output: headers separated by blank line from body $headerEnd = strpos($stdout, "\r\n\r\n"); $sep = 4; if ($headerEnd === false) { $headerEnd = strpos($stdout, "\n\n"); $sep = 2; } if ($headerEnd === false) { $body = $stdout; $headers = array(); $status = 200; $extraHeaders = array(); } else { $headerBlock = substr($stdout, 0, $headerEnd); $body = substr($stdout, $headerEnd + $sep); $headers = array(); $extraHeaders = array(); // for multiple Set-Cookie headers $status = 200; foreach (explode("\n", $headerBlock) as $line) { $line = rtrim($line, "\r"); if ($line === '') continue; // Status line: "Status: 404 Not Found" if (stripos($line, 'Status:') === 0) { $status = (int) trim(substr($line, 7)); continue; } // Location header implies redirect status $colonPos = strpos($line, ':'); if ($colonPos === false) continue; $name = trim(substr($line, 0, $colonPos)); $value = trim(substr($line, $colonPos + 1)); // Multiple Set-Cookie headers must all be forwarded if (strtolower($name) === 'set-cookie') { $extraHeaders[] = array($name, $value); } else { $headers[$name] = $value; } if (strtolower($name) === 'location' && $status === 200) { $status = 302; // implicit redirect } } } // Build and send response // We bypass processResponse for Set-Cookie to handle multiples $headers['Content-Length'] = strlen($body); $headers['Connection'] = 'close'; static $reasons = array( 200=>'OK', 201=>'Created', 204=>'No Content', 301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified', 400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden', 404=>'Not Found', 405=>'Method Not Allowed', 413=>'Payload Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway', 503=>'Service Unavailable', 504=>'Gateway Timeout', ); $reason = $reasons[$status] ?? 'OK'; $out = "HTTP/1.1 $status $reason\r\n"; foreach ($headers as $k => $v) { $out .= "$k: $v\r\n"; } // Append all Set-Cookie headers (can't use associative array) foreach ($extraHeaders as $pair) { $out .= $pair[0] . ': ' . $pair[1] . "\r\n"; } @fwrite($client, $out . "\r\n" . $body); self::$lastStatus = $status; return false; } private static function serveStaticFile($client, $fsPath, $method, $reqHeaders, $keepAlive = false) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); if (!in_array($ext, self::$allowedExtensions)) { self::sendResponse($client, 403, 'Forbidden'); return; } $connKey = $keepAlive ? 'ka' : 'cl'; $now = microtime(true); // ── Try response cache ── if (isset(self::$fileCache[$fsPath])) { $cached = &self::$fileCache[$fsPath]; // Revalidate mtime periodically if (($now - $cached['checked']) >= self::$fileCacheCheckInterval) { clearstatcache(true, $fsPath); if (filemtime($fsPath) !== $cached['mtime']) { self::$fileCacheSize -= $cached['bodyLen'] * 2; unset(self::$fileCache[$fsPath]); } else { $cached['checked'] = $now; } } } if (isset(self::$fileCache[$fsPath])) { $cached = &self::$fileCache[$fsPath]; $etag = $cached['etag']; // 304 against cached etag if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) { self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive); return; } if (isset($reqHeaders['if-modified-since'])) { $since = strtotime($reqHeaders['if-modified-since']); if ($since !== false && $cached['mtime'] <= $since) { self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive); return; } } // Serve from cache — single fwrite self::$lastStatus = 200; if ($method === 'HEAD') { @fwrite($client, $cached['head'][$connKey]); } else { @fwrite($client, $cached['full'][$connKey]); } return; } // ── Cache miss — build from disk ── clearstatcache(true, $fsPath); $mtime = filemtime($fsPath); $size = filesize($fsPath); $etag = '"' . dechex($mtime) . '-' . dechex($size) . '"'; // 304 Not Modified if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) { self::sendNotModified($client, $etag, $mtime, $keepAlive); return; } if (isset($reqHeaders['if-modified-since'])) { $since = strtotime($reqHeaders['if-modified-since']); if ($since !== false && $mtime <= $since) { self::sendNotModified($client, $etag, $mtime, $keepAlive); return; } } $contentType = self::mimeType($ext); $baseHeaders = "Content-Type: $contentType\r\n" . "ETag: $etag\r\n" . "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n" . "Cache-Control: public, max-age=0, must-revalidate\r\n"; // Companion .headers file $hf = $fsPath . '.headers'; if (file_exists($hf)) { foreach (file($hf, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { if ($line[0] === '#' || strpos($line, ':') === false) continue; $baseHeaders .= trim($line) . "\r\n"; } } $connHeader = $keepAlive ? 'keep-alive' : 'close'; // Pre-compressed siblings — not cached (different per Accept-Encoding) $preComp = Q_WebServer_Headers::findPreCompressed($fsPath, $reqHeaders); if ($preComp) { $out = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Encoding: " . $preComp['encoding'] . "\r\n" . "Content-Length: " . $preComp['size'] . "\r\n" . "Vary: Accept-Encoding\r\n" . "Connection: $connHeader\r\n\r\n"; self::$lastStatus = 200; @fwrite($client, $method === 'HEAD' ? $out : $out . file_get_contents($preComp['path'])); return; } // On-the-fly gzip — not cached (different per Accept-Encoding) if ($size < 5242880) { $gzHeaders = array(); if (Q_WebServer_Headers::shouldCompress($contentType, $size, $reqHeaders)) { $body = file_get_contents($fsPath); $body = Q_WebServer_Headers::maybeCompress($body, $contentType, $reqHeaders, $gzHeaders); $out = "HTTP/1.1 200 OK\r\n" . $baseHeaders; foreach ($gzHeaders as $k => $v) $out .= "$k: $v\r\n"; $out .= "Content-Length: " . strlen($body) . "\r\n" . "Connection: $connHeader\r\n\r\n"; self::$lastStatus = 200; @fwrite($client, $method === 'HEAD' ? $out : $out . $body); return; } } // ── Uncompressed — serve and cache ── $body = file_get_contents($fsPath); $kaHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Length: $size\r\nConnection: keep-alive\r\n\r\n"; $clHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Length: $size\r\nConnection: close\r\n\r\n"; self::$lastStatus = 200; $headStr = $keepAlive ? $kaHead : $clHead; @fwrite($client, $method === 'HEAD' ? $headStr : $headStr . $body); // Cache if small enough if ($size <= self::$fileCacheMaxFile && self::$fileCacheSize + $size * 2 < self::$fileCacheMaxSize ) { self::$fileCache[$fsPath] = array( 'mtime' => $mtime, 'bodyLen' => $size, 'etag' => $etag, 'checked' => $now, 'head' => array('ka' => $kaHead, 'cl' => $clHead), 'full' => array('ka' => $kaHead . $body, 'cl' => $clHead . $body), ); self::$fileCacheSize += $size * 2; // Evict oldest if over limit while (self::$fileCacheSize > self::$fileCacheMaxSize && self::$fileCache) { $evict = array_key_first(self::$fileCache); self::$fileCacheSize -= self::$fileCache[$evict]['bodyLen'] * 2; unset(self::$fileCache[$evict]); } } } // ── Privacy / access control ───────────────────────── /** * Check if a URL path is blocked entirely (403 Forbidden). * * These paths cannot be accessed by any URL. They contain * server-side code, config, and internal data. * * Blocked: /config/, /classes/, /handlers/, /scripts/ * Also: dotfiles/dotdirs (except /.well-known/) * Also: paths in Q.web.blocked.paths config * * For true access control on files, use X-Accel-Redirect * (PHP checks permissions, server does file I/O). * * @method isBlocked * @static * @param {string} $urlPath * @return {boolean} */ static function isBlocked($urlPath) { // Core blocked directories (server internals) $blocked = array('/config/', '/classes/', '/handlers/', '/scripts/'); foreach ($blocked as $prefix) { if (strpos($urlPath, $prefix) === 0) return true; } // Dotfiles/dotdirs (except /.well-known/) if (preg_match('#/\.(?!well-known)#', $urlPath)) return true; // Config-based blocked paths static $blockedPaths = null; if ($blockedPaths === null) $blockedPaths = Q_Config::get('Q', 'web', 'blocked', 'paths', array()); foreach ($blockedPaths as $pp => $v) { if ($v && strpos($urlPath, '/' . ltrim($pp, '/')) === 0) return true; } return false; } /** * Check if a URL path allows directory listing. * * Directory listings are OFF by default (more secure). * Only paths matching regexes in * Q.web.indexed.paths get listings. Default: /img/. * * Config: * "Q": { "web": { "indexed": { "paths": { * "#^/img/#": true, * "#^/downloads/#": true * }}}} * * For actual access control, use X-Accel-Redirect. * * @method isIndexed * @static * @param {string} $urlPath * @return {boolean} */ static function isIndexed($urlPath) { static $patterns = null; if ($patterns === null) $patterns = Q_Config::get('Q', 'web', 'indexed', 'paths', array( '#^/img/#' => true )); foreach ($patterns as $regex => $enabled) { if ($enabled && preg_match($regex, $urlPath)) return true; } return false; // not indexed by default } // ── Directory listing ──────────────────────────────── /** * Render a responsive directory listing with media previews. * Only called for paths that pass isIndexed(). * Dotfiles are always hidden from listings. * * @method renderDirectoryListing * @static * @param {string} $dir Filesystem path * @param {string} $urlPath URL path * @return {string} HTML */ static function renderDirectoryListing($dir, $urlPath) { $maxImages = (int) Q_Config::get('Q', 'webserver', 'listing', 'images', 'max', 100); $items = scandir($dir); $dirs = array(); $files = array(); $media = array(); $imageExts = array('png','jpg','jpeg','gif','webp','svg'); $videoExts = array('mp4','webm','ogg'); $audioExts = array('mp3','wav','ogg'); foreach ($items as $name) { if ($name === '.' || $name === '..') continue; if ($name[0] === '.') continue; // dotfiles always hidden $full = $dir . DS . $name; $href = htmlspecialchars($urlPath . $name, ENT_QUOTES); $safe = htmlspecialchars($name, ENT_QUOTES); if (is_dir($full)) { $dirs[] = "ðŸ“{$safe}/"; 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[] = "📄{$safe}{$sizeStr}"; // Collect media for preview grid if (count($media) < $maxImages) { if (in_array($ext, $imageExts)) { $media[] = "
\"{$safe}\"
{$safe}
"; } elseif (in_array($ext, $videoExts)) { $media[] = "
{$safe}
"; } elseif (in_array($ext, $audioExts)) { $media[] = "
{$safe}
"; } } } $safePath = htmlspecialchars($urlPath, ENT_QUOTES); $upLink = ($urlPath !== '/') ? '⬆Parent Directory' : ''; $mediaSection = ''; if ($media) { $mediaSection = '
Media Preview
' . implode("\n", $media) . '
'; } return << Index of {$safePath}

Index of {$safePath}

{$upLink} HTML . implode("\n", $dirs) . "\n" . implode("\n", $files) . "\n
\n" . $mediaSection . "\n"; } // ── MIME types ──────────────────────────────────────── static function mimeType($ext) { static $types = array( 'html'=>'text/html; charset=utf-8', 'htm'=>'text/html; charset=utf-8', 'css'=>'text/css; charset=utf-8', 'js'=>'application/javascript; charset=utf-8', 'mjs'=>'application/javascript; charset=utf-8', 'json'=>'application/json; charset=utf-8', 'xml'=>'application/xml', 'txt'=>'text/plain; charset=utf-8', 'md'=>'text/plain; charset=utf-8', 'csv'=>'text/csv; charset=utf-8', 'yaml'=>'text/yaml', 'yml'=>'text/yaml', 'log'=>'text/plain; charset=utf-8', 'map'=>'application/json', 'png'=>'image/png', 'jpg'=>'image/jpeg', 'jpeg'=>'image/jpeg', 'gif'=>'image/gif', 'webp'=>'image/webp', 'svg'=>'image/svg+xml', 'bmp'=>'image/bmp', 'ico'=>'image/x-icon', 'avif'=>'image/avif', 'woff'=>'font/woff', 'woff2'=>'font/woff2', 'ttf'=>'font/ttf', 'otf'=>'font/otf', 'mp3'=>'audio/mpeg', 'wav'=>'audio/wav', 'ogg'=>'audio/ogg', 'mp4'=>'video/mp4', 'webm'=>'video/webm', 'pdf'=>'application/pdf', 'zip'=>'application/zip', 'wasm'=>'application/wasm', ); return $types[$ext] ?? 'application/octet-stream'; } // ── Q_Dispatcher bridge ────────────────────────────── static function dispatchToQ($parsed) { $saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE); $scriptPath = $parsed['_scriptPath'] ?? self::$rootDir . 'index.php'; $host = $parsed['headers']['host'] ?? 'localhost'; $hostParts = explode(':', $host); // ── Standard CGI variables ────────────────────── $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SCRIPT_NAME'] = '/' . basename($scriptPath); $_SERVER['SCRIPT_FILENAME'] = $scriptPath; $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; // WordPress uses this $_SERVER['PATH_TRANSLATED'] = $scriptPath; $_SERVER['PATH_INFO'] = ''; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); $_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME']; $_SERVER['SERVER_NAME'] = $hostParts[0]; $_SERVER['SERVER_PORT'] = isset($hostParts[1]) ? $hostParts[1] : self::$port; $_SERVER['SERVER_ADDR'] = self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/' . ($parsed['httpVersion'] ?? '1.1'); $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0'); $_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1'; $_SERVER['REDIRECT_STATUS'] = 200; $_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1'; $_SERVER['REMOTE_PORT'] = $parsed['_remotePort'] ?? 0; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT']= microtime(true); // ── HTTPS detection (direct TLS or proxy header) ── $isHttps = !empty(self::$tlsSocket); $fwdProto = $parsed['headers']['x-forwarded-proto'] ?? ''; if (strtolower($fwdProto) === 'https') $isHttps = true; // CloudFront $cfProto = $parsed['headers']['cloudfront-forwarded-proto'] ?? ''; if (strtolower($cfProto) === 'https') $isHttps = true; // Cloudflare $cfVisitor = $parsed['headers']['cf-visitor'] ?? ''; if (strpos($cfVisitor, '"https"') !== false) $isHttps = true; $_SERVER['REQUEST_SCHEME'] = $isHttps ? 'https' : 'http'; $_SERVER['HTTPS'] = $isHttps ? 'on' : ''; // ── Request headers → HTTP_* ──────────────────── // All request headers become HTTP_HEADERNAME (uppercase, hyphens→underscores) foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } // Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec) if (isset($parsed['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $parsed['headers']['content-type']; if (isset($parsed['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $parsed['headers']['content-length']; // ── Basic auth parsing ────────────────────────── $auth = $parsed['headers']['authorization'] ?? ''; if (stripos($auth, 'Basic ') === 0) { $decoded = base64_decode(substr($auth, 6)); if ($decoded && strpos($decoded, ':') !== false) { list($user, $pass) = explode(':', $decoded, 2); $_SERVER['PHP_AUTH_USER'] = $user; $_SERVER['PHP_AUTH_PW'] = $pass; $_SERVER['AUTH_TYPE'] = 'Basic'; } } elseif (stripos($auth, 'Bearer ') === 0) { $_SERVER['HTTP_AUTHORIZATION'] = $auth; // already set by loop $_SERVER['AUTH_TYPE'] = 'Bearer'; } // ── $_COOKIE ──────────────────────────────────── $_COOKIE = array(); $cookieHeader = $parsed['headers']['cookie'] ?? ''; if ($cookieHeader) { $pairs = explode(';', $cookieHeader); foreach ($pairs as $pair) { $pair = trim($pair); if ($pair === '') continue; $eqPos = strpos($pair, '='); if ($eqPos === false) continue; $name = urldecode(trim(substr($pair, 0, $eqPos))); $value = urldecode(trim(substr($pair, $eqPos + 1))); $_COOKIE[$name] = $value; } } // ── $_GET, $_POST, $_FILES, $_REQUEST ─────────── $_GET = $_POST = $_REQUEST = $_FILES = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($_SERVER['CONTENT_TYPE'] ?? ''); $rawBody = $parsed['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($rawBody, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($rawBody, true) ?: array(); } elseif (strpos($ct, 'multipart/form-data') !== false) { $origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''; self::parseMultipart($origCt, $rawBody, $_POST, $_FILES); } $_REQUEST = array_merge($_COOKIE, $_GET, $_POST); // PHP default order // Make raw body available Q_Request::$input = $rawBody; // Clear any stale headers and output from previous in-process requests, // then start fresh output buffering. This prevents "headers already sent" // errors when scripts call header() after prior output leaked through. while (ob_get_level()) ob_end_clean(); header_remove(); http_response_code(200); Q::clearResponseHeaders(); if (class_exists('Q_Response', false)) Q_Response::clear(); ob_start(); $status = 200; $headers = array(); try { if (class_exists('Q_Dispatcher', false)) { // Full Qbix Platform mode Q_Dispatcher::dispatch(); } else { // Standalone mode — execute PHP script directly $scriptPath = $parsed['_scriptPath'] ?? $_SERVER['SCRIPT_FILENAME']; if (is_file($scriptPath)) { include $scriptPath; } else { $status = 404; echo 'Not Found'; } } $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; // Process Merkle cache headers (strips X-Q-Cache-* from response) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); Q_WebServer_Cache_Components::processResponseHeaders($pageKey, $headers); } return compact('status', 'body', 'headers'); } // ── Request parsing ────────────────────────────────── static function parseRequest($raw) { $headerEnd = strpos($raw, "\r\n\r\n"); $headerBlock = substr($raw, 0, $headerEnd); $body = substr($raw, $headerEnd + 4); // Fast request line parse $rlEnd = strpos($headerBlock, "\r\n"); $requestLine = $rlEnd !== false ? substr($headerBlock, 0, $rlEnd) : $headerBlock; if (!preg_match('#^(\w+)\s+([^\s]+)\s+HTTP/(\d\.\d)#', $requestLine, $m)) { return array( 'method' => 'GET', 'uri' => '/', 'path' => '/', 'query' => '', 'headers' => array(), 'body' => '', 'httpVersion' => '1.0', '_malformed' => true ); } $method = strtoupper($m[1]); $uri = $m[2]; $httpVersion = $m[3]; // Fast path parsing — avoid parse_url for simple paths $qPos = strpos($uri, '?'); if ($qPos !== false) { $path = urldecode(substr($uri, 0, $qPos)); $query = substr($uri, $qPos + 1); } else { $path = urldecode($uri); $query = ''; } // Collapse double slashes if (strpos($path, '//') !== false) { $path = preg_replace('#/+#', '/', $path); } // Fast header parsing — scan for common headers first $headers = array(); $pos = $rlEnd !== false ? $rlEnd + 2 : strlen($headerBlock); $len = strlen($headerBlock); while ($pos < $len) { $nlPos = strpos($headerBlock, "\r\n", $pos); if ($nlPos === false) $nlPos = $len; $colonPos = strpos($headerBlock, ':', $pos); if ($colonPos !== false && $colonPos < $nlPos) { $k = strtolower(substr($headerBlock, $pos, $colonPos - $pos)); $v = ltrim(substr($headerBlock, $colonPos + 1, $nlPos - $colonPos - 1)); $headers[$k] = $v; } $pos = $nlPos + 2; } // HTTP/1.0 defaults to Connection: close if ($httpVersion === '1.0' && !isset($headers['connection'])) { $headers['connection'] = 'close'; } return compact('method', 'uri', 'path', 'query', 'headers', 'body', 'httpVersion'); } /** * Parse multipart/form-data body into $_POST and $_FILES arrays. * Handles file uploads by writing to temp files (same as php-fpm). * @method parseMultipart * @static * @param {string} $contentType Full Content-Type header value * @param {string} $body Raw request body * @param {array} &$post Populated with form field values * @param {array} &$files Populated with file upload entries */ static function parseMultipart($contentType, $body, &$post, &$files) { // Extract boundary from Content-Type if (!preg_match('/boundary=(?:"([^"]+)"|([^\s;]+))/i', $contentType, $bm)) { return; } $boundary = '--' . ($bm[1] ?: $bm[2]); $endBoundary = $boundary . '--'; $parts = explode($boundary, $body); array_shift($parts); // before first boundary foreach ($parts as $part) { $part = ltrim($part, "\r\n"); if ($part === '--' || $part === "--\r\n" || $part === '') continue; if (strpos($part, '--') === 0) continue; // end boundary // Split headers from body $headerEnd = strpos($part, "\r\n\r\n"); if ($headerEnd === false) continue; $headerBlock = substr($part, 0, $headerEnd); $partBody = substr($part, $headerEnd + 4); // Remove trailing \r\n if (substr($partBody, -2) === "\r\n") { $partBody = substr($partBody, 0, -2); } // Parse part headers $partHeaders = array(); foreach (explode("\r\n", $headerBlock) as $line) { $colonPos = strpos($line, ':'); if ($colonPos !== false) { $k = strtolower(trim(substr($line, 0, $colonPos))); $v = trim(substr($line, $colonPos + 1)); $partHeaders[$k] = $v; } } $disp = $partHeaders['content-disposition'] ?? ''; if (strpos($disp, 'form-data') === false) continue; // Extract name $name = null; if (preg_match('/\bname="([^"]*)"/', $disp, $nm)) { $name = $nm[1]; } elseif (preg_match("/\bname='([^']*)'/", $disp, $nm)) { $name = $nm[1]; } if ($name === null) continue; // Check if it's a file upload $filename = null; if (preg_match('/\bfilename="([^"]*)"/', $disp, $fm)) { $filename = $fm[1]; } elseif (preg_match("/\bfilename='([^']*)'/", $disp, $fm)) { $filename = $fm[1]; } if ($filename !== null) { // File upload — write to temp file $tmpPath = tempnam(sys_get_temp_dir(), 'qbix_upload_'); file_put_contents($tmpPath, $partBody); $fileEntry = array( 'name' => $filename, 'type' => $partHeaders['content-type'] ?? 'application/octet-stream', 'tmp_name' => $tmpPath, 'error' => UPLOAD_ERR_OK, 'size' => strlen($partBody), ); // Handle array notation: files[0], files[photo], etc. if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) { $files[$am[1]]['name'][$am[2]] = $fileEntry['name']; $files[$am[1]]['type'][$am[2]] = $fileEntry['type']; $files[$am[1]]['tmp_name'][$am[2]] = $fileEntry['tmp_name']; $files[$am[1]]['error'][$am[2]] = $fileEntry['error']; $files[$am[1]]['size'][$am[2]] = $fileEntry['size']; } else { $files[$name] = $fileEntry; } } else { // Regular form field // Handle array notation: tags[], data[key], etc. if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) { if ($am[2] === '') { $post[$am[1]][] = $partBody; } else { $post[$am[1]][$am[2]] = $partBody; } } else { $post[$name] = $partBody; } } } } // ── Response helpers ───────────────────────────────── static function sendResponse($client, $status, $body, $type = 'text/plain; charset=utf-8', $extra = array()) { static $reasons = array( 200=>'OK', 301=>'Moved Permanently', 304=>'Not Modified', 400=>'Bad Request', 403=>'Forbidden', 404=>'Not Found', 413=>'Payload Too Large', 429=>'Too Many Requests', 431=>'Request Header Fields Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway' ); self::$lastStatus = $status; self::$lastBody = $body; $body = (string) $body; $conn = $extra['Connection'] ?? 'keep-alive'; unset($extra['Connection']); $out = "HTTP/1.1 $status " . ($reasons[$status] ?? 'OK') . "\r\nContent-Type: $type\r\nContent-Length: " . strlen($body) . "\r\nConnection: $conn\r\n"; foreach ($extra as $k => $v) $out .= "$k: $v\r\n"; @fwrite($client, $out . "\r\n" . $body); } private static function sendRedirect($client, $loc) { @fwrite($client, "HTTP/1.1 301 Moved Permanently\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); self::$lastStatus = 301; } private static function sendNotModified($client, $etag, $mtime, $keepAlive = false) { $conn = $keepAlive ? 'keep-alive' : 'close'; @fwrite($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n" . "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n" . "Cache-Control: public, max-age=0, must-revalidate\r\nContent-Length: 0\r\nConnection: $conn\r\n\r\n"); self::$lastStatus = 304; } private static function render404($path) { $safe = htmlspecialchars($path, ENT_QUOTES); return "404" . "" . "

404

{$safe} not found

"; } // ── Path resolution ────────────────────────────────── /** * Parse a Cookie header string into an associative array * @method parseCookieHeader * @static * @param {string} $header The raw Cookie header value * @return {array} name => value pairs */ static function parseCookieHeader($header) { $cookies = array(); if (empty($header)) return $cookies; $pairs = explode(';', $header); foreach ($pairs as $pair) { $pair = trim($pair); if ($pair === '') continue; $eq = strpos($pair, '='); if ($eq === false) { $cookies[$pair] = ''; } else { $name = trim(substr($pair, 0, $eq)); $value = trim(substr($pair, $eq + 1)); $cookies[$name] = urldecode($value); } } return $cookies; } /** * Check rate limit for a client IP. Returns true if allowed, false if over limit. * Configured via Q.webserver.rateLimit: * { "enabled": true, "requests": 100, "window": 60, "burstRequests": 20, "burstWindow": 1 } * @method checkRateLimit * @static * @param {string} $ip Client IP address * @return {boolean} true if request is allowed */ static function checkRateLimit($ip) { static $rateLimitEnabled = null; if ($rateLimitEnabled === null) $rateLimitEnabled = Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false); if (!$rateLimitEnabled) { return true; } $now = time(); static $maxReqs = null; if ($maxReqs === null) $maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100); static $window = null; if ($window === null) $window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60); static $burstReqs = null; if ($burstReqs === null) $burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20); static $burstWindow = null; if ($burstWindow === null) $burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1); // Clean old entries if (!isset(self::$rateLimitData[$ip])) { self::$rateLimitData[$ip] = array(); } $hits = &self::$rateLimitData[$ip]; $cutoff = $now - $window; $hits = array_filter($hits, function ($t) use ($cutoff) { return $t >= $cutoff; }); // Check window limit if (count($hits) >= $maxReqs) { return false; } // Check burst limit $burstCutoff = $now - $burstWindow; $recent = array_filter($hits, function ($t) use ($burstCutoff) { return $t >= $burstCutoff; }); if (count($recent) >= $burstReqs) { return false; } $hits[] = $now; // Periodic cleanup: remove IPs not seen in the last window if (mt_rand(0, 99) < 5) { // 5% chance per request foreach (self::$rateLimitData as $k => $v) { if (empty($v) || max($v) < $cutoff) { unset(self::$rateLimitData[$k]); } } } return true; } private static function resolveStatic($urlPath) { // Path resolution cache — avoids repeated realpath() syscalls // Key includes rootDir so vhosts don't cross-contaminate static $pathCache = array(); $cacheKey = self::$rootDir . $urlPath; if (isset($pathCache[$cacheKey])) { $cached = $pathCache[$cacheKey]; // Quick mtime check for invalidation (cheaper than realpath) if ($cached === null || file_exists($cached)) { return $cached; } unset($pathCache[$cacheKey]); } $rel = str_replace('/', DS, ltrim($urlPath, '/')); // Block null bytes (directory traversal via null byte injection) if (strpos($rel, "\0") !== false) return null; $fsPath = realpath(self::$rootDir . $rel); if (!$fsPath) { // Cache negative results too (404s won't re-stat) if (count($pathCache) < 10000) $pathCache[$cacheKey] = null; return null; } $fsPath = str_replace(array('/','\\'), DS, $fsPath); $root = rtrim(self::$rootDir, DS); if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) { $pathCache[$cacheKey] = null; return null; // path traversal } $result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null; if (count($pathCache) < 10000) $pathCache[$cacheKey] = $result; return $result; } private static function closeClient($key) { if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); unset(self::$clientWatchers[$key]); } if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } if (isset(self::$clients[$key])) { @fclose(self::$clients[$key]); unset(self::$clients[$key]); } unset(self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); } // ── State ──────────────────────────────────────────── private static $socket = null; private static $tlsSocket = null; private static $tlsWatcher = null; private static $tlsPending = array(); private static $httpsPort = 0; static $clients = array(); static $clientWatchers = array(); private static $buffers = array(); private static $clientInfo = array(); // key => [ip, connectTime] private static $keepAliveCount = array(); // key => int private static $timeoutWatchers = array(); // key => evented timer id private static $acceptWatcher = null; private static $running = false; private static $lastStatus = 200; private static $lastBody = ''; /** @internal pid => start_time for request timeout enforcement */ static $workerPids = array(); 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; } 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', "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; } } [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); } } running = true; EventLoop::run(); $this->running = false; } function tick($timeout = 0) { EventLoop::delay($timeout ?: 0.0, function () {}); EventLoop::run(); } function stop() { $this->running = false; } function running() { return $this->running; } } $key)) return $def; $cur = $cur->$key; } else { return $def; } } return $cur; } /** * Set a value deep inside a nested array, creating intermediate arrays as needed. * * Q::setObject(['users', 'alice', 'email'], 'alice@example.com', $data) * * @method setObject * @static * @param {array} $path * @param {mixed} $value * @param {&array} $dest The target array (modified by reference) */ static function setObject($path, $value, &$dest) { if (is_string($path)) $path = array($path); $ref = &$dest; foreach ($path as $key) { if (!isset($ref[$key]) || !is_array($ref[$key])) { $ref[$key] = array(); } $ref = &$ref[$key]; } $ref = $value; } /** * JSON encode with unescaped slashes * @method json_encode * @static */ static function json_encode($value, $options = 0) { return json_encode($value, $options | JSON_UNESCAPED_SLASHES); } /** * JSON decode wrapper * @method json_decode * @static */ static function json_decode($json, $assoc = false, $depth = 512, $options = 0) { return json_decode($json, $assoc, $depth, $options); } /** * Captured response headers. PHP's headers_list() returns empty in CLI SAPI, * so we capture headers ourselves when scripts call header(). * @property $_responseHeaders * @static */ static $_responseHeaders = array(); static $_responseCode = 200; /** * Get the app name. Used to prefix handler function names. * Set via config: {"Q": {"app": "MyApp"}} * @method app * @static * @return {string} App name, or empty string if not set */ static function app() { return Q_Config::get('Q', 'app', ''); } /** * Set a response header. Wraps PHP's header() and captures it. * Scripts can call either header() directly or Q::header() — both work. * But Q::header() ensures capture in CLI SAPI mode. * @method header * @static * @param {string} $header Full header string e.g. "Content-Type: text/html" * @param {boolean} $replace Replace existing header of same name * @param {integer} $code HTTP status code */ static function header($header, $replace = true, $code = 0) { // Delegate to Q_Response for proper tracking $colonPos = strpos($header, ':'); if ($colonPos !== false) { $name = trim(substr($header, 0, $colonPos)); $value = trim(substr($header, $colonPos + 1)); if (class_exists('Q_Response', false)) { Q_Response::setHeader($name, $value, $replace); } else { // Fallback: direct capture if ($replace) { self::$_responseHeaders[$name] = $value; } elseif (!isset(self::$_responseHeaders[$name])) { self::$_responseHeaders[$name] = $value; } } } if ($code > 0) { self::$_responseCode = $code; if (class_exists('Q_Response', false)) { Q_Response::code($code); } } // Also call native header() (works in non-CLI SAPIs) @header($header, $replace, $code); } /** * Get all captured response headers. * Falls back to headers_list() if available (non-CLI SAPI). * @method getResponseHeaders * @static * @return {array} */ static function getResponseHeaders() { // Try PHP native first (works in non-CLI SAPIs) $native = headers_list(); if (!empty($native)) { $result = array(); foreach ($native as $h) { $p = strpos($h, ':'); if ($p !== false) { $result[trim(substr($h, 0, $p))] = trim(substr($h, $p + 1)); } } return $result; } // CLI SAPI: merge Q_Response headers over Q:: captured headers $headers = self::$_responseHeaders; if (class_exists('Q_Response', false)) { $headers = array_merge($headers, Q_Response::getHeaders()); } return $headers; } /** * Clear captured headers (called between requests). * @method clearResponseHeaders * @static */ static function clearResponseHeaders() { self::$_responseHeaders = array(); self::$_responseCode = 200; } // ── Event system ──────────────────────────────────── /** * Fire an event. Looks for handler functions in handlers/ directory. * * Handler for "MyApp/feed/post" lives at: * handlers/MyApp/feed/post.php * And defines: * function MyApp_feed_post($params) { ... } * * @method event * @static * @param {string} $eventName e.g. "MyApp/feed/post" * @param {array} $params Parameters passed to the handler * @param {string|boolean} $pure false=run handler, 'before'=before hooks only, * 'after'=after hooks only, true=both hooks but skip main handler * @param {boolean} $skipIncludes If true, only call already-defined functions * @param {mixed} &$result Reference for handlers to modify * @return {mixed} Whatever the handler returned */ static function event( $eventName, $params = array(), $pure = false, $skipIncludes = false, &$result = null) { if (!is_string($eventName) || !$eventName) return null; if (!is_array($params)) $params = array(); // Before hooks if ($pure !== 'after') { $handlers = Q_Config::get('Q', 'handlersBeforeEvent', $eventName, array()); if (is_string($handlers)) $handlers = array($handlers); if (is_array($handlers)) { foreach ($handlers as $handler) { $r = self::handle($handler, $params, $skipIncludes, $result); if ($r === false) return $result; } } } // Main handler if (!$pure) { $result = self::handle($eventName, $params, $skipIncludes, $result); } // After hooks if ($pure !== 'before') { $handlers = Q_Config::get('Q', 'handlersAfterEvent', $eventName, array()); if (is_string($handlers)) $handlers = array($handlers); if (is_array($handlers)) { foreach ($handlers as $handler) { $r = self::handle($handler, $params, $skipIncludes, $result); if ($r === false) return $result; } } } return $result; } /** * Check if a handler exists for an event name * @method canHandle * @static * @param {string} $eventName * @return {boolean} */ static function canHandle($eventName) { $parts = explode('/', $eventName); $baseName = str_replace('-', '_', implode('_', $parts)); $app = Q::app(); $funcName = ($app !== '' ? $app . '_' : '') . $baseName; if (function_exists($funcName)) return true; // Try to load from handlers/ directory $relPath = 'handlers' . DS . implode(DS, $parts) . '.php'; foreach (self::$paths as $base) { $full = $base . DS . $relPath; if (file_exists($full)) { include_once $full; return function_exists($funcName); } } return false; } /** * Execute a handler function. Loads from handlers/ directory if needed. * If $eventName starts with http:// or https://, POSTs params as JSON * to that URL (remote handler / webhook). * @method handle * @static * @param {string} $eventName * @param {array} &$params * @param {boolean} $skipIncludes * @param {mixed} &$result * @return {mixed} */ protected static function handle( $eventName, &$params = array(), $skipIncludes = false, &$result = null) { if (!$eventName) return null; // Remote handler — POST params as JSON to URL if (strncmp($eventName, 'http://', 7) === 0 || strncmp($eventName, 'https://', 8) === 0 ) { return self::handleRemote($eventName, $params, $result); } $parts = explode('/', $eventName); $baseName = str_replace('-', '_', implode('_', $parts)); $app = Q::app(); $funcName = ($app !== '' ? $app . '_' : '') . $baseName; if (!function_exists($funcName)) { if ($skipIncludes) return null; // Try to load from handlers/ directory $relPath = 'handlers' . DS . implode(DS, $parts) . '.php'; $loaded = false; foreach (self::$paths as $base) { $full = $base . DS . $relPath; if (file_exists($full)) { include_once $full; $loaded = true; break; } } if (!$loaded || !function_exists($funcName)) { return null; // no handler found — that's OK } } $args = array(&$params, &$result); return call_user_func_array($funcName, $args); } /** * POST event params as JSON to a remote URL. * Used for webhook-style handlers configured in Q.handlersAfterEvent. * Non-blocking: uses a short timeout so it doesn't slow down the request. * @method handleRemote * @static * @param {string} $url * @param {array} &$params * @param {mixed} &$result * @return {mixed} */ protected static function handleRemote($url, &$params, &$result) { $json = json_encode($params, JSON_UNESCAPED_SLASHES); $opts = array('http' => array( 'method' => 'POST', 'header' => "Content-Type: application/json\r\n" . "Content-Length: " . strlen($json) . "\r\n" . "User-Agent: QbixServer/1.0\r\n", 'content' => $json, 'timeout' => 5, 'ignore_errors' => true, )); $ctx = stream_context_create($opts); $response = @file_get_contents($url, false, $ctx); if ($response !== false) { $decoded = json_decode($response, true); if ($decoded !== null) { $result = $decoded; } } return $result; } /** * Render a PHP view file. Searches views/ directories in $paths. * * echo Q::view('MyApp/feed/page.php', ['items' => $items]); * * @method view * @static * @param {string} $viewName Path relative to views/ directory * @param {array} $params Variables extracted into the view scope * @return {string} Rendered HTML */ static function view($viewName, $params = array()) { $viewPath = str_replace('/', DS, $viewName); foreach (self::$paths as $base) { $full = $base . DS . 'views' . DS . $viewPath; if (file_exists($full)) { extract($params); ob_start(); include $full; return ob_get_clean(); } } return ""; } // ── Autoloader ────────────────────────────────────── /** * Autoloader that handles both conventions: * Q_WebServer → classes/Q/WebServer.php (underscore) * MyApp\User → classes/MyApp/User.php (namespace) * MyApp_Helper → classes/MyApp/Helper.php (underscore) * * Searches the src/ directory (for Q_ server classes) and all * directories in Q::$paths (for user classes). * * @method autoload * @static * @param {string} $className */ static function autoload($className) { // Split on both \ and _ to get path parts $parts = array(); foreach (explode('\\', $className) as $nsPart) { $parts = array_merge($parts, explode('_', $nsPart)); } $relPath = implode(DS, $parts) . '.php'; // 1. Search src/ directory (for Q_* server classes) $srcPath = dirname(__FILE__) . DS . $relPath; if (file_exists($srcPath)) { require_once $srcPath; return; } // 2. Search project classes/ directories foreach (self::$paths as $base) { $full = $base . DS . 'classes' . DS . $relPath; if (file_exists($full)) { require_once $full; // If loaded via underscore but also accessible via namespace, alias $underscoreName = implode('_', $parts); $namespaceName = implode('\\', $parts); if ($underscoreName !== $namespaceName) { if (class_exists($underscoreName, false) && !class_exists($namespaceName, false) ) { class_alias($underscoreName, $namespaceName); } elseif (class_exists($namespaceName, false) && !class_exists($underscoreName, false) ) { class_alias($namespaceName, $underscoreName); } } return; } } } /** * Initialize Q paths from the project root directory. * Called by the server at startup. * @method init * @static * @param {string} $projectRoot The project root (parent of web/) */ static function init($projectRoot) { $projectRoot = rtrim($projectRoot, DS); if (!in_array($projectRoot, self::$paths)) { self::$paths[] = $projectRoot; } } /** * Preload all handler files if Q.handlers.preload is true. * Call this after config is loaded and before the server starts accepting * connections. Handlers are included once in the parent process and shared * via COW across all forked children. * * Off by default — handlers lazy-load via include_once on first call, * which is fine with opcache (edit a file, refresh, see the change). * Enable in production for full COW sharing of handler bytecode. * * @method preload * @static */ static function preload() { if (!Q_Config::get('Q', 'handlers', 'preload', false)) { return; } foreach (self::$paths as $base) { $handlersDir = $base . DS . 'handlers'; if (is_dir($handlersDir)) { self::preloadDir($handlersDir); } } } /** * Recursively include all .php files in a directory. * @method preloadDir * @static * @param {string} $dir Directory to scan */ static function preloadDir($dir) { $entries = @scandir($dir); if (!$entries) return; foreach ($entries as $entry) { if ($entry[0] === '.') continue; $path = $dir . DS . $entry; if (is_dir($path)) { self::preloadDir($path); } elseif (substr($entry, -4) === '.php') { include_once $path; self::$preloadedHandlers++; } } } /** @var integer Number of preloaded handler files */ static $preloadedHandlers = 0; } spl_autoload_register(array('Q', 'autoload')); // ── Q_Socket ──────────────────────────────────────── /** * WebSocket connection context. Passed to per-connection handlers as * $params['socket']. Use instance methods to communicate with clients. * * function my_handler(&$params, &$result) { * extract($params); // $socket, $event, $data * $socket->reply(['hello' => 'world']); * $socket->join('chat/general', ['name' => 'Alice']); * $location = $socket->getLocation(); // RPC call to client * } * * @class Q_Socket */ class Q_Socket { /** @var integer This socket's ID */ public $id; function __construct($id) { $this->id = $id; } /** Get a socket instance by ID */ static function byId($id) { return new self($id); } /** Send data to this socket's client */ function reply($data) { self::_cmd(array('cmd' => 'send', 'socketId' => $this->id, 'data' => $data)); } /** Send data to a specific client by socket ID */ function send($socketId, $data) { self::_cmd(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data)); } /** Broadcast to all clients in a room */ function broadcast($room, $data) { self::_cmd(array('cmd' => 'broadcast', 'room' => $room, 'data' => $data)); } /** Broadcast to ALL connected clients */ function broadcastAll($data) { self::_cmd(array('cmd' => 'broadcastAll', 'data' => $data)); } /** Join a room, optionally forwarding data to the room's join handler */ function join($room, $data = array()) { self::_cmd(array('cmd' => 'join', 'socketId' => $this->id, 'room' => $room, 'data' => $data)); } /** Leave a room, optionally forwarding data to the room's leave handler */ function leave($room, $data = array()) { self::_cmd(array('cmd' => 'leave', 'socketId' => $this->id, 'room' => $room, 'data' => $data)); } /** * Call a method on the remote client. Blocks until the client responds. * The client must have registered a handler via qs.handle('methodName', fn). * * @method __call * @param {string} $method Method name to invoke on the client * @param {array} $args Arguments — first element is passed as data to client * @return {mixed} Return value from the client handler, or null on timeout */ function __call($method, $args) { $rpcId = ++self::$_rpcCounter; $data = isset($args[0]) ? $args[0] : array(); // Flush any pending commands first self::flush(); // Write RPC request directly to pipe (not buffered — need immediate send) $cmd = json_encode(array( 'cmd' => 'rpc', 'socketId' => $this->id, 'method' => $method, 'data' => $data, 'rpcId' => $rpcId, ), JSON_UNESCAPED_SLASHES) . "\n"; @fwrite(self::$_pipe, $cmd); // Block reading pipe until we get our RPC response (timeout 5s) $deadline = microtime(true) + 5.0; while (microtime(true) < $deadline) { $remaining = $deadline - microtime(true); if ($remaining <= 0) break; $read = array(self::$_pipe); $w = $e = null; $sec = (int) $remaining; $usec = (int) (($remaining - $sec) * 1000000); if (@stream_select($read, $w, $e, $sec, $usec) < 1) break; $header = @fread(self::$_pipe, 4); if (!$header || strlen($header) < 4) break; $len = unpack('N', $header)[1]; if ($len <= 0 || $len > 10485760) break; $json = ''; while (strlen($json) < $len) { $chunk = @fread(self::$_pipe, $len - strlen($json)); if ($chunk === false || $chunk === '') break 2; $json .= $chunk; } $msg = json_decode($json, true); if (!$msg) continue; // Is this our RPC response? if (isset($msg['_rpc']) && $msg['_rpc'] === $rpcId) { return isset($msg['result']) ? $msg['result'] : null; } // Not our response — buffer for the main loop self::$_messageQueue[] = $msg; } return null; // timeout } // ── Internal IPC plumbing (not part of the public API) ── /** @internal */ static $_pipe = null; /** @internal */ static $_ack = null; /** @internal */ static $_directMode = false; /** @internal */ static $_buffer = array(); /** @internal */ static $_rpcCounter = 0; /** @internal */ static $_messageQueue = array(); /** @internal */ static function _cmd($cmd) { if (self::$_directMode) { Q_WebSocket::executeCommand($cmd); } else { self::$_buffer[] = $cmd; } } /** @internal Flush buffered commands to IPC pipe */ static function flush() { if (!self::$_pipe || empty(self::$_buffer)) return; $out = ''; foreach (self::$_buffer as $cmd) { $out .= json_encode($cmd, JSON_UNESCAPED_SLASHES) . "\n"; } @fwrite(self::$_pipe, $out); self::$_buffer = array(); } } // ── Q_Room ────────────────────────────────────────── /** * Room context. Passed to room handlers as $params['room']. * Wraps IPC commands with room context for cleaner handler code. * * function chat_room_message(&$params, &$result) { * extract($params); // $room, $event, $data * $room->broadcast(['event' => 'chat/message', 'data' => $data]); * } * * @class Q_Room */ class Q_Room { /** @var string Room name (e.g. 'chat/general') */ public $name; /** @var integer Socket ID of the current message sender (0 for lifecycle events without a sender) */ public $socketId; /** @var array Pattern params (e.g. ['room' => 'general'] from 'chat/$room') */ public $params; function __construct($name, $socketId = 0, $params = array()) { $this->name = $name; $this->socketId = $socketId; $this->params = $params; } /** Get a room instance by name */ static function byName($name) { return new self($name); } /** Send to all members in this room */ function broadcast($data) { Q_Socket::_cmd(array('cmd' => 'broadcast', 'room' => $this->name, 'data' => $data)); } /** Send to the member who sent the current message */ function reply($data) { Q_Socket::_cmd(array('cmd' => 'send', 'socketId' => $this->socketId, 'data' => $data)); } /** Send to a specific member by socket ID */ function send($socketId, $data) { Q_Socket::_cmd(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data)); } } // ── Q_Request ─────────────────────────────────────── /** * Minimal Q_Response — compatible subset of the Qbix Platform's Q_Response. * Manages response headers, status codes, and cookies in CLI SAPI mode * where PHP's header()/setcookie()/headers_list() don't work. * * Use Q::header() for simple cases, or Q_Response methods for full control. * * @class Q_Response */ class Q_Response { /** @var array Response headers: name => value */ protected static $headers = array(); /** @var integer HTTP status code */ protected static $statusCode = 200; /** @var string Status message */ protected static $statusMessage = 'OK'; /** @var array Cookies to set: name => [value, expires, path, domain, secure, httponly, samesite] */ public static $cookies = array(); /** @var array Cookies to remove */ protected static $cookiesToRemove = array(); /** @var string|null Redirect URL if set */ public static $redirected = null; /** * Set a response header. Compatible with Q_Response::setHeader() from the Platform. * @method setHeader * @static * @param {string} $name Header name (e.g. 'Content-Type') * @param {string} $value Header value * @param {boolean} $replace Whether to replace existing header of same name */ static function setHeader($name, $value, $replace = true) { if ($replace || !isset(self::$headers[$name])) { self::$headers[$name] = $value; } // Also store in Q's header capture Q::$_responseHeaders[$name] = $value; // Call native header() for non-CLI SAPIs @header("$name: $value", $replace); } /** * Get a response header that was set. * @method getHeader * @static * @param {string} $name * @return {string|null} */ static function getHeader($name) { return self::$headers[$name] ?? null; } /** * Get all response headers. * @method getHeaders * @static * @return {array} */ static function getHeaders() { return self::$headers; } /** * Set the HTTP response status code. * Compatible with Q_Response::code() from the Platform. * @method code * @static * @param {integer} $code HTTP status code * @param {string} $message Optional status message */ static function code($code, $message = null) { self::$statusCode = (int) $code; if ($message !== null) { self::$statusMessage = $message; } Q::$_responseCode = (int) $code; http_response_code($code); } /** * Get the current status code. * @method getStatusCode * @static * @return {integer} */ static function getStatusCode() { return self::$statusCode; } /** * Set a cookie. Compatible with Q_Response::setCookie() from the Platform. * Prevents duplicate cookies — if the same name+value is already set * and it's a session cookie, skips it. * @method setCookie * @static * @param {string} $name * @param {string} $value * @param {integer} $expires Timestamp, 0 = session cookie * @param {string} $path Cookie path (default: /) * @param {string|null} $domain * @param {boolean} $secure * @param {boolean} $httponly * @param {string|null} $samesite None, Lax, or Strict * @return {string|false} */ static function setCookie( $name, $value, $expires = 0, $path = '/', $domain = null, $secure = false, $httponly = false, $samesite = null ) { // Skip if already set with same value and is a session cookie if (isset($_COOKIE[$name]) && $_COOKIE[$name] === $value && !$expires) { return $value; } self::$cookies[$name] = array($value, $expires, $path, $domain, $secure, $httponly, $samesite); unset(self::$cookiesToRemove[$name]); return $value; } /** * Get the value of a cookie that will be sent, falling back to $_COOKIE. * @method cookie * @static * @param {string} $name * @return {string|null} */ static function cookie($name) { return isset(self::$cookies[$name][0]) ? self::$cookies[$name][0] : ($_COOKIE[$name] ?? null); } /** * Clear a cookie. * @method clearCookie * @static * @param {string} $name * @param {string} $path */ static function clearCookie($name, $path = '/') { self::$cookiesToRemove[$name] = array($path); unset(self::$cookies[$name]); } /** * Set redirect. Compatible with Q_Response::redirect() from the Platform. * @method redirect * @static * @param {string} $url * @param {array} $options * @return {boolean} */ static function redirect($url, $options = array()) { $permanently = !empty($options['permanently']); self::code($permanently ? 301 : 302); self::setHeader('Location', $url); self::$redirected = $url; return true; } /** * Build Set-Cookie header strings from stored cookies. * Called by the server when assembling the response. * @method cookieHeaders * @static * @return {array} Array of Set-Cookie header strings */ static function cookieHeaders() { $headers = array(); // Remove cookies foreach (self::$cookiesToRemove as $name => $args) { $path = $args[0] ?? '/'; $headers[] = "$name=; Path=$path; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; } // Set cookies foreach (self::$cookies as $name => $args) { list($value, $expires, $path, $domain, $secure, $httponly, $samesite) = $args; $parts = array(urlencode($name) . '=' . urlencode($value)); if ($expires) { $parts[] = 'Expires=' . gmdate('D, d M Y H:i:s T', $expires); $parts[] = 'Max-Age=' . max(0, $expires - time()); } $parts[] = 'Path=' . ($path ?: '/'); if ($domain) $parts[] = 'Domain=' . $domain; if ($secure) $parts[] = 'Secure'; if ($httponly) $parts[] = 'HttpOnly'; if ($samesite) $parts[] = 'SameSite=' . $samesite; $headers[] = implode('; ', $parts); } return $headers; } /** * Clear all response state between requests (in-process mode). * @method clear * @static */ static function clear() { self::$headers = array(); self::$statusCode = 200; self::$statusMessage = 'OK'; self::$cookies = array(); self::$cookiesToRemove = array(); self::$redirected = null; } } // ── Q_Request ─────────────────────────────────────── /** * Minimal Q_Request — compatible subset of the Qbix Platform's Q_Request. * Provides convenient access to request data that the server has already parsed. * * @class Q_Request */ class Q_Request { /** * Raw request body. Set by the server before your script runs. * Use this instead of php://input (which doesn't work in our model). * @property $input * @type string * @static */ static $input = ''; /** * Get the HTTP method (GET, POST, PUT, DELETE, etc.) * @method method * @static * @return {string} */ static function method() { return $_SERVER['REQUEST_METHOD'] ?? 'GET'; } /** * Get the raw request body. * @method input * @static * @return {string} */ static function input() { return self::$input; } /** * Get the request body parsed as JSON. * @method json * @static * @param {boolean} $assoc Return associative array (default true) * @return {array|object|null} */ static function json($assoc = true) { return json_decode(self::$input, $assoc); } /** * Get the full request URL. * @method url * @static * @param {boolean} $querystring Include query string (default true) * @return {string} */ static function url($querystring = true) { $scheme = ($_SERVER['REQUEST_SCHEME'] ?? 'http'); $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'; $uri = $querystring ? ($_SERVER['REQUEST_URI'] ?? '/') : ($_SERVER['SCRIPT_NAME'] ?? '/'); return $scheme . '://' . $host . $uri; } /** * Get the URL path (without query string). * @method path * @static * @return {string} */ static function path() { $uri = $_SERVER['REQUEST_URI'] ?? '/'; $qPos = strpos($uri, '?'); return $qPos !== false ? substr($uri, 0, $qPos) : $uri; } /** * Get a request header value. * @method header * @static * @param {string} $name Header name (case-insensitive) * @return {string|null} */ static function header($name) { $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return $_SERVER[$key] ?? null; } /** * Get the client's IP address (resolved through proxy headers by the server). * @method ip * @static * @return {string} */ static function ip() { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Check if the request is an AJAX/XHR request. * @method isAjax * @static * @return {boolean} */ static function isAjax() { return strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest'; } /** * Get uploaded files. Convenience wrapper around $_FILES. * @method files * @static * @param {string|null} $name Specific file input name, or null for all * @return {array|null} */ static function files($name = null) { if ($name === null) return $_FILES; return $_FILES[$name] ?? null; } /** * Check if running in CLI mode (command line, cron, not via web server). * In Qbix Server, scripts run in CLI SAPI but are dispatched as web * requests. This method returns false for server-dispatched requests * (because $_SERVER['REQUEST_METHOD'] is set) and true for genuine * CLI invocations. * Compatible with Q_Request::isInternal() from the Platform. * @method isInternal * @static * @return {boolean} */ static function isInternal() { // If REQUEST_METHOD is set, we're handling a web request // (even though php_sapi_name() === 'cli') if (!empty($_SERVER['REQUEST_METHOD']) && !empty($_SERVER['REQUEST_URI'])) { return false; } return (php_sapi_name() === 'cli' || defined('STDIN') || !isset($_SERVER['REQUEST_METHOD'])); } /** * Whether the server is running in CLI SAPI. * Always true for Qbix Server (same as FrankenPHP worker mode, Workerman). * Scripts should use isInternal() to check if they're handling a web request. * @method isCli * @static * @return {boolean} */ static function isCli() { return php_sapi_name() === 'cli'; } /** * Get the Content-Type of the request. * @method contentType * @static * @return {string} */ static function contentType() { return $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? ''; } /** * Check if the request body is JSON. * @method isJson * @static * @return {boolean} */ static function isJson() { return strpos(strtolower(self::contentType()), 'application/json') !== false; } /** * Get a value from $_GET, $_POST, or $_REQUEST with a default. * @method special * @static * @param {string} $name * @param {mixed} $default * @return {mixed} */ static function special($name, $default = null) { return $_REQUEST[$name] ?? $default; } } // ── Q_Config ──────────────────────────────────────── /** * JSON config file loader with deep merge. * Compatible with the full Qbix Platform's Q_Config API. * * @class Q_Config */ class Q_Config { private static $data = array(); /** * Load and merge a JSON config file * @method load * @static * @param {string} $path Path to JSON file */ static function load($path) { if (!file_exists($path)) return; $json = json_decode(file_get_contents($path), true); if (is_array($json)) { self::$data = self::merge(self::$data, $json); } } /** * Set a config value programmatically. * Q_Config::set('Q', 'webserver', 'port', 8080) * @method set * @static */ static function set(/* key1, key2, ..., value */) { $args = func_get_args(); $value = array_pop($args); $ref = &self::$data; foreach ($args as $key) { if (!isset($ref[$key]) || !is_array($ref[$key])) { $ref[$key] = array(); } $ref = &$ref[$key]; } $ref = $value; } /** * Get a config value with a default. * Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100) * Last argument is the default. * @method get * @static * @return {mixed} */ static function get(/* key1, key2, ..., default */) { $args = func_get_args(); $default = array_pop($args); $ref = self::$data; foreach ($args as $key) { if (!is_array($ref) || !array_key_exists($key, $ref)) { return $default; } $ref = $ref[$key]; } return $ref; } /** * Get a config value or throw if missing. * Q_Config::expect('Q', 'app') * @method expect * @static * @return {mixed} * @throws {Exception} */ static function expect(/* key1, key2, ... */) { $args = func_get_args(); $ref = self::$data; foreach ($args as $key) { if (!is_array($ref) || !array_key_exists($key, $ref)) { throw new Exception("Missing config: " . implode('.', $args)); } $ref = $ref[$key]; } return $ref; } /** * Get all config data * @method getAll * @static * @return {array} */ static function getAll() { return self::$data; } /** * Deep merge: arrays merge recursively, scalars overwrite. * @method merge * @static * @private */ private static function merge($base, $overlay) { foreach ($overlay as $key => $value) { if (is_array($value) && isset($base[$key]) && is_array($base[$key])) { $base[$key] = self::merge($base[$key], $value); } else { $base[$key] = $value; } } return $base; } } ™ÊÎ Ì50Q{êãî™Ëáü„¼(½¦O’Q'hEGBMB