#!/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/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.php$$Ù½îP¤Q/WebServer/Dashboard.php99öº¤Q/WebServer/Log.phpK K ý«È¤Q/WebServer/Headers.phpÛ1Û1–o¤Q/WebSocket.phpèLèLûU‡Å¤ Q/Uri.php‚‚ÊrÁ^¤ Q/Evented.phpè(¬r¤Q/WebServer.phpSS…Z¤Q/Snapshot.phpÝÝ“£ëk¤Q/Evented/Driver.php''ûhšÇ¤Q/Evented/StreamSelect.phpÁ¦.¤Q/Evented/Revolt.phpu¬t¤Q.php¾s¾sõ0ÐÓ¤ [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) { $parts = $parsed['path'] . '?' . ($parsed['query'] ?? ''); // Include Accept-Encoding in key for compressed variants $ae = $parsed['headers']['accept-encoding'] ?? ''; if (strpos($ae, 'br') !== false) { $parts .= '|br'; } elseif (strpos($ae, 'gzip') !== false) { $parts .= '|gzip'; } return md5($parts); } static function cacheKeyFromUrl($url) { return md5($url); } static function filePath($key) { if (!self::$dir) return null; // Two-level directory to avoid too many files in one dir return self::$dir . DS . substr($key, 0, 2) . DS . $key . '.json'; } /** * Check if request has any cookies from the skip list. * If a session cookie is present, the response is likely * personalized and shouldn't be cached. */ static function hasSkipCookie($headers) { $cookieHeader = $headers['cookie'] ?? ''; if (!$cookieHeader || empty(self::$skipCookies)) return false; foreach (self::$skipCookies as $name) { if (preg_match('/(?:^|;\s*)' . preg_quote($name, '/') . '=/', $cookieHeader)) { return true; } } return false; } /** * Parse Cache-Control header into directives. */ static function parseCacheControl($headers) { $cc = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'cache-control') { $cc = $v; break; } } if (!$cc) return array(); $directives = array(); foreach (explode(',', $cc) as $part) { $part = trim($part); if (strpos($part, '=') !== false) { list($k, $v) = explode('=', $part, 2); $directives[trim($k)] = trim($v); } else { $directives[$part] = true; } } return $directives; } static function ttlRemaining($entry) { if ($entry['expires'] <= 0) return 86400; return max(1, $entry['expires'] - time()); } /** * Stats for the dashboard. */ static function stats() { $total = self::$hits + self::$misses; return array( 'hits' => self::$hits, 'misses' => self::$misses, 'hitRate' => $total > 0 ? round(self::$hits / $total * 100, 1) : 0, ); } } 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; } } [socket, watcher, channels, buffer, onMessage] */ static $clients = array(); /** Channel/room subscriptions. channelName => [socketKey => true] */ static $channels = array(); /** Connection workers. socketKey => [pid, pipe, watcher] */ static $workers = array(); /** Room workers. roomName => [pid, pipe, watcher, members => [socketKey => true], tick => ms] */ static $roomWorkers = array(); /** Cached room patterns from config */ static $roomPatterns = null; // ── Upgrade + framing (unchanged) ─────────────── static function upgrade($socket, $headers, $onMessage = null, $channel = null) { $key = $headers['sec-websocket-key'] ?? null; if (!$key) return false; $accept = base64_encode(sha1($key . self::GUID, true)); $resp = "HTTP/1.1 101 Switching Protocols\r\n" . "Upgrade: websocket\r\nConnection: Upgrade\r\n" . "Sec-WebSocket-Accept: $accept\r\n" . "Server: QbixServer\r\n\r\n"; @fwrite($socket, $resp); $sk = (int) $socket; $watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) { Q_WebSocket::onData($sk, $sock); }); self::$clients[$sk] = array( 'socket' => $socket, 'watcher' => $watcher, 'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage ); if ($channel) self::subscribe($sk, $channel); return true; } static function onData($sk, $socket) { if (!isset(self::$clients[$sk])) return; $data = @fread($socket, 65536); if ($data === false || $data === '') { self::disconnect($sk); return; } self::$clients[$sk]['buffer'] .= $data; while (($frame = self::decodeFrame(self::$clients[$sk]['buffer'])) !== null) { switch ($frame['opcode']) { case 0x1: // text $cb = self::$clients[$sk]['onMessage']; if ($cb) $cb($sk, $frame['payload']); break; case 0x2: // binary — ignore break; case 0x8: // close self::disconnect($sk); return; case 0x9: // ping → pong self::encodeAndSend(self::$clients[$sk]['socket'], 0xA, $frame['payload']); break; case 0xA: // pong — ignore break; } } } static function decodeFrame(&$buffer) { $len = strlen($buffer); if ($len < 2) return null; $b0 = ord($buffer[0]); $b1 = ord($buffer[1]); $opcode = $b0 & 0x0F; $masked = ($b1 >> 7) & 1; $payloadLen = $b1 & 0x7F; $offset = 2; if ($payloadLen === 126) { if ($len < 4) return null; $payloadLen = unpack('n', substr($buffer, 2, 2))[1]; $offset = 4; } elseif ($payloadLen === 127) { if ($len < 10) return null; $payloadLen = unpack('J', substr($buffer, 2, 8))[1]; $offset = 10; } if ($masked) { if ($len < $offset + 4 + $payloadLen) return null; $mask = substr($buffer, $offset, 4); $offset += 4; $payload = ''; $raw = substr($buffer, $offset, $payloadLen); for ($i = 0; $i < $payloadLen; $i++) { $payload .= chr(ord($raw[$i]) ^ ord($mask[$i % 4])); } } else { if ($len < $offset + $payloadLen) return null; $payload = substr($buffer, $offset, $payloadLen); } $buffer = substr($buffer, $offset + $payloadLen); return array('opcode' => $opcode, 'payload' => $payload); } // ── Sending ───────────────────────────────────── static function send($socketKey, $data) { if (!isset(self::$clients[$socketKey])) return; $json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $json); } static function broadcast($data) { $json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); foreach (self::$clients as $sk => $c) { self::encodeAndSend($c['socket'], 0x1, $json); } } static function broadcastTo($channel, $data) { if (!isset(self::$channels[$channel])) return; $json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); foreach (self::$channels[$channel] as $sk => $_) { if (isset(self::$clients[$sk])) { self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $json); } } } static function subscribe($sk, $channel) { if (!isset(self::$channels[$channel])) self::$channels[$channel] = array(); self::$channels[$channel][$sk] = true; if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true; // If a room worker exists for this channel, notify it self::notifyRoomJoin($channel, $sk); } static function unsubscribe($sk, $channel) { unset(self::$channels[$channel][$sk]); if (empty(self::$channels[$channel])) unset(self::$channels[$channel]); if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]); self::notifyRoomLeave($channel, $sk); } static function disconnect($sk) { if (!isset(self::$clients[$sk])) return; self::notifyDisconnect($sk); $w = self::$clients[$sk]['watcher']; if ($w) Q_Evented::cancel($w); foreach (self::$clients[$sk]['channels'] as $ch => $_) { unset(self::$channels[$ch][$sk]); self::notifyRoomLeave($ch, $sk); } @fclose(self::$clients[$sk]['socket']); unset(self::$clients[$sk]); } static function encodeAndSend($socket, $opcode, $payload) { $len = strlen($payload); $frame = chr(0x80 | $opcode); if ($len < 126) { $frame .= chr($len); } elseif ($len < 65536) { $frame .= chr(126) . pack('n', $len); } else { $frame .= chr(127) . pack('J', $len); } $frame .= $payload; @fwrite($socket, $frame); } // ── Connection worker (process-per-socket) ────── static function dispatchEvent($socketKey, $raw, $path = '/') { $msg = json_decode($raw, true); if (!$msg || empty($msg['event'])) return; $event = $msg['event']; // Check if this event should go to a room worker instead if (isset(self::$clients[$socketKey])) { foreach (self::$clients[$socketKey]['channels'] as $ch => $_) { if (isset(self::$roomWorkers[$ch])) { // Forward to room worker with sender info $msg['_socketId'] = $socketKey; self::sendToRoomWorker($ch, $msg); return; } } } // Default: per-connection worker if (!isset(self::$workers[$socketKey])) { self::spawnWorker($socketKey, $path); } if (!isset(self::$workers[$socketKey])) return; $json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $packet = pack('N', strlen($json)) . $json; $written = @fwrite(self::$workers[$socketKey]['pipe'], $packet); if ($written === false || $written === 0) { self::cleanupWorker($socketKey); self::spawnWorker($socketKey, $path); if (isset(self::$workers[$socketKey])) { @fwrite(self::$workers[$socketKey]['pipe'], $packet); } } } static function spawnWorker($socketKey, $path) { if (!function_exists('pcntl_fork')) return; $pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET; $pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); if (!$pair) return; $pid = pcntl_fork(); if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; } if ($pid === 0) { // ── CHILD: connection message loop ── fclose($pair[0]); $pipe = $pair[1]; Q_Socket::$_pipe = $pipe; Q_Socket::$_socketId = $socketKey; $connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null); if ($connectHandler) { Q::event($connectHandler, array( '_socketId' => $socketKey, '_path' => $path, 'event' => '_connect', 'data' => array(), )); Q_Socket::flush(); } while (true) { $header = @fread($pipe, 4); if ($header === false || $header === '' || strlen($header) < 4) break; $len = unpack('N', $header)[1]; if ($len <= 0 || $len > 10485760) break; $json = ''; while (strlen($json) < $len) { $chunk = @fread($pipe, $len - strlen($json)); if ($chunk === false || $chunk === '') break 2; $json .= $chunk; } $msg = json_decode($json, true); if (!$msg) continue; $event = $msg['event'] ?? ''; if ($event === '_disconnect') break; $mapped = Q_Config::get('Q', 'webserver', 'sockets', 'events', $event, $event); Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null; $result = null; $params = array( '_socketId' => $socketKey, '_path' => $path, '_ack' => Q_Socket::$_ack, 'event' => $event, 'data' => $msg['data'] ?? array(), ); Q::event($mapped, $params, false, false, $result); if (Q_Socket::$_ack !== null && $result !== null) { Q_Socket::reply(array('ack' => Q_Socket::$_ack, 'data' => $result)); } Q_Socket::flush(); } $disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null); if ($disconnectHandler) { Q::event($disconnectHandler, array( '_socketId' => $socketKey, 'event' => '_disconnect', 'data' => array(), )); Q_Socket::flush(); } fclose($pipe); exit(0); } // ── PARENT ── fclose($pair[1]); stream_set_blocking($pair[0], false); $ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($socketKey) { $data = @fread($pipe, 65536); if ($data === false || $data === '') { Q_WebSocket::cleanupWorker($socketKey); return; } $lines = explode("\n", trim($data)); foreach ($lines as $line) { if ($line === '') continue; $cmd = json_decode($line, true); if ($cmd) Q_WebSocket::executeCommand($cmd); } }); self::$workers[$socketKey] = array( 'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher, ); } static function cleanupWorker($socketKey) { if (!isset(self::$workers[$socketKey])) return; $w = self::$workers[$socketKey]; if ($w['watcher']) Q_Evented::cancel($w['watcher']); @fclose($w['pipe']); if ($w['pid'] > 0 && function_exists('posix_kill')) { posix_kill($w['pid'], SIGTERM); pcntl_waitpid($w['pid'], $st, WNOHANG); } unset(self::$workers[$socketKey]); } static function notifyDisconnect($socketKey) { if (!isset(self::$workers[$socketKey])) return; $json = json_encode(array('event' => '_disconnect', 'data' => array())); $packet = pack('N', strlen($json)) . $json; @fwrite(self::$workers[$socketKey]['pipe'], $packet); self::cleanupWorker($socketKey); } // ── Room workers (process-per-room) ───────────── /** * Get room patterns from config. Cached. * Config format: * Q.webserver.sockets.rooms.$pattern = {handler, tick?} * e.g. "game/$id" => {"handler": "game/room", "tick": 100} * @method getRoomPatterns * @static */ static function getRoomPatterns() { if (self::$roomPatterns !== null) return self::$roomPatterns; self::$roomPatterns = Q_Config::get('Q', 'webserver', 'sockets', 'rooms', array()); return self::$roomPatterns; } /** * Check if a room name matches a configured room pattern. * Returns the config (handler, tick) or null. * @method matchRoomPattern * @static */ static function matchRoomPattern($roomName) { $patterns = self::getRoomPatterns(); if (empty($patterns)) return null; $segments = explode('/', $roomName); foreach ($patterns as $pattern => $config) { $pSegments = explode('/', $pattern); if (count($pSegments) !== count($segments)) continue; $match = true; $params = array(); for ($i = 0; $i < count($pSegments); $i++) { $ps = $pSegments[$i]; if (isset($ps[0]) && ($ps[0] === '$' || $ps[0] === ':')) { $params[substr($ps, 1)] = $segments[$i]; } elseif ($ps !== $segments[$i]) { $match = false; break; } } if ($match) { return array_merge((array) $config, array('_params' => $params, '_pattern' => $pattern)); } } return null; } /** * Spawn a room worker process. * @method spawnRoomWorker * @static */ static function spawnRoomWorker($roomName, $config) { if (!function_exists('pcntl_fork')) return; if (isset(self::$roomWorkers[$roomName])) return; $pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET; $pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); if (!$pair) return; $handler = $config['handler'] ?? ''; $tick = isset($config['tick']) ? (int) $config['tick'] : 0; $params = $config['_params'] ?? array(); $pid = pcntl_fork(); if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; } if ($pid === 0) { // ── CHILD: room message loop ── fclose($pair[0]); $pipe = $pair[1]; Q_Socket::$_pipe = $pipe; Q_Socket::$_socketId = 0; // room process, no single socket // Set up tick timer if configured $tickCallback = null; if ($tick > 0) { $tickCallback = function () use ($handler, $roomName, $params, $pipe) { Q_Socket::$_ack = null; $result = null; $p = array_merge($params, array( '_room' => $roomName, 'event' => '_tick', 'data' => array(), '_socketId' => 0, )); Q::event($handler, $p, false, false, $result); Q_Socket::flush(); }; } // Fire _init event $result = null; Q::event($handler, array_merge($params, array( '_room' => $roomName, 'event' => '_init', 'data' => array(), '_socketId' => 0, )), false, false, $result); Q_Socket::flush(); // Message loop with optional tick stream_set_blocking($pipe, false); $lastTick = microtime(true); while (true) { $read = array($pipe); $write = $except = null; $timeout = $tick > 0 ? max(0.001, ($tick / 1000.0) - (microtime(true) - $lastTick)) : 1.0; $ready = @stream_select($read, $write, $except, (int) $timeout, (int) (($timeout - (int) $timeout) * 1000000)); // Tick if ($tick > 0 && (microtime(true) - $lastTick) * 1000 >= $tick) { $lastTick = microtime(true); if ($tickCallback) $tickCallback(); } if ($ready === false) break; if ($ready === 0) continue; // Read length-prefixed messages $raw = @fread($pipe, 65536); if ($raw === false || $raw === '') break; // May contain multiple messages $buf = $raw; while (strlen($buf) >= 4) { $len = unpack('N', substr($buf, 0, 4))[1]; if ($len <= 0 || $len > 10485760) { $buf = ''; break; } if (strlen($buf) < 4 + $len) break; $json = substr($buf, 4, $len); $buf = substr($buf, 4 + $len); $msg = json_decode($json, true); if (!$msg) continue; $event = $msg['event'] ?? ''; if ($event === '_shutdown') break 2; Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null; Q_Socket::$_socketId = $msg['_socketId'] ?? 0; $result = null; $p = array_merge($params, array( '_room' => $roomName, '_socketId' => Q_Socket::$_socketId, '_ack' => Q_Socket::$_ack, 'event' => $event, 'data' => $msg['data'] ?? array(), )); Q::event($handler, $p, false, false, $result); if (Q_Socket::$_ack !== null && $result !== null) { Q_Socket::send(Q_Socket::$_socketId, array('ack' => Q_Socket::$_ack, 'data' => $result)); } Q_Socket::flush(); } } // Fire _destroy event Q::event($handler, array_merge($params, array( '_room' => $roomName, 'event' => '_destroy', 'data' => array(), '_socketId' => 0, )), false, false, $result); Q_Socket::flush(); fclose($pipe); exit(0); } // ── PARENT ── fclose($pair[1]); stream_set_blocking($pair[0], false); $ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($roomName) { $data = @fread($pipe, 65536); if ($data === false || $data === '') { Q_WebSocket::cleanupRoomWorker($roomName); return; } $lines = explode("\n", trim($data)); foreach ($lines as $line) { if ($line === '') continue; $cmd = json_decode($line, true); if ($cmd) Q_WebSocket::executeCommand($cmd); } }); self::$roomWorkers[$roomName] = array( 'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher, 'members' => array(), ); } /** * Send a message to a room worker. * @method sendToRoomWorker * @static */ static function sendToRoomWorker($roomName, $msg) { if (!isset(self::$roomWorkers[$roomName])) return; $json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $packet = pack('N', strlen($json)) . $json; @fwrite(self::$roomWorkers[$roomName]['pipe'], $packet); } /** * Notify room worker when a socket joins. * @method notifyRoomJoin * @static */ static function notifyRoomJoin($channel, $socketKey) { $config = self::matchRoomPattern($channel); if (!$config) return; // Spawn room worker if not running if (!isset(self::$roomWorkers[$channel])) { self::spawnRoomWorker($channel, $config); } if (!isset(self::$roomWorkers[$channel])) return; self::$roomWorkers[$channel]['members'][$socketKey] = true; self::sendToRoomWorker($channel, array( 'event' => '_join', 'data' => array(), '_socketId' => $socketKey, )); } /** * Notify room worker when a socket leaves. * @method notifyRoomLeave * @static */ static function notifyRoomLeave($channel, $socketKey) { if (!isset(self::$roomWorkers[$channel])) return; unset(self::$roomWorkers[$channel]['members'][$socketKey]); self::sendToRoomWorker($channel, array( 'event' => '_leave', 'data' => array(), '_socketId' => $socketKey, )); // Shut down room if empty if (empty(self::$roomWorkers[$channel]['members'])) { self::sendToRoomWorker($channel, array( 'event' => '_shutdown', 'data' => array(), )); self::cleanupRoomWorker($channel); } } /** * Clean up a room worker. * @method cleanupRoomWorker * @static */ static function cleanupRoomWorker($roomName) { if (!isset(self::$roomWorkers[$roomName])) return; $w = self::$roomWorkers[$roomName]; if ($w['watcher']) Q_Evented::cancel($w['watcher']); @fclose($w['pipe']); if ($w['pid'] > 0 && function_exists('posix_kill')) { posix_kill($w['pid'], SIGTERM); pcntl_waitpid($w['pid'], $st, WNOHANG); } unset(self::$roomWorkers[$roomName]); } // ── In-process fallback (Windows) ─────────────── static function dispatchEventInProcess($eventName, $params, $socketKey, $ack) { Q_Socket::$_directMode = true; Q_Socket::$_socketId = $socketKey; Q_Socket::$_ack = $ack; $result = null; Q::event($eventName, $params, false, false, $result); if ($ack !== null && $result !== null) { self::send($socketKey, array('ack' => $ack, 'data' => $result)); } Q_Socket::$_directMode = false; } // ── IPC command execution ─────────────────────── static function executeCommand($cmd) { switch ($cmd['cmd'] ?? '') { case 'send': self::send($cmd['socketId'], $cmd['data']); break; case 'broadcast': self::broadcastTo($cmd['room'], $cmd['data']); break; case 'broadcastAll': self::broadcast($cmd['data']); break; case 'join': self::subscribe($cmd['socketId'], $cmd['room']); break; case 'leave': self::unsubscribe($cmd['socketId'], $cmd['room']); break; } } } 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; } } 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(); }); // Reap zombie children from fork-per-request PHP execution Q_Evented::onSignal(SIGCHLD, function () { while (pcntl_waitpid(-1, $st, WNOHANG) > 0) {} }); } Q_Evented::run(); } // ── Connection handling ────────────────────────────── static function onAccept($socket) { // Max connections check (cached) static $maxConn = null; if ($maxConn === null) $maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024); if (count(self::$clients) >= $maxConn) { $reject = @stream_socket_accept($socket, 0); if ($reject) { @fwrite($reject, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($reject); } return; } $client = @stream_socket_accept($socket, 0); if (!$client) return; stream_set_blocking($client, false); // Disable Nagle's algorithm — eliminates 40ms delayed ACK on keep-alive if (function_exists('socket_import_stream')) { $rawSocket = socket_import_stream($client); if ($rawSocket) { socket_set_option($rawSocket, SOL_TCP, TCP_NODELAY, 1); } } $key = (int) $client; self::$clients[$key] = $client; self::$buffers[$key] = ''; self::$keepAliveCount[$key] = 0; // Store remote IP for logging + proxy resolution $peer = stream_socket_get_name($client, true); $ip = $peer ? explode(':', $peer)[0] : '0.0.0.0'; self::$clientInfo[$key] = array( 'ip' => $ip, 'connectTime' => microtime(true) ); // Rate limit check if (!self::checkRateLimit($ip)) { @fwrite($client, "HTTP/1.1 429 Too Many Requests\r\n" . "Retry-After: 60\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($client); unset(self::$clients[$key], self::$buffers[$key], self::$keepAliveCount[$key], self::$clientInfo[$key]); return; } self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { Q_WebServer::onClientData($c); } ); // Read timeout — close if no complete request within N seconds static $readTimeout = null; if ($readTimeout === null) $readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30); self::$timeoutWatchers[$key] = Q_Evented::delay($readTimeout, function () use ($key) { Q_WebServer::closeClient($key); }); } static function onClientData($client) { $key = (int) $client; if (!isset(self::$clients[$key])) return; // Check if we already have a complete request from pipelining $buf = self::$buffers[$key] ?? ''; $havePipelined = ($buf !== '' && strpos($buf, "\r\n\r\n") !== false); if (!$havePipelined) { $chunk = @fread($client, 65536); if ($chunk === false || $chunk === '') { self::closeClient($key); return; } self::$buffers[$key] .= $chunk; $buf = self::$buffers[$key]; } // Wait for complete headers $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd === false) { if (strlen($buf) > 65536) self::closeClient($key); return; } // Wait for complete body on POST/PUT/PATCH $firstChar = $buf[0]; if ($firstChar === 'P') { // POST, PUT, PATCH all start with P $cl = 0; if (preg_match('/content-length:\s*(\d+)/i', $buf, $m)) { $cl = (int) $m[1]; } if ($cl > 10485760) { self::sendResponse($client, 413, 'Payload Too Large'); self::closeClient($key); return; } if (strlen($buf) - $headerEnd - 4 < $cl) return; } // Cancel read timeout (request received) if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } // Calculate consumed bytes for pipelining support $headerEnd = strpos($buf, "\r\n\r\n"); $bodyLen = 0; $firstChar = $buf[0]; if ($firstChar === 'P') { // POST/PUT/PATCH if (preg_match('/content-length:\s*(\d+)/i', $buf, $clm)) { $bodyLen = (int) $clm[1]; } } $consumed = $headerEnd + 4 + $bodyLen; $start = microtime(true); $parsed = self::parseRequest($buf); // Reject malformed request lines if (!empty($parsed['_malformed'])) { self::sendResponse($client, 400, 'Bad Request'); self::closeClient($key); return; } // Reject oversized headers (>64KB total) $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd > 65536) { self::sendResponse($client, 431, 'Request Header Fields Too Large', 'text/plain; charset=utf-8', array('Connection' => 'close')); self::closeClient($key); return; } // Resolve proxy headers for real client IP $directIp = self::$clientInfo[$key]['ip'] ?? '0.0.0.0'; $parsed['clientIp'] = Q_WebServer_Proxy::clientIp($directIp, $parsed['headers']); $parsed['_remoteAddr'] = $parsed['clientIp']; $peer = stream_socket_get_name($client, true); $parsed['_remotePort'] = $peer ? (int) substr(strrchr($peer, ':'), 1) : 0; // Determine keep-alive before handling request static $maxKeepAlive = null; if ($maxKeepAlive === null) $maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100); $connHeader = strtolower($parsed['headers']['connection'] ?? 'keep-alive'); self::$keepAliveCount[$key] = (self::$keepAliveCount[$key] ?? 0) + 1; $parsed['_keepAlive'] = ($connHeader !== 'close') && self::$keepAliveCount[$key] < $maxKeepAlive; try { $keepOpen = self::handleRequest($client, $parsed); } catch (\Throwable $e) { // Never let a request crash the event loop $msg = htmlspecialchars($e->getMessage()); self::sendResponse($client, 500, "Internal Server Error: $msg"); self::closeClient($key); $ms = round((microtime(true) - $start) * 1000, 1); Q_WebServer_Dashboard::recordRequest( $parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms ); if (self::$onRequest) { (self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms); } return; } $ms = round((microtime(true) - $start) * 1000, 1); if ($keepOpen) { // WebSocket upgraded — Q_WebSocket owns this socket now if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); return; } // Stats + logging Q_WebServer_Dashboard::recordRequest( $parsed['method'], $parsed['uri'], self::$lastStatus, $ms ); if (self::$onRequest) { (self::$onRequest)($parsed['method'], $parsed['uri'], self::$lastStatus, $ms); } // Log to file $bodyLen = strlen(self::$lastBody ?? ''); Q_WebServer_Log::access( $parsed['clientIp'], $parsed['method'], $parsed['uri'], self::$lastStatus, $bodyLen, $parsed['headers']['referer'] ?? '', $parsed['headers']['user-agent'] ?? '', $ms ); // ── Keep-alive decision ────────────────────────── static $keepAliveTimeout = null; if ($keepAliveTimeout === null) $keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15); $shouldKeepAlive = !empty($parsed['_keepAlive']) && self::$lastStatus < 500; if ($shouldKeepAlive) { // Keep leftover data for pipelined requests $leftover = strlen($buf) > $consumed ? substr($buf, $consumed) : ''; self::$buffers[$key] = $leftover; // Set idle timeout — close if no new request arrives self::$timeoutWatchers[$key] = Q_Evented::delay( $keepAliveTimeout, function () use ($key) { Q_WebServer::closeClient($key); } ); // If there's already a complete request in the buffer, process it now if ($leftover !== '' && strpos($leftover, "\r\n\r\n") !== false) { Q_Evented::defer(function () use ($client) { Q_WebServer::onClientData($client); }); } } else { self::closeClient($key); } } // ── Request routing ────────────────────────────────── /** * Route a parsed request and return a response array. * * This is the clean interface that external HTTP drivers * (like amphp/http-server) call. Handles all routing: * blocked paths, static files, PHP dispatch, directory * listings, X-Accel-Redirect, compression. * * The built-in server uses handleRequest() which writes * directly to sockets. amphp calls route() and converts * the response to its own format. * * @method route * @static * @param {array} $parsed [method, uri, path, query, headers, body, clientIp] * @return {array} [status, headers, body] */ static function route($parsed) { $method = $parsed['method']; $path = $parsed['path']; // Reverse cache check (before any dispatch) $cached = Q_WebServer_Cache::get($parsed); if ($cached) return $cached; if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); return array('status'=>200, 'body'=>json_encode(array('status'=>'ok')+$stats), 'headers'=>array('Content-Type'=>'application/json')); } if ($path === '/Q/dashboard' || $path === '/Q/dashboard/') { return array('status'=>200, 'body'=>Q_WebServer_Dashboard::renderHtml($parsed), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } if (self::isBlocked($path)) { return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } $fsPath = self::resolveStatic($path); // Directory if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { return array('status'=>301, 'body'=>'', 'headers'=>array('Location'=>$path.'/')); } foreach (array('index.html','index.php') as $idx) { $ip = $fsPath.DS.$idx; if (is_file($ip)) { $fsPath = $ip; break; } } if (is_dir($fsPath)) { if (self::isIndexed($path)) { return array('status'=>200, 'body'=>self::renderDirectoryListing($fsPath, $path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8', 'Cache-Control'=>'no-store')); } return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } } // File if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); if ($ext === 'php') { // PHP dispatch (in-process — amphp uses fibers for concurrency) $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } if (in_array($ext, self::$allowedExtensions) && ($method === 'GET' || $method === 'HEAD') ) { return self::buildFileResponse($fsPath, $ext, $method, $parsed['headers']); } } // Clean URL → index.php if (is_file(self::$rootDir . 'index.php')) { $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } return array('status'=>404, 'body'=>self::render404($path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } /** * Process a PHP response: X-Accel-Redirect + compression. * Used by both route() and handlePhp(). */ static function processPhpResponse($response, $reqHeaders) { $headers = Q_WebServer_Headers::stripInternal($response['headers'] ?? array()); $body = $response['body'] ?? ''; // X-Accel-Redirect foreach ($response['headers'] ?? array() as $k => $v) { if (strtolower($k) === 'x-accel-redirect') { $af = Q_WebServer_Headers::resolveAccelPath($v); if ($af && is_file($af)) { $body = file_get_contents($af); $ext = strtolower(pathinfo($af, PATHINFO_EXTENSION)); if (!Q_WebServer_Headers::hasHeader($headers, 'Content-Type')) { $headers['Content-Type'] = self::mimeType($ext); } } $headers = Q_WebServer_Headers::stripInternal($headers); break; } } $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); return array('status'=>$response['status']??200, 'body'=>$body, 'headers'=>$headers); } /** * Build a static file response with ETag/compression. * Used by route() for amphp compatibility. */ static function buildFileResponse($fsPath, $ext, $method, $reqHeaders) { clearstatcache(true, $fsPath); $mtime = filemtime($fsPath); $size = filesize($fsPath); $ct = self::mimeType($ext); $headers = array( 'Content-Type' => $ct, 'ETag' => '"' . dechex($mtime) . '-' . dechex($size) . '"', 'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT', 'Cache-Control' => 'public, max-age=0, must-revalidate' ); $body = ($method === 'HEAD') ? '' : file_get_contents($fsPath); if ($method !== 'HEAD') { $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); } return array('status'=>200, 'body'=>$body, 'headers'=>$headers); } // ── Built-in server: socket-based routing ──────────── /** * Route a request (built-in server). * Returns true if the connection should stay open (WebSocket). * @return {boolean} */ private static function handleRequest($client, $parsed) { $method = $parsed['method']; $path = $parsed['path']; // 1. Dashboard + Panel + WebSocket + Health (/Q/*) if (strpos($path, '/Q/') === 0) { if ($path === '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], null, 'dashboard' ); return $upgraded; // true = keep open } if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); self::sendResponse($client, 200, json_encode(array('status' => 'ok') + $stats), 'application/json'); return false; } // Panel (control panel + API) $handled = Q_WebServer_Panel::handle($client, $parsed); if ($handled) return false; // Dashboard (live stats) $handled = Q_WebServer_Dashboard::handle($client, $parsed); if ($handled) return false; } // 2. WebSocket upgrade on any path $upgrade = strtolower($parsed['headers']['upgrade'] ?? ''); if ($upgrade === 'websocket' && $path !== '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], function ($sk, $msg) use ($path) { Q_WebSocket::dispatchEvent($sk, $msg, $path); } ); return $upgraded; } // 3. Blocked paths if (self::isBlocked($path)) { self::sendResponse($client, 403, 'Forbidden'); return false; } // 3. Component cache check (Merkle tree — serves from cached slots) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); $cachedPage = Q_WebServer_Cache_Components::getPage($pageKey); if ($cachedPage !== null) { self::sendResponse($client, 200, $cachedPage, 'text/html; charset=utf-8', array('X-Cache' => 'HIT-COMPONENTS')); return false; } } // 4. Reverse cache check (before forking a worker) $cached = Q_WebServer_Cache::get($parsed); if ($cached) { self::sendResponse($client, $cached['status'], $cached['body'], $cached['headers']['Content-Type'] ?? 'text/html', $cached['headers']); return false; } // 4. Resolve filesystem path $fsPath = self::resolveStatic($path); // 4. Directory handling if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { self::sendRedirect($client, $path . '/'); return false; } // Check for index files foreach (array('index.html', 'index.php') as $idx) { $indexPath = $fsPath . DS . $idx; if (is_file($indexPath)) { $fsPath = $indexPath; break; } } if (is_dir($fsPath)) { // No index file → check if listings are enabled for this path if (self::isIndexed($path)) { $html = self::renderDirectoryListing($fsPath, $path); self::sendResponse($client, 200, $html, 'text/html; charset=utf-8', array('Cache-Control' => 'no-store')); } else { self::sendResponse($client, 403, 'Forbidden'); } return false; } } // 5. File handling if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // PHP scripts → worker pool or in-process if ($ext === 'php') { return self::handlePhp($client, $parsed, $fsPath); } // Static file if ($method === 'GET' || $method === 'HEAD') { self::serveStaticFile($client, $fsPath, $method, $parsed['headers'], !empty($parsed['_keepAlive'])); return false; } } // 6. Route dispatch — if Q.routes configured, match URL to handler // Q_Uri caches compiled patterns and path→URI results in memory. static $routingEnabled = null; if ($routingEnabled === null) { $routingEnabled = Q_Config::get('Q', 'routes', null) !== null && class_exists('Q_Uri', true); } if ($routingEnabled) { $uri = Q_Uri::fromPath($path); if ($uri && !empty($uri->module) && !empty($uri->action)) { return self::handleRoute($client, $parsed, $uri); } } // 7. Clean URL → route through index.php (if exists) $indexPhp = self::$rootDir . 'index.php'; if (is_file($indexPhp)) { return self::handlePhp($client, $parsed, $indexPhp); } // 8. Configurable fallback (SPA routing, custom 404 page, etc.) // Q.webserver.fallback can be: // - string: path to a static file relative to web/ (e.g. "index.html") // - object with "handler": event name to dispatch via Q::event() // - object with "file": static file + auto-detect Content-Type static $fallback = null; if ($fallback === null) $fallback = Q_Config::get('Q', 'webserver', 'fallback', null); if ($fallback !== null) { if (is_string($fallback)) { // Static file (SPA catch-all: serve index.html for all routes) $fbPath = self::$rootDir . str_replace('/', DS, $fallback); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } elseif (is_array($fallback)) { if (!empty($fallback['handler'])) { // Route to a handler — build a synthetic Q_Uri $uri = Q_Uri::from(array( 'module' => dirname($fallback['handler']), 'action' => basename($fallback['handler']), '_originalPath' => $path, )); if ($uri) { return self::handleRoute($client, $parsed, $uri); } } elseif (!empty($fallback['file'])) { $fbPath = self::$rootDir . str_replace('/', DS, $fallback['file']); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } } } // 9. Not found self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8'); return false; } /** * Handle a routed request via Q::event() dispatch pipeline. * Fires the same events as Qbix Platform's Q_Dispatcher: * {module}/{action}/validate → validate input * {module}/{action}/{method} → handle GET/POST/PUT/DELETE * {module}/{action}/response → render response * * @method handleRoute * @static * @private * @param {resource} $client * @param {array} $parsed * @param {Q_Uri} $uri * @return {boolean} */ private static function handleRoute($client, $parsed, $uri) { $module = $uri->module; $action = $uri->action; $routed = $uri->toArray(); $method = strtolower($parsed['method']); // get, post, put, delete // Set up superglobals $parsed['_scriptPath'] = ''; // no script — handler-based $saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE); $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SERVER_NAME'] = explode(':', $parsed['headers']['host'] ?? 'localhost')[0]; $_SERVER['SERVER_PORT'] = self::$port; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0'; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); $_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1'; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } $_GET = $_POST = $_REQUEST = $_FILES = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($parsed['headers']['content-type'] ?? ''); $rawBody = $parsed['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($rawBody, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($rawBody, true) ?: array(); } elseif (strpos($ct, 'multipart/form-data') !== false) { $origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''; self::parseMultipart($origCt, $rawBody, $_POST, $_FILES); } $_REQUEST = array_merge($_GET, $_POST); // Make raw body available Q_Request::$input = $rawBody; // If pcntl available, fork to isolate if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === 0) { // ── CHILD: run dispatch pipeline ── while (ob_get_level()) ob_end_clean(); ob_start(); $status = 200; $headers = array(); try { // 1. Validate Q::event("$module/$action/validate", $routed, false, true); // 2. Method handler (get, post, put, delete) if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } // 3. Response Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } elseif ($pid > 0) { @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; return false; } // Fork failed — fall through to in-process } // In-process fallback while (ob_get_level()) ob_end_clean(); header_remove(); http_response_code(200); ob_start(); $status = 200; $headers = array(); try { Q::event("$module/$action/validate", $routed, false, true); if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $status; Q_WebServer_Cache::put($parsed, $response); return false; } /** * Route a .php script to the worker pool or dispatch in-process. * @return {boolean} false (connection closes after response) */ private static function handlePhp($client, $parsed, $scriptPath) { // ── CGI carveout: check if this script should use php-cgi ── // Scripts matching Q.webserver.cgi.patterns run via php-cgi subprocess // where native header(), setcookie(), headers_list() all work. // Use for legacy/third-party code (WordPress, etc.) that calls header() directly. static $cgiPatterns = null; static $cgiBinary = null; if ($cgiPatterns === null) { $cgiPatterns = Q_Config::get('Q', 'webserver', 'cgi', 'patterns', array()); $cgiBinary = Q_Config::get('Q', 'webserver', 'cgi', 'binary', null); if (!$cgiBinary) { // Auto-detect php-cgi foreach (array('php-cgi', 'php-cgi8.3', 'php-cgi8.2', 'php-cgi8.1') as $bin) { $path = trim(shell_exec("which $bin 2>/dev/null") ?? ''); if ($path && is_executable($path)) { $cgiBinary = $path; break; } } } } if (!empty($cgiPatterns) && $cgiBinary) { $relPath = '/' . ltrim(str_replace(DS, '/', substr($scriptPath, strlen(self::$rootDir))), '/'); foreach ($cgiPatterns as $pattern) { if (@preg_match($pattern, $relPath)) { return self::handlePhpCgi($client, $parsed, $scriptPath, $cgiBinary); } } } if (self::$pool) { self::$lastStatus = 200; self::$pool->dispatch($client, $parsed, $scriptPath); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); return false; } // No pool — fork a single child if pcntl is available. // This protects the server from exit()/die() in scripts // and prevents blocking the event loop during PHP execution. if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === -1) { // Fork failed — fall through to in-process execution } elseif ($pid === 0) { // ── CHILD: handle request, write response to client, exit ── $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } else { // ── PARENT: close client socket (child owns it now), reap later ── @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); // Non-blocking reap — don't wait for child pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; return false; } } // Fallback: use proc_open to run PHP in a subprocess (Windows, // or fork failed). Safe against exit()/die() — the subprocess // dies, not the server. Slower than fork (no shared classes) // but still provides isolation. return self::handlePhpSubprocess($client, $parsed, $scriptPath); } /** * Execute a PHP script in a subprocess via proc_open. * Used on Windows (no pcntl_fork) or as fallback when fork fails. * The subprocess gets request data via stdin, returns response via stdout. * @method handlePhpSubprocess * @static * @private */ private static function handlePhpSubprocess($client, $parsed, $scriptPath) { // Build a small inline PHP worker that: // 1. Reads request JSON from stdin // 2. Sets up superglobals // 3. Loads Q.php for autoloader/events // 4. Includes the target script // 5. Writes response JSON to stdout $qFile = dirname(__FILE__) . DS . 'Q.php'; $workerCode = <<<'WORKER' 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 static $pathCache = array(); if (isset($pathCache[$urlPath])) { $cached = $pathCache[$urlPath]; // Quick mtime check for invalidation (cheaper than realpath) if ($cached === null || file_exists($cached)) { return $cached; } unset($pathCache[$urlPath]); } $rel = str_replace('/', DS, ltrim($urlPath, '/')); // Block null bytes (directory traversal via null byte injection) if (strpos($rel, "\0") !== false) return null; $fsPath = realpath(self::$rootDir . $rel); if (!$fsPath) { // Cache negative results too (404s won't re-stat) if (count($pathCache) < 10000) $pathCache[$urlPath] = null; return null; } $fsPath = str_replace(array('/','\\'), DS, $fsPath); $root = rtrim(self::$rootDir, DS); if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) { $pathCache[$urlPath] = null; return null; // path traversal } $result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null; if (count($pathCache) < 10000) $pathCache[$urlPath] = $result; return $result; } private static function closeClient($key) { if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); unset(self::$clientWatchers[$key]); } if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } if (isset(self::$clients[$key])) { @fclose(self::$clients[$key]); unset(self::$clients[$key]); } unset(self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); } // ── State ──────────────────────────────────────────── private static $socket = null; private static $tlsSocket = null; private static $tlsWatcher = null; private static $tlsPending = array(); private static $httpsPort = 0; static $clients = array(); static $clientWatchers = array(); private static $buffers = array(); private static $clientInfo = array(); // key => [ip, connectTime] private static $keepAliveCount = array(); // key => int private static $timeoutWatchers = array(); // key => evented timer id private static $acceptWatcher = null; private static $running = false; private static $lastStatus = 200; private static $lastBody = ''; static $allowedExtensions = array( 'html','htm','txt','md','json','xml','yaml','yml','csv','tsv','log', 'css','js','mjs','map','wasm', 'png','gif','webp','jpg','jpeg','svg','bmp','ico','avif', 'woff','woff2','ttf','otf', 'mp3','wav','ogg','mp4','webm', 'pdf','zip' ); private static $rateLimitData = array(); // ip => [timestamps] // ── Static file response cache ────────────────────── // Caches full response bytes (headers+body) keyed by fsPath. // Invalidated on mtime change. Saves stat/read/header-build per request. private static $fileCache = array(); // fsPath => [mtime, size, etag, responses => [connType => bytes]] private static $fileCacheSize = 0; // total bytes in cache private static $fileCacheMaxSize = 67108864; // 64MB default, configurable private static $fileCacheMaxFile = 1048576; // don't cache files > 1MB private static $fileCacheCheckInterval = 1; // seconds between mtime checks private static $fileCacheLastCheck = 0; } 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; /** * Set a response header. Wraps PHP's header() and captures it. * Scripts can call either header() directly or Q::header() — both work. * But Q::header() ensures capture in CLI SAPI mode. * @method header * @static * @param {string} $header Full header string e.g. "Content-Type: text/html" * @param {boolean} $replace Replace existing header of same name * @param {integer} $code HTTP status code */ static function header($header, $replace = true, $code = 0) { // Delegate to Q_Response for proper tracking $colonPos = strpos($header, ':'); if ($colonPos !== false) { $name = trim(substr($header, 0, $colonPos)); $value = trim(substr($header, $colonPos + 1)); if (class_exists('Q_Response', false)) { Q_Response::setHeader($name, $value, $replace); } else { // Fallback: direct capture if ($replace) { self::$_responseHeaders[$name] = $value; } elseif (!isset(self::$_responseHeaders[$name])) { self::$_responseHeaders[$name] = $value; } } } if ($code > 0) { self::$_responseCode = $code; if (class_exists('Q_Response', false)) { Q_Response::code($code); } } // Also call native header() (works in non-CLI SAPIs) @header($header, $replace, $code); } /** * Get all captured response headers. * Falls back to headers_list() if available (non-CLI SAPI). * @method getResponseHeaders * @static * @return {array} */ static function getResponseHeaders() { // Try PHP native first (works in non-CLI SAPIs) $native = headers_list(); if (!empty($native)) { $result = array(); foreach ($native as $h) { $p = strpos($h, ':'); if ($p !== false) { $result[trim(substr($h, 0, $p))] = trim(substr($h, $p + 1)); } } return $result; } // CLI SAPI: merge Q_Response headers over Q:: captured headers $headers = self::$_responseHeaders; if (class_exists('Q_Response', false)) { $headers = array_merge($headers, Q_Response::getHeaders()); } return $headers; } /** * Clear captured headers (called between requests). * @method clearResponseHeaders * @static */ static function clearResponseHeaders() { self::$_responseHeaders = array(); self::$_responseCode = 200; } // ── Event system ──────────────────────────────────── /** * Fire an event. Looks for handler functions in handlers/ directory. * * Handler for "MyApp/feed/post" lives at: * handlers/MyApp/feed/post.php * And defines: * function MyApp_feed_post($params) { ... } * * @method event * @static * @param {string} $eventName e.g. "MyApp/feed/post" * @param {array} $params Parameters passed to the handler * @param {string|boolean} $pure false=run handler, 'before'=before hooks only, * 'after'=after hooks only, true=both hooks but skip main handler * @param {boolean} $skipIncludes If true, only call already-defined functions * @param {mixed} &$result Reference for handlers to modify * @return {mixed} Whatever the handler returned */ static function event( $eventName, $params = array(), $pure = false, $skipIncludes = false, &$result = null) { if (!is_string($eventName) || !$eventName) return null; if (!is_array($params)) $params = array(); // Before hooks if ($pure !== 'after') { $handlers = Q_Config::get('Q', 'handlersBeforeEvent', $eventName, array()); if (is_string($handlers)) $handlers = array($handlers); if (is_array($handlers)) { foreach ($handlers as $handler) { $r = self::handle($handler, $params, $skipIncludes, $result); if ($r === false) return $result; } } } // Main handler if (!$pure) { $result = self::handle($eventName, $params, $skipIncludes, $result); } // After hooks if ($pure !== 'before') { $handlers = Q_Config::get('Q', 'handlersAfterEvent', $eventName, array()); if (is_string($handlers)) $handlers = array($handlers); if (is_array($handlers)) { foreach ($handlers as $handler) { $r = self::handle($handler, $params, $skipIncludes, $result); if ($r === false) return $result; } } } return $result; } /** * Check if a handler exists for an event name * @method canHandle * @static * @param {string} $eventName * @return {boolean} */ static function canHandle($eventName) { $parts = explode('/', $eventName); $funcName = str_replace('-', '_', implode('_', $parts)); if (function_exists($funcName)) return true; // Try to load from handlers/ directory $relPath = 'handlers' . DS . implode(DS, $parts) . '.php'; foreach (self::$paths as $base) { $full = $base . DS . $relPath; if (file_exists($full)) { include_once $full; return function_exists($funcName); } } return false; } /** * Execute a handler function. Loads from handlers/ directory if needed. * If $eventName starts with http:// or https://, POSTs params as JSON * to that URL (remote handler / webhook). * @method handle * @static * @param {string} $eventName * @param {array} &$params * @param {boolean} $skipIncludes * @param {mixed} &$result * @return {mixed} */ protected static function handle( $eventName, &$params = array(), $skipIncludes = false, &$result = null) { if (!$eventName) return null; // Remote handler — POST params as JSON to URL if (strncmp($eventName, 'http://', 7) === 0 || strncmp($eventName, 'https://', 8) === 0 ) { return self::handleRemote($eventName, $params, $result); } $parts = explode('/', $eventName); $funcName = str_replace('-', '_', implode('_', $parts)); if (!function_exists($funcName)) { if ($skipIncludes) return null; // Try to load from handlers/ directory $relPath = 'handlers' . DS . implode(DS, $parts) . '.php'; $loaded = false; foreach (self::$paths as $base) { $full = $base . DS . $relPath; if (file_exists($full)) { include_once $full; $loaded = true; break; } } if (!$loaded || !function_exists($funcName)) { return null; // no handler found — that's OK } } $args = array(&$params, &$result); return call_user_func_array($funcName, $args); } /** * POST event params as JSON to a remote URL. * Used for webhook-style handlers configured in Q.handlersAfterEvent. * Non-blocking: uses a short timeout so it doesn't slow down the request. * @method handleRemote * @static * @param {string} $url * @param {array} &$params * @param {mixed} &$result * @return {mixed} */ protected static function handleRemote($url, &$params, &$result) { $json = json_encode($params, JSON_UNESCAPED_SLASHES); $opts = array('http' => array( 'method' => 'POST', 'header' => "Content-Type: application/json\r\n" . "Content-Length: " . strlen($json) . "\r\n" . "User-Agent: QbixServer/1.0\r\n", 'content' => $json, 'timeout' => 5, 'ignore_errors' => true, )); $ctx = stream_context_create($opts); $response = @file_get_contents($url, false, $ctx); if ($response !== false) { $decoded = json_decode($response, true); if ($decoded !== null) { $result = $decoded; } } return $result; } /** * Render a PHP view file. Searches views/ directories in $paths. * * echo Q::view('MyApp/feed/page.php', ['items' => $items]); * * @method view * @static * @param {string} $viewName Path relative to views/ directory * @param {array} $params Variables extracted into the view scope * @return {string} Rendered HTML */ static function view($viewName, $params = array()) { $viewPath = str_replace('/', DS, $viewName); foreach (self::$paths as $base) { $full = $base . DS . 'views' . DS . $viewPath; if (file_exists($full)) { extract($params); ob_start(); include $full; return ob_get_clean(); } } return ""; } // ── Autoloader ────────────────────────────────────── /** * Autoloader that handles both conventions: * Q_WebServer → classes/Q/WebServer.php (underscore) * MyApp\User → classes/MyApp/User.php (namespace) * MyApp_Helper → classes/MyApp/Helper.php (underscore) * * Searches the src/ directory (for Q_ server classes) and all * directories in Q::$paths (for user classes). * * @method autoload * @static * @param {string} $className */ static function autoload($className) { // Split on both \ and _ to get path parts $parts = array(); foreach (explode('\\', $className) as $nsPart) { $parts = array_merge($parts, explode('_', $nsPart)); } $relPath = implode(DS, $parts) . '.php'; // 1. Search src/ directory (for Q_* server classes) $srcPath = dirname(__FILE__) . DS . $relPath; if (file_exists($srcPath)) { require_once $srcPath; return; } // 2. Search project classes/ directories foreach (self::$paths as $base) { $full = $base . DS . 'classes' . DS . $relPath; if (file_exists($full)) { require_once $full; // If loaded via underscore but also accessible via namespace, alias $underscoreName = implode('_', $parts); $namespaceName = implode('\\', $parts); if ($underscoreName !== $namespaceName) { if (class_exists($underscoreName, false) && !class_exists($namespaceName, false) ) { class_alias($underscoreName, $namespaceName); } elseif (class_exists($namespaceName, false) && !class_exists($underscoreName, false) ) { class_alias($namespaceName, $underscoreName); } } return; } } } /** * Initialize Q paths from the project root directory. * Called by the server at startup. * @method init * @static * @param {string} $projectRoot The project root (parent of web/) */ static function init($projectRoot) { $projectRoot = rtrim($projectRoot, DS); if (!in_array($projectRoot, self::$paths)) { self::$paths[] = $projectRoot; } } } spl_autoload_register(array('Q', 'autoload')); // ── Q_Socket ──────────────────────────────────────── /** * PHP API for WebSocket handlers — sending messages, managing rooms. * * Each WebSocket connection gets one PHP process. The server dispatches * messages via Q::event() to handlers. Handlers use Q_Socket to send * data back. Static variables in handlers persist across messages * (same process) and are wiped on disconnect (process dies). * * @class Q_Socket */ class Q_Socket { /** @var resource IPC pipe to parent */ static $_pipe = null; /** @var integer Current client's socket key */ static $_socketId = null; /** @var integer|null Ack ID from current message */ static $_ack = null; /** @var boolean True when running in-process (no fork) */ static $_directMode = false; /** @var array Buffered outbound commands */ static $_buffer = array(); /** * Send data to the client that owns this connection. */ static function reply($data) { self::send(self::$_socketId, $data); } /** * Send data to a specific connected client. */ static function send($socketId, $data) { self::_command(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data)); } /** * Broadcast to all clients in a room/channel. */ static function broadcast($room, $data) { self::_command(array('cmd' => 'broadcast', 'room' => $room, 'data' => $data)); } /** * Broadcast to ALL connected WebSocket clients. */ static function broadcastAll($data) { self::_command(array('cmd' => 'broadcastAll', 'data' => $data)); } /** * Subscribe a client to a room/channel. */ static function join($socketId, $room) { self::_command(array('cmd' => 'join', 'socketId' => $socketId, 'room' => $room)); } /** * Unsubscribe a client from a room/channel. */ static function leave($socketId, $room) { self::_command(array('cmd' => 'leave', 'socketId' => $socketId, 'room' => $room)); } /** * Buffer a command or execute directly in-process. */ private static function _command($cmd) { if (self::$_directMode) { Q_WebSocket::executeCommand($cmd); } else { self::$_buffer[] = $cmd; } } /** * Flush buffered commands to the IPC pipe. * Called automatically after each handler invocation. */ static function flush() { if (!self::$_pipe || empty(self::$_buffer)) return; $out = ''; foreach (self::$_buffer as $cmd) { $out .= json_encode($cmd, JSON_UNESCAPED_SLASHES) . "\n"; } @fwrite(self::$_pipe, $out); self::$_buffer = array(); } } // ── Q_Request ─────────────────────────────────────── /** * Minimal Q_Response — compatible subset of the Qbix Platform's Q_Response. * Manages response headers, status codes, and cookies in CLI SAPI mode * where PHP's header()/setcookie()/headers_list() don't work. * * Use Q::header() for simple cases, or Q_Response methods for full control. * * @class Q_Response */ class Q_Response { /** @var array Response headers: name => value */ protected static $headers = array(); /** @var integer HTTP status code */ protected static $statusCode = 200; /** @var string Status message */ protected static $statusMessage = 'OK'; /** @var array Cookies to set: name => [value, expires, path, domain, secure, httponly, samesite] */ public static $cookies = array(); /** @var array Cookies to remove */ protected static $cookiesToRemove = array(); /** @var string|null Redirect URL if set */ public static $redirected = null; /** * Set a response header. Compatible with Q_Response::setHeader() from the Platform. * @method setHeader * @static * @param {string} $name Header name (e.g. 'Content-Type') * @param {string} $value Header value * @param {boolean} $replace Whether to replace existing header of same name */ static function setHeader($name, $value, $replace = true) { if ($replace || !isset(self::$headers[$name])) { self::$headers[$name] = $value; } // Also store in Q's header capture Q::$_responseHeaders[$name] = $value; // Call native header() for non-CLI SAPIs @header("$name: $value", $replace); } /** * Get a response header that was set. * @method getHeader * @static * @param {string} $name * @return {string|null} */ static function getHeader($name) { return self::$headers[$name] ?? null; } /** * Get all response headers. * @method getHeaders * @static * @return {array} */ static function getHeaders() { return self::$headers; } /** * Set the HTTP response status code. * Compatible with Q_Response::code() from the Platform. * @method code * @static * @param {integer} $code HTTP status code * @param {string} $message Optional status message */ static function code($code, $message = null) { self::$statusCode = (int) $code; if ($message !== null) { self::$statusMessage = $message; } Q::$_responseCode = (int) $code; http_response_code($code); } /** * Get the current status code. * @method getStatusCode * @static * @return {integer} */ static function getStatusCode() { return self::$statusCode; } /** * Set a cookie. Compatible with Q_Response::setCookie() from the Platform. * Prevents duplicate cookies — if the same name+value is already set * and it's a session cookie, skips it. * @method setCookie * @static * @param {string} $name * @param {string} $value * @param {integer} $expires Timestamp, 0 = session cookie * @param {string} $path Cookie path (default: /) * @param {string|null} $domain * @param {boolean} $secure * @param {boolean} $httponly * @param {string|null} $samesite None, Lax, or Strict * @return {string|false} */ static function setCookie( $name, $value, $expires = 0, $path = '/', $domain = null, $secure = false, $httponly = false, $samesite = null ) { // Skip if already set with same value and is a session cookie if (isset($_COOKIE[$name]) && $_COOKIE[$name] === $value && !$expires) { return $value; } self::$cookies[$name] = array($value, $expires, $path, $domain, $secure, $httponly, $samesite); unset(self::$cookiesToRemove[$name]); return $value; } /** * Get the value of a cookie that will be sent, falling back to $_COOKIE. * @method cookie * @static * @param {string} $name * @return {string|null} */ static function cookie($name) { return isset(self::$cookies[$name][0]) ? self::$cookies[$name][0] : ($_COOKIE[$name] ?? null); } /** * Clear a cookie. * @method clearCookie * @static * @param {string} $name * @param {string} $path */ static function clearCookie($name, $path = '/') { self::$cookiesToRemove[$name] = array($path); unset(self::$cookies[$name]); } /** * Set redirect. Compatible with Q_Response::redirect() from the Platform. * @method redirect * @static * @param {string} $url * @param {array} $options * @return {boolean} */ static function redirect($url, $options = array()) { $permanently = !empty($options['permanently']); self::code($permanently ? 301 : 302); self::setHeader('Location', $url); self::$redirected = $url; return true; } /** * Build Set-Cookie header strings from stored cookies. * Called by the server when assembling the response. * @method cookieHeaders * @static * @return {array} Array of Set-Cookie header strings */ static function cookieHeaders() { $headers = array(); // Remove cookies foreach (self::$cookiesToRemove as $name => $args) { $path = $args[0] ?? '/'; $headers[] = "$name=; Path=$path; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; } // Set cookies foreach (self::$cookies as $name => $args) { list($value, $expires, $path, $domain, $secure, $httponly, $samesite) = $args; $parts = array(urlencode($name) . '=' . urlencode($value)); if ($expires) { $parts[] = 'Expires=' . gmdate('D, d M Y H:i:s T', $expires); $parts[] = 'Max-Age=' . max(0, $expires - time()); } $parts[] = 'Path=' . ($path ?: '/'); if ($domain) $parts[] = 'Domain=' . $domain; if ($secure) $parts[] = 'Secure'; if ($httponly) $parts[] = 'HttpOnly'; if ($samesite) $parts[] = 'SameSite=' . $samesite; $headers[] = implode('; ', $parts); } return $headers; } /** * Clear all response state between requests (in-process mode). * @method clear * @static */ static function clear() { self::$headers = array(); self::$statusCode = 200; self::$statusMessage = 'OK'; self::$cookies = array(); self::$cookiesToRemove = array(); self::$redirected = null; } } // ── Q_Request ─────────────────────────────────────── /** * Minimal Q_Request — compatible subset of the Qbix Platform's Q_Request. * Provides convenient access to request data that the server has already parsed. * * @class Q_Request */ class Q_Request { /** * Raw request body. Set by the server before your script runs. * Use this instead of php://input (which doesn't work in our model). * @property $input * @type string * @static */ static $input = ''; /** * Get the HTTP method (GET, POST, PUT, DELETE, etc.) * @method method * @static * @return {string} */ static function method() { return $_SERVER['REQUEST_METHOD'] ?? 'GET'; } /** * Get the raw request body. * @method input * @static * @return {string} */ static function input() { return self::$input; } /** * Get the request body parsed as JSON. * @method json * @static * @param {boolean} $assoc Return associative array (default true) * @return {array|object|null} */ static function json($assoc = true) { return json_decode(self::$input, $assoc); } /** * Get the full request URL. * @method url * @static * @param {boolean} $querystring Include query string (default true) * @return {string} */ static function url($querystring = true) { $scheme = ($_SERVER['REQUEST_SCHEME'] ?? 'http'); $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'; $uri = $querystring ? ($_SERVER['REQUEST_URI'] ?? '/') : ($_SERVER['SCRIPT_NAME'] ?? '/'); return $scheme . '://' . $host . $uri; } /** * Get the URL path (without query string). * @method path * @static * @return {string} */ static function path() { $uri = $_SERVER['REQUEST_URI'] ?? '/'; $qPos = strpos($uri, '?'); return $qPos !== false ? substr($uri, 0, $qPos) : $uri; } /** * Get a request header value. * @method header * @static * @param {string} $name Header name (case-insensitive) * @return {string|null} */ static function header($name) { $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return $_SERVER[$key] ?? null; } /** * Get the client's IP address (resolved through proxy headers by the server). * @method ip * @static * @return {string} */ static function ip() { return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Check if the request is an AJAX/XHR request. * @method isAjax * @static * @return {boolean} */ static function isAjax() { return strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest'; } /** * Get uploaded files. Convenience wrapper around $_FILES. * @method files * @static * @param {string|null} $name Specific file input name, or null for all * @return {array|null} */ static function files($name = null) { if ($name === null) return $_FILES; return $_FILES[$name] ?? null; } /** * Check if running in CLI mode (command line, cron, not via web server). * In Qbix Server, scripts run in CLI SAPI but are dispatched as web * requests. This method returns false for server-dispatched requests * (because $_SERVER['REQUEST_METHOD'] is set) and true for genuine * CLI invocations. * Compatible with Q_Request::isInternal() from the Platform. * @method isInternal * @static * @return {boolean} */ static function isInternal() { // If REQUEST_METHOD is set, we're handling a web request // (even though php_sapi_name() === 'cli') if (!empty($_SERVER['REQUEST_METHOD']) && !empty($_SERVER['REQUEST_URI'])) { return false; } return (php_sapi_name() === 'cli' || defined('STDIN') || !isset($_SERVER['REQUEST_METHOD'])); } /** * Whether the server is running in CLI SAPI. * Always true for Qbix Server (same as FrankenPHP worker mode, Workerman). * Scripts should use isInternal() to check if they're handling a web request. * @method isCli * @static * @return {boolean} */ static function isCli() { return php_sapi_name() === 'cli'; } /** * Get the Content-Type of the request. * @method contentType * @static * @return {string} */ static function contentType() { return $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? ''; } /** * Check if the request body is JSON. * @method isJson * @static * @return {boolean} */ static function isJson() { return strpos(strtolower(self::contentType()), 'application/json') !== false; } /** * Get a value from $_GET, $_POST, or $_REQUEST with a default. * @method special * @static * @param {string} $name * @param {mixed} $default * @return {mixed} */ static function special($name, $default = null) { return $_REQUEST[$name] ?? $default; } } // ── Q_Config ──────────────────────────────────────── /** * JSON config file loader with deep merge. * Compatible with the full Qbix Platform's Q_Config API. * * @class Q_Config */ class Q_Config { private static $data = array(); /** * Load and merge a JSON config file * @method load * @static * @param {string} $path Path to JSON file */ static function load($path) { if (!file_exists($path)) return; $json = json_decode(file_get_contents($path), true); if (is_array($json)) { self::$data = self::merge(self::$data, $json); } } /** * Set a config value programmatically. * Q_Config::set('Q', 'webserver', 'port', 8080) * @method set * @static */ static function set(/* key1, key2, ..., value */) { $args = func_get_args(); $value = array_pop($args); $ref = &self::$data; foreach ($args as $key) { if (!isset($ref[$key]) || !is_array($ref[$key])) { $ref[$key] = array(); } $ref = &$ref[$key]; } $ref = $value; } /** * Get a config value with a default. * Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100) * Last argument is the default. * @method get * @static * @return {mixed} */ static function get(/* key1, key2, ..., default */) { $args = func_get_args(); $default = array_pop($args); $ref = self::$data; foreach ($args as $key) { if (!is_array($ref) || !array_key_exists($key, $ref)) { return $default; } $ref = $ref[$key]; } return $ref; } /** * Get a config value or throw if missing. * Q_Config::expect('Q', 'app') * @method expect * @static * @return {mixed} * @throws {Exception} */ static function expect(/* key1, key2, ... */) { $args = func_get_args(); $ref = self::$data; foreach ($args as $key) { if (!is_array($ref) || !array_key_exists($key, $ref)) { throw new Exception("Missing config: " . implode('.', $args)); } $ref = $ref[$key]; } return $ref; } /** * Get all config data * @method getAll * @static * @return {array} */ static function getAll() { return self::$data; } /** * Deep merge: arrays merge recursively, scalars overwrite. * @method merge * @static * @private */ private static function merge($base, $overlay) { foreach ($overlay as $key => $value) { if (is_array($value) && isset($base[$key]) && is_array($base[$key])) { $base[$key] = self::merge($base[$key], $value); } else { $base[$key] = $value; } } return $base; } } ,©®Î~UÜäô+sµNàIgÿy÷®ÄÙðOÃP´GBMB