#!/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(); ?> sqbixserver.pharQ/FileCache.php× × T1פQ/WebServer/Pool.php^.^.^ýfp¤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.php))âG¹ì¤Q/WebServer/Log.phpK K ý«È¤Q/WebServer/Headers.phpÆ-Æ-Ò€?ò¤Q/WebSocket.php\\bÚ¤ Q/Evented.phpè(¬r¤Q/WebServer.php‘Ï‘ÏÓ³9ƤQ/Snapshot.phpÝÝ“£ëk¤Q/Evented/Driver.php''ûhšÇ¤Q/Evented/StreamSelect.phpÁ¦.¤Q/Evented/Revolt.phpu¬t¤Q.phpl5l5•E¤ [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' )); fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg); } /** * Called when data or EOF arrives from a worker. */ function onWorkerData($index, $sock) { $chunk = @fread($sock, 65536); if ($chunk === false || $chunk === '') { // Worker exited (expected after one request) $this->recycle($index, true); return; } $this->workerBuffers[$index] .= $chunk; $buf = $this->workerBuffers[$index]; if (strlen($buf) < 4) return; $len = unpack('N', substr($buf, 0, 4))[1]; if (strlen($buf) < 4 + $len) return; // Got complete response $json = substr($buf, 4, $len); $response = json_decode($json, true); // Check for cache messages piggybacked on the response if ($response && !empty($response['_cacheMessages'])) { foreach ($response['_cacheMessages'] as $msg) { Q_WebServer_Cache_Components::processChildMessage($msg); } unset($response['_cacheMessages']); } $client = $this->workerClients[$index] ?? null; if ($response && $client && is_resource($client)) { $this->sendHttp($client, $response, $index); } $this->recycle($index, false); } /** * Clean up a finished worker: close socket, reap pid, * fork replacement, process pending queue. */ protected function recycle($index, $isEof) { if (isset($this->watchers[$index])) { Q_Evented::cancel($this->watchers[$index]); unset($this->watchers[$index]); } // EOF with no response → 502 if ($isEof && isset($this->workerClients[$index]) && empty($this->workerBuffers[$index]) ) { $c = $this->workerClients[$index]; if (is_resource($c)) { Q_WebServer::sendResponse($c, 502, 'Worker died'); @fclose($c); } } elseif (isset($this->workerClients[$index])) { @fclose($this->workerClients[$index]); } if (isset($this->workers[$index])) { @fclose($this->workers[$index]['socket']); pcntl_waitpid($this->workers[$index]['pid'], $st, WNOHANG); } unset($this->workers[$index], $this->workerClients[$index], $this->workerBuffers[$index], $this->workerRequestHeaders[$index]); // Immediately fork replacement $newIdx = $this->forkWorker(); // Drain pending queue if (!empty($this->pending)) { $next = array_shift($this->pending); $this->sendTo($newIdx, $next[0], $next[1], $next[2]); } } /** * We need the original request headers for compression * negotiation. Store them alongside the client. * @property $workerRequestHeaders */ protected $workerRequestHeaders = array(); protected function sendHttp($client, $resp, $index) { $reqHeaders = $this->workerRequestHeaders[$index] ?? array(); Q_WebServer_Headers::processResponse($client, $resp, $reqHeaders); } protected function findIdle() { foreach ($this->workers as $i => $w) { if (!$w['busy']) return $i; } return null; } /** * Graceful shutdown: SIGTERM all workers, wait up to $timeout seconds, * then SIGKILL any remaining. * @param {float} $timeout Seconds to wait after SIGTERM before SIGKILL */ function shutdown($timeout = 3.0) { // Cancel watchers and close sockets foreach ($this->workers as $i => $w) { if (isset($this->watchers[$i])) { Q_Evented::cancel($this->watchers[$i]); } @fclose($w['socket']); } // Send SIGTERM to all workers foreach ($this->workers as $w) { posix_kill($w['pid'], SIGTERM); } // Wait for workers to exit gracefully $deadline = microtime(true) + $timeout; $remaining = $this->workers; while (!empty($remaining) && microtime(true) < $deadline) { foreach ($remaining as $i => $w) { $result = pcntl_waitpid($w['pid'], $st, WNOHANG); if ($result > 0 || $result === -1) { unset($remaining[$i]); } } if (!empty($remaining)) { usleep(50000); // 50ms } } // SIGKILL any workers that didn't exit in time foreach ($remaining as $w) { posix_kill($w['pid'], SIGKILL); pcntl_waitpid($w['pid'], $st, 0); } $this->workers = array(); } function idleCount() { $n = 0; foreach ($this->workers as $w) if (!$w['busy']) $n++; return $n; } // ── Wire helpers ───────────────────────────────────── protected static function readExact($sock, $n) { $buf = ''; while (strlen($buf) < $n) { $c = fread($sock, $n - strlen($buf)); if ($c === false || $c === '') return false; $buf .= $c; } return $buf; } protected static function writeMsg($sock, $status, $body, $headers) { $j = json_encode(compact('status', 'body', 'headers')); fwrite($sock, pack('N', strlen($j)) . $j); } } = 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, ); static $recentRequests = array(); static function init() { self::$stats['startTime'] = time(); } static function recordRequest($method, $uri, $status, $ms) { self::$stats['requests']++; if ($status < 300) self::$stats['status2xx']++; elseif ($status < 400) self::$stats['status3xx']++; elseif ($status < 500) self::$stats['status4xx']++; else self::$stats['status5xx']++; $entry = array('time' => date('H:i:s'), 'method' => $method, 'uri' => $uri, 'status' => $status, 'ms' => $ms); self::$recentRequests[] = $entry; if (count(self::$recentRequests) > 200) array_shift(self::$recentRequests); Q_WebSocket::broadcastTo('dashboard', array( 'type' => 'request', 'entry' => $entry, 'stats' => self::getStats() )); } static function getStats() { $up = time() - self::$stats['startTime']; $pool = Q_WebServer::$pool; return array( 'uptime' => self::fmtUp($up), 'uptimeSec' => $up, 'requests' => self::$stats['requests'], 'status2xx' => self::$stats['status2xx'], 'status3xx' => self::$stats['status3xx'], 'status4xx' => self::$stats['status4xx'], 'status5xx' => self::$stats['status5xx'], 'memory' => round(memory_get_usage(true)/1048576, 1), 'memoryPeak' => round(memory_get_peak_usage(true)/1048576, 1), 'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'in-process', 'wsClients' => Q_WebSocket::clientCount(), 'cache' => Q_WebServer_Cache::stats(), 'components' => Q_WebServer_Cache_Components::enabled() ? Q_WebServer_Cache_Components::stats() : null, ); } static function handle($client, $parsed) { $p = $parsed['path']; if ($p === '/Q/dashboard' || $p === '/Q/dashboard/') { Q_WebServer::sendResponse($client, 200, self::renderHtml($parsed), 'text/html; charset=utf-8'); return true; } if ($p === '/Q/stats') { Q_WebServer::sendResponse($client, 200, json_encode(self::getStats()), 'application/json'); return true; } return false; } static function fmtUp($s) { if ($s < 60) return "{$s}s"; if ($s < 3600) return floor($s/60).'m '.($s%60).'s'; return floor($s/3600).'h '.floor(($s%3600)/60).'m'; } static function renderHtml($parsed) { $stats = json_encode(self::getStats()); $recent = json_encode(array_slice(self::$recentRequests, -50)); $host = $parsed['headers']['host'] ?? 'localhost:8080'; $wsUrl = "ws://$host/Q/ws"; return << Qbix Server

Qbix Server

Requests
0
Workers
—
Memory
—
Status
0 ok 0 redir 0 err

Live Requests

connecting
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'; static $reasons = array( 200=>'OK', 201=>'Created', 204=>'No Content', 301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified', 400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden', 404=>'Not Found', 405=>'Method Not Allowed', 413=>'Payload Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway', 503=>'Service Unavailable', ); $reason = $reasons[$status] ?? 'OK'; $out = "HTTP/1.1 $status $reason\r\n"; foreach ($headers as $k => $v) { $out .= "$k: $v\r\n"; } @fwrite($client, $out . "\r\n" . $body); return true; } /** * Serve a file via X-Accel-Redirect, applying compression * if appropriate. Merges headers the PHP script set * (Content-Type, Cache-Control, etc.) with file serving headers. * * @method serveAccelFile * @static */ static function serveAccelFile($client, $fsPath, $phpHeaders, $requestHeaders) { clearstatcache(true, $fsPath); $size = filesize($fsPath); $mtime = filemtime($fsPath); $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // Start with headers from PHP, fill in defaults $headers = $phpHeaders; if (!self::hasHeader($headers, 'Content-Type')) { $headers['Content-Type'] = Q_WebServer::mimeType($ext); } if (!self::hasHeader($headers, 'Cache-Control')) { $headers['Cache-Control'] = 'public, max-age=0, must-revalidate'; } // ETag / Last-Modified $etag = '"' . dechex($mtime) . '-' . dechex($size) . '"'; $headers['ETag'] = $etag; $headers['Last-Modified'] = gmdate('D, d M Y H:i:s', $mtime) . ' GMT'; // Check for pre-compressed version $compressed = self::findPreCompressed($fsPath, $requestHeaders); if ($compressed) { $headers['Content-Encoding'] = $compressed['encoding']; $headers['Content-Length'] = $compressed['size']; $headers['Vary'] = 'Accept-Encoding'; $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; fwrite($client, $out . "\r\n"); $fp = fopen($compressed['path'], 'rb'); while (!feof($fp)) { $data = fread($fp, 65536); if ($data === false || @fwrite($client, $data) === false) break; } fclose($fp); return; } // Check if we should compress on-the-fly $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $shouldCompress = self::shouldCompress($ct, $size, $requestHeaders); if ($shouldCompress && $size < 5242880) { // < 5MB: read + compress + send $body = file_get_contents($fsPath); $body = self::maybeCompress($body, $ct, $requestHeaders, $headers); $headers['Content-Length'] = strlen($body); $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; @fwrite($client, $out . "\r\n" . $body); return; } // No compression — stream directly $headers['Content-Length'] = $size; $headers['Connection'] = 'close'; $out = "HTTP/1.1 200 OK\r\n"; foreach ($headers as $k => $v) $out .= "$k: $v\r\n"; fwrite($client, $out . "\r\n"); $fp = fopen($fsPath, 'rb'); while (!feof($fp)) { $data = fread($fp, 65536); if ($data === false || @fwrite($client, $data) === false) break; } fclose($fp); } /** * Check for pre-compressed .gz or .br sibling files * (like nginx gzip_static / brotli_static). * * @method findPreCompressed * @static * @param {string} $fsPath Original file path * @param {array} $requestHeaders * @return {array|null} [path, encoding, size] or null */ static function findPreCompressed($fsPath, $requestHeaders) { $accept = strtolower($requestHeaders['accept-encoding'] ?? ''); // Prefer brotli over gzip if (strpos($accept, 'br') !== false) { $brPath = $fsPath . '.br'; if (file_exists($brPath)) { clearstatcache(true, $brPath); // Only use if not older than original if (filemtime($brPath) >= filemtime($fsPath)) { return array( 'path' => $brPath, 'encoding' => 'br', 'size' => filesize($brPath) ); } } } if (strpos($accept, 'gzip') !== false) { $gzPath = $fsPath . '.gz'; if (file_exists($gzPath)) { clearstatcache(true, $gzPath); if (filemtime($gzPath) >= filemtime($fsPath)) { return array( 'path' => $gzPath, 'encoding' => 'gzip', 'size' => filesize($gzPath) ); } } } return null; } /** * Maybe compress a response body (gzip on-the-fly). * Modifies $headers by reference to add Content-Encoding + Vary. * * @method maybeCompress * @static * @param {string} $body * @param {string} $contentType * @param {array} $requestHeaders * @param {array} &$headers Response headers (modified) * @return {string} Possibly compressed body */ static function maybeCompress($body, $contentType, $requestHeaders, &$headers) { if (!self::shouldCompress($contentType, strlen($body), $requestHeaders)) { return $body; } $accept = strtolower($requestHeaders['accept-encoding'] ?? ''); // Try gzip (universally supported, no ext needed) if (strpos($accept, 'gzip') !== false && function_exists('gzencode')) { $compressed = gzencode($body, 6); if ($compressed !== false && strlen($compressed) < strlen($body)) { $headers['Content-Encoding'] = 'gzip'; $headers['Vary'] = 'Accept-Encoding'; return $compressed; } } return $body; } /** * Check whether a response should be compressed. * * @method shouldCompress * @static * @param {string} $contentType * @param {integer} $bodySize * @param {array} $requestHeaders * @return {boolean} */ static function shouldCompress($contentType, $bodySize, $requestHeaders) { if ($bodySize < self::$compressMinSize) return false; if (empty($requestHeaders['accept-encoding'])) return false; // Check content type (strip charset parameter) $baseType = strtolower(strtok($contentType, ';')); return in_array($baseType, self::$compressibleTypes); } /** * Resolve an X-Accel-Redirect path to a filesystem path. * * Supports two patterns: * /Q/internal/... → maps to configured internal directory * /absolute/path → maps relative to document root * * Config: Q.webserver.accel.mappings = { "/Q/internal": "/path/on/disk" } * * @method resolveAccelPath * @static * @param {string} $accelPath The X-Accel-Redirect value * @return {string|null} Filesystem path or null */ static function resolveAccelPath($accelPath) { // Check configured mappings first $mappings = Q_Config::get('Q', 'webserver', 'accel', 'mappings', array()); foreach ($mappings as $prefix => $diskPath) { if (strpos($accelPath, $prefix) === 0) { $relative = substr($accelPath, strlen($prefix)); $fsPath = rtrim($diskPath, DS) . DS . ltrim(str_replace('/', DS, $relative), DS); $real = realpath($fsPath); // Ensure we don't escape the mapped directory if ($real && strpos($real, realpath($diskPath)) === 0) { return $real; } return null; } } // Default: resolve relative to APP_DIR (not web root — // the point is to serve files OUTSIDE the web root) if (defined('APP_DIR')) { $fsPath = APP_DIR . DS . ltrim(str_replace('/', DS, $accelPath), DS); $real = realpath($fsPath); if ($real && strpos($real, realpath(APP_DIR)) === 0) { return $real; } } return null; } /** * Strip internal/server-directive headers from a response. * * @method stripInternal * @static * @param {array} $headers * @return {array} Cleaned headers */ static function stripInternal($headers) { $result = array(); foreach ($headers as $k => $v) { if (!in_array(strtolower($k), self::$internalHeaders)) { $result[$k] = $v; } } return $result; } /** * Check if a header exists (case-insensitive). */ static function hasHeader($headers, $name) { $lower = strtolower($name); foreach ($headers as $k => $v) { if (strtolower($k) === $lower) return true; } return false; } } 'update', 'data' => $data)); * Q_WebSocket::broadcastTo('dashboard', array('type' => 'stats')); * * @class Q_WebSocket */ class Q_WebSocket { const GUID = '258EAFA5-E914-47DA-95CA-5AB5DC587B41'; /** * Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage] * @property $clients * @static */ static $clients = array(); /** * Channel → subscriber map. channel => [socketKey => true] * @property $channels * @static */ static $channels = array(); /** * Upgrade an HTTP connection to WebSocket. * Performs the RFC 6455 handshake and registers the socket * with Q_Evented for non-blocking frame reads. * * @method upgrade * @static * @param {resource} $socket The TCP socket (from Q_WebServer) * @param {array} $headers Lowercase HTTP headers from the request * @param {callable|null} [$onMessage=null] function($socketKey, $message) * called when client sends a text frame * @param {string|null} [$channel=null] Auto-subscribe to this channel * @return {boolean} true if upgrade succeeded */ static function upgrade($socket, $headers, $onMessage = null, $channel = null) { $key = $headers['sec-websocket-key'] ?? ''; if (!$key) return false; $accept = base64_encode(sha1($key . self::GUID, true)); $resp = "HTTP/1.1 101 Switching Protocols\r\n" . "Upgrade: websocket\r\n" . "Connection: Upgrade\r\n" . "Sec-WebSocket-Accept: $accept\r\n\r\n"; fwrite($socket, $resp); $sk = (int) $socket; self::$clients[$sk] = array( 'socket' => $socket, 'watcher' => null, 'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage ); self::$clients[$sk]['watcher'] = Q_Evented::onReadable( $socket, function ($s) { Q_WebSocket::onData($s); } ); if ($channel) { self::subscribe($sk, $channel); } return true; } /** * Handle incoming data on a WebSocket connection. * Parses frames, dispatches text messages, handles * ping/pong and close. * * @method onData * @static * @param {resource} $socket */ static function onData($socket) { $sk = (int) $socket; if (!isset(self::$clients[$sk])) return; $data = @fread($socket, 65536); if ($data === false || $data === '') { self::disconnect($sk); return; } self::$clients[$sk]['buffer'] .= $data; while (strlen(self::$clients[$sk]['buffer']) >= 2) { $frame = self::decodeFrame(self::$clients[$sk]['buffer']); if ($frame === null) break; // incomplete self::$clients[$sk]['buffer'] = $frame['remaining']; switch ($frame['opcode']) { case 0x1: // Text frame $cb = self::$clients[$sk]['onMessage']; if ($cb) { $cb($sk, $frame['payload']); } break; case 0x8: // Close self::encodeAndSend($socket, 0x8, ''); self::disconnect($sk); return; case 0x9: // Ping → Pong self::encodeAndSend($socket, 0xA, $frame['payload']); break; case 0xA: // Pong — ignore break; } } } // ── Sending ────────────────────────────────────────── /** * Send a text message to a specific client. * @method send * @static * @param {integer} $socketKey * @param {array|string} $data If array, JSON-encoded */ static function send($socketKey, $data) { if (!isset(self::$clients[$socketKey])) return; $text = is_string($data) ? $data : json_encode($data); self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $text); } /** * Broadcast to ALL connected clients. * @method broadcast * @static * @param {array|string} $data */ static function broadcast($data) { $text = is_string($data) ? $data : json_encode($data); foreach (self::$clients as $sk => $c) { if (is_resource($c['socket'])) { self::encodeAndSend($c['socket'], 0x1, $text); } else { self::disconnect($sk); } } } /** * Broadcast to clients subscribed to a channel. * @method broadcastTo * @static * @param {string} $channel * @param {array|string} $data */ static function broadcastTo($channel, $data) { if (empty(self::$channels[$channel])) return; $text = is_string($data) ? $data : json_encode($data); foreach (self::$channels[$channel] as $sk => $_) { if (!isset(self::$clients[$sk]) || !is_resource(self::$clients[$sk]['socket'])) { unset(self::$channels[$channel][$sk]); continue; } self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $text); } } // ── Channels ───────────────────────────────────────── static function subscribe($socketKey, $channel) { self::$channels[$channel][$socketKey] = true; self::$clients[$socketKey]['channels'][$channel] = true; } static function unsubscribe($socketKey, $channel) { unset(self::$channels[$channel][$socketKey]); unset(self::$clients[$socketKey]['channels'][$channel]); } // ── Connection management ──────────────────────────── static function disconnect($sk) { if (!isset(self::$clients[$sk])) return; $w = self::$clients[$sk]['watcher']; if ($w) Q_Evented::cancel($w); foreach (self::$clients[$sk]['channels'] as $ch => $_) { unset(self::$channels[$ch][$sk]); } @fclose(self::$clients[$sk]['socket']); unset(self::$clients[$sk]); } static function disconnectAll() { foreach (array_keys(self::$clients) as $sk) { self::disconnect($sk); } } static function clientCount() { return count(self::$clients); } // ── RFC 6455 frame encoding/decoding ───────────────── /** * Decode one frame from a buffer. * @return {array|null} [opcode, payload, remaining] or null if incomplete */ static function decodeFrame(&$buf) { $len = strlen($buf); if ($len < 2) return null; $b0 = ord($buf[0]); $b1 = ord($buf[1]); $opcode = $b0 & 0x0F; $masked = ($b1 & 0x80) !== 0; $payloadLen = $b1 & 0x7F; $offset = 2; if ($payloadLen === 126) { if ($len < 4) return null; $payloadLen = unpack('n', substr($buf, 2, 2))[1]; $offset = 4; } elseif ($payloadLen === 127) { if ($len < 10) return null; $payloadLen = unpack('J', substr($buf, 2, 8))[1]; $offset = 10; } $totalNeeded = $offset + ($masked ? 4 : 0) + $payloadLen; if ($len < $totalNeeded) return null; if ($masked) { $mask = substr($buf, $offset, 4); $offset += 4; $payload = substr($buf, $offset, $payloadLen); for ($i = 0; $i < $payloadLen; $i++) { $payload[$i] = chr(ord($payload[$i]) ^ ord($mask[$i % 4])); } } else { $payload = substr($buf, $offset, $payloadLen); } return array( 'opcode' => $opcode, 'payload' => $payload, 'remaining' => substr($buf, $offset + $payloadLen) ); } /** * Encode and send a frame (server→client, unmasked). */ static function encodeAndSend($socket, $opcode, $payload) { $len = strlen($payload); $frame = chr(0x80 | $opcode); if ($len < 126) { $frame .= chr($len); } elseif ($len < 65536) { $frame .= chr(126) . pack('n', $len); } else { $frame .= chr(127) . pack('J', $len); } $frame .= $payload; @fwrite($socket, $frame); } } onReadable($stream, $cb); } static function onWritable($stream, callable $cb) { return self::driver()->onWritable($stream, $cb); } static function delay($sec, callable $cb) { return self::driver()->delay($sec, $cb); } static function repeat($sec, callable $cb) { return self::driver()->repeat($sec, $cb); } static function defer(callable $cb) { return self::driver()->defer($cb); } static function onSignal($sig, callable $cb) { return self::driver()->onSignal($sig, $cb); } static function cancel($id) { self::driver()->cancel($id); } static function disable($id) { self::driver()->disable($id); } static function enable($id) { self::driver()->enable($id); } static function run() { self::driver()->run(); } static function tick($timeout = 0) { self::driver()->tick($timeout); } static function stop() { self::driver()->stop(); } static function running() { return self::driver()->running(); } static function driver() { if (!self::$driver) { self::$driver = class_exists('Revolt\\EventLoop') ? new Q_Evented_Revolt() : new Q_Evented_StreamSelect(); } return self::$driver; } static function setDriver(Q_Evented_Driver $d) { self::$driver = $d; } protected static $driver = null; } 0 && function_exists('pcntl_fork')) { self::$pool = new Q_WebServer_Pool($workers); } self::$running = true; } /** * Start or restart the TLS listener. * Uses tcp:// + stream_socket_enable_crypto() for non-blocking * TLS handshake. The handshake happens per-connection in the * event loop, not during accept. * * @method startTls * @static */ static function startTls($host, $port) { if (self::$tlsWatcher) { Q_Evented::cancel(self::$tlsWatcher); self::$tlsWatcher = null; } if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; } if (!Q_WebServer_Certs::validateCerts()) return; // Listen on plain tcp:// — TLS handshake happens after accept $errno = $errstr = 0; self::$tlsSocket = stream_socket_server( "tcp://{$host}:{$port}", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN ); if (!self::$tlsSocket) { echo "[HTTPS] Could not bind to {$host}:{$port} — $errstr\n"; return; } stream_set_blocking(self::$tlsSocket, false); self::$tlsWatcher = Q_Evented::onReadable( self::$tlsSocket, function ($sock) { Q_WebServer::onAcceptTls($sock); } ); echo "[HTTPS] Listening on https://{$host}:{$port}\n"; } /** * Accept a connection on the TLS port and begin * non-blocking crypto handshake. * * @method onAcceptTls * @static */ static function onAcceptTls($serverSocket) { $client = @stream_socket_accept($serverSocket, 0); if (!$client) return; stream_set_blocking($client, false); // Set SSL context options on this specific socket $certPath = Q_WebServer_Certs::$certPath; $keyPath = Q_WebServer_Certs::$keyPath; stream_context_set_option($client, 'ssl', 'local_cert', $certPath); stream_context_set_option($client, 'ssl', 'local_pk', $keyPath); stream_context_set_option($client, 'ssl', 'allow_self_signed', true); stream_context_set_option($client, 'ssl', 'verify_peer', false); $key = (int) $client; self::$clients[$key] = $client; self::$buffers[$key] = ''; self::$tlsPending[$key] = true; // Start the handshake — may need multiple attempts self::continueTlsHandshake($key); } /** * Continue a non-blocking TLS handshake. * stream_socket_enable_crypto() returns: * true → handshake complete * false → handshake failed * 0 → handshake in progress, try again * * @method continueTlsHandshake * @static */ static function continueTlsHandshake($key) { if (!isset(self::$clients[$key])) return; $client = self::$clients[$key]; $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) { $cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_SERVER; } $result = @stream_socket_enable_crypto($client, true, $cryptoMethod); if ($result === true) { // Handshake complete — treat like a normal client unset(self::$tlsPending[$key]); self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { Q_WebServer::onClientData($c); } ); } elseif ($result === 0) { // In progress — watch for readability to retry self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { $k = (int) $c; // Cancel this watcher and retry handshake if (isset(Q_WebServer::$clientWatchers[$k])) { Q_Evented::cancel(Q_WebServer::$clientWatchers[$k]); unset(Q_WebServer::$clientWatchers[$k]); } Q_WebServer::continueTlsHandshake($k); } ); } else { // Failed self::closeClient($key); } } /** * Reload TLS after cert renewal. Called by Q_WebServer_Certs. * New connections will use the new certs. Existing connections * keep their old certs until they close (normal behavior). * * @method reloadTls * @static */ static function reloadTls() { if (self::$httpsPort) { // No need to restart the listener — we set SSL context // per-connection in onAcceptTls, so new connections // will pick up the new cert files automatically. echo "[HTTPS] Certificates reloaded for new connections.\n"; } } /** * Graceful shutdown: stop accepting new connections, * wait for in-flight requests to complete (up to timeout), * then close everything. * @method stop * @static * @param {float} $drainTimeout Max seconds to wait for in-flight requests */ static function stop($drainTimeout = 5.0) { if (!self::$running) return; self::$running = false; // 1. Stop accepting new connections if (self::$acceptWatcher) { Q_Evented::cancel(self::$acceptWatcher); self::$acceptWatcher = null; } if (self::$tlsWatcher) { Q_Evented::cancel(self::$tlsWatcher); self::$tlsWatcher = null; } if (self::$socket) { @fclose(self::$socket); self::$socket = null; } if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; } // 2. Wait for in-flight connections to drain (up to timeout) $deadline = microtime(true) + $drainTimeout; while (!empty(self::$clients) && microtime(true) < $deadline) { Q_Evented::tick(0.1); // process pending I/O briefly } // 3. Force-close remaining connections foreach (self::$timeoutWatchers as $id) Q_Evented::cancel($id); self::$timeoutWatchers = array(); foreach (self::$clientWatchers as $id) Q_Evented::cancel($id); self::$clientWatchers = array(); foreach (self::$clients as $c) @fclose($c); self::$clients = array(); self::$buffers = array(); self::$clientInfo = array(); self::$keepAliveCount = array(); // 4. Disconnect WebSockets Q_WebSocket::disconnectAll(); // 5. Gracefully shut down worker pool (SIGTERM → wait → SIGKILL) if (self::$pool) { self::$pool->shutdown(); self::$pool = null; } } static function run() { if (!self::$running) return; if (function_exists('pcntl_signal')) { Q_Evented::onSignal(SIGINT, function () { echo "\n Graceful shutdown (SIGINT)...\n"; self::stop(); Q_Evented::stop(); }); Q_Evented::onSignal(SIGTERM, function () { echo "\n Graceful shutdown (SIGTERM)...\n"; self::stop(); Q_Evented::stop(); }); } Q_Evented::run(); } // ── Connection handling ────────────────────────────── static function onAccept($socket) { // Max connections check $maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024); if (count(self::$clients) >= $maxConn) { $reject = @stream_socket_accept($socket, 0); if ($reject) { @fwrite($reject, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($reject); } return; } $client = @stream_socket_accept($socket, 0); if (!$client) return; stream_set_blocking($client, false); // Disable Nagle's algorithm — eliminates 40ms delayed ACK on keep-alive if (function_exists('socket_import_stream')) { $rawSocket = socket_import_stream($client); if ($rawSocket) { socket_set_option($rawSocket, SOL_TCP, TCP_NODELAY, 1); } } $key = (int) $client; self::$clients[$key] = $client; self::$buffers[$key] = ''; self::$keepAliveCount[$key] = 0; // Store remote IP for logging + proxy resolution $peer = stream_socket_get_name($client, true); $ip = $peer ? explode(':', $peer)[0] : '0.0.0.0'; self::$clientInfo[$key] = array( 'ip' => $ip, 'connectTime' => microtime(true) ); // Rate limit check if (!self::checkRateLimit($ip)) { @fwrite($client, "HTTP/1.1 429 Too Many Requests\r\n" . "Retry-After: 60\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); @fclose($client); unset(self::$clients[$key], self::$buffers[$key], self::$keepAliveCount[$key], self::$clientInfo[$key]); return; } self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { Q_WebServer::onClientData($c); } ); // Read timeout — close if no complete request within N seconds $readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30); self::$timeoutWatchers[$key] = Q_Evented::delay($readTimeout, function () use ($key) { Q_WebServer::closeClient($key); }); } static function onClientData($client) { $key = (int) $client; if (!isset(self::$clients[$key])) return; // Check if we already have a complete request from pipelining $buf = self::$buffers[$key] ?? ''; $havePipelined = ($buf !== '' && strpos($buf, "\r\n\r\n") !== false); if (!$havePipelined) { $chunk = @fread($client, 65536); if ($chunk === false || $chunk === '') { self::closeClient($key); return; } self::$buffers[$key] .= $chunk; $buf = self::$buffers[$key]; } // Wait for complete headers $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd === false) { if (strlen($buf) > 65536) self::closeClient($key); return; } // Wait for complete body on POST/PUT/PATCH $firstChar = $buf[0]; if ($firstChar === 'P') { // POST, PUT, PATCH all start with P $cl = 0; if (preg_match('/content-length:\s*(\d+)/i', $buf, $m)) { $cl = (int) $m[1]; } if ($cl > 10485760) { self::sendResponse($client, 413, 'Payload Too Large'); self::closeClient($key); return; } if (strlen($buf) - $headerEnd - 4 < $cl) return; } // Cancel read timeout (request received) if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } // Calculate consumed bytes for pipelining support $headerEnd = strpos($buf, "\r\n\r\n"); $bodyLen = 0; $firstChar = $buf[0]; if ($firstChar === 'P') { // POST/PUT/PATCH if (preg_match('/content-length:\s*(\d+)/i', $buf, $clm)) { $bodyLen = (int) $clm[1]; } } $consumed = $headerEnd + 4 + $bodyLen; $start = microtime(true); $parsed = self::parseRequest($buf); // Reject malformed request lines if (!empty($parsed['_malformed'])) { self::sendResponse($client, 400, 'Bad Request'); self::closeClient($key); return; } // Reject oversized headers (>64KB total) $headerEnd = strpos($buf, "\r\n\r\n"); if ($headerEnd > 65536) { self::sendResponse($client, 431, 'Request Header Fields Too Large', 'text/plain; charset=utf-8', array('Connection' => 'close')); self::closeClient($key); return; } // Resolve proxy headers for real client IP $directIp = self::$clientInfo[$key]['ip'] ?? '0.0.0.0'; $parsed['clientIp'] = Q_WebServer_Proxy::clientIp($directIp, $parsed['headers']); // Determine keep-alive before handling request $maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100); $connHeader = strtolower($parsed['headers']['connection'] ?? 'keep-alive'); self::$keepAliveCount[$key] = (self::$keepAliveCount[$key] ?? 0) + 1; $parsed['_keepAlive'] = ($connHeader !== 'close') && self::$keepAliveCount[$key] < $maxKeepAlive; try { $keepOpen = self::handleRequest($client, $parsed); } catch (\Throwable $e) { // Never let a request crash the event loop $msg = htmlspecialchars($e->getMessage()); self::sendResponse($client, 500, "Internal Server Error: $msg"); self::closeClient($key); $ms = round((microtime(true) - $start) * 1000, 1); Q_WebServer_Dashboard::recordRequest( $parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms ); if (self::$onRequest) { (self::$onRequest)($parsed['method'] ?? 'GET', $parsed['uri'] ?? '/', 500, $ms); } return; } $ms = round((microtime(true) - $start) * 1000, 1); if ($keepOpen) { // WebSocket upgraded — Q_WebSocket owns this socket now if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); return; } // Stats + logging Q_WebServer_Dashboard::recordRequest( $parsed['method'], $parsed['uri'], self::$lastStatus, $ms ); if (self::$onRequest) { (self::$onRequest)($parsed['method'], $parsed['uri'], self::$lastStatus, $ms); } // Log to file $bodyLen = strlen(self::$lastBody ?? ''); Q_WebServer_Log::access( $parsed['clientIp'], $parsed['method'], $parsed['uri'], self::$lastStatus, $bodyLen, $parsed['headers']['referer'] ?? '', $parsed['headers']['user-agent'] ?? '', $ms ); // ── Keep-alive decision ────────────────────────── $keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15); $shouldKeepAlive = !empty($parsed['_keepAlive']) && self::$lastStatus < 500; if ($shouldKeepAlive) { // Keep leftover data for pipelined requests $leftover = strlen($buf) > $consumed ? substr($buf, $consumed) : ''; self::$buffers[$key] = $leftover; // Set idle timeout — close if no new request arrives self::$timeoutWatchers[$key] = Q_Evented::delay( $keepAliveTimeout, function () use ($key) { Q_WebServer::closeClient($key); } ); // If there's already a complete request in the buffer, process it now if ($leftover !== '' && strpos($leftover, "\r\n\r\n") !== false) { Q_Evented::defer(function () use ($client) { Q_WebServer::onClientData($client); }); } } else { self::closeClient($key); } } // ── Request routing ────────────────────────────────── /** * Route a parsed request and return a response array. * * This is the clean interface that external HTTP drivers * (like amphp/http-server) call. Handles all routing: * blocked paths, static files, PHP dispatch, directory * listings, X-Accel-Redirect, compression. * * The built-in server uses handleRequest() which writes * directly to sockets. amphp calls route() and converts * the response to its own format. * * @method route * @static * @param {array} $parsed [method, uri, path, query, headers, body, clientIp] * @return {array} [status, headers, body] */ static function route($parsed) { $method = $parsed['method']; $path = $parsed['path']; // Reverse cache check (before any dispatch) $cached = Q_WebServer_Cache::get($parsed); if ($cached) return $cached; if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); return array('status'=>200, 'body'=>json_encode(array('status'=>'ok')+$stats), 'headers'=>array('Content-Type'=>'application/json')); } if ($path === '/Q/dashboard' || $path === '/Q/dashboard/') { return array('status'=>200, 'body'=>Q_WebServer_Dashboard::renderHtml($parsed), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } if (self::isBlocked($path)) { return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } $fsPath = self::resolveStatic($path); // Directory if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { return array('status'=>301, 'body'=>'', 'headers'=>array('Location'=>$path.'/')); } foreach (array('index.html','index.php') as $idx) { $ip = $fsPath.DS.$idx; if (is_file($ip)) { $fsPath = $ip; break; } } if (is_dir($fsPath)) { if (self::isIndexed($path)) { return array('status'=>200, 'body'=>self::renderDirectoryListing($fsPath, $path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8', 'Cache-Control'=>'no-store')); } return array('status'=>403, 'body'=>'Forbidden', 'headers'=>array('Content-Type'=>'text/plain')); } } // File if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); if ($ext === 'php') { // PHP dispatch (in-process — amphp uses fibers for concurrency) $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } if (in_array($ext, self::$allowedExtensions) && ($method === 'GET' || $method === 'HEAD') ) { return self::buildFileResponse($fsPath, $ext, $method, $parsed['headers']); } } // Clean URL → index.php if (is_file(self::$rootDir . 'index.php')) { $response = self::dispatchToQ($parsed); $response = self::processPhpResponse($response, $parsed['headers']); Q_WebServer_Cache::put($parsed, $response); return $response; } return array('status'=>404, 'body'=>self::render404($path), 'headers'=>array('Content-Type'=>'text/html; charset=utf-8')); } /** * Process a PHP response: X-Accel-Redirect + compression. * Used by both route() and handlePhp(). */ static function processPhpResponse($response, $reqHeaders) { $headers = Q_WebServer_Headers::stripInternal($response['headers'] ?? array()); $body = $response['body'] ?? ''; // X-Accel-Redirect foreach ($response['headers'] ?? array() as $k => $v) { if (strtolower($k) === 'x-accel-redirect') { $af = Q_WebServer_Headers::resolveAccelPath($v); if ($af && is_file($af)) { $body = file_get_contents($af); $ext = strtolower(pathinfo($af, PATHINFO_EXTENSION)); if (!Q_WebServer_Headers::hasHeader($headers, 'Content-Type')) { $headers['Content-Type'] = self::mimeType($ext); } } $headers = Q_WebServer_Headers::stripInternal($headers); break; } } $ct = ''; foreach ($headers as $k => $v) { if (strtolower($k) === 'content-type') $ct = $v; } $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); return array('status'=>$response['status']??200, 'body'=>$body, 'headers'=>$headers); } /** * Build a static file response with ETag/compression. * Used by route() for amphp compatibility. */ static function buildFileResponse($fsPath, $ext, $method, $reqHeaders) { clearstatcache(true, $fsPath); $mtime = filemtime($fsPath); $size = filesize($fsPath); $ct = self::mimeType($ext); $headers = array( 'Content-Type' => $ct, 'ETag' => '"' . dechex($mtime) . '-' . dechex($size) . '"', 'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT', 'Cache-Control' => 'public, max-age=0, must-revalidate' ); $body = ($method === 'HEAD') ? '' : file_get_contents($fsPath); if ($method !== 'HEAD') { $body = Q_WebServer_Headers::maybeCompress($body, $ct, $reqHeaders, $headers); } return array('status'=>200, 'body'=>$body, 'headers'=>$headers); } // ── Built-in server: socket-based routing ──────────── /** * Route a request (built-in server). * Returns true if the connection should stay open (WebSocket). * @return {boolean} */ private static function handleRequest($client, $parsed) { $method = $parsed['method']; $path = $parsed['path']; // 1. Dashboard + Panel + WebSocket + Health (/Q/*) if (strpos($path, '/Q/') === 0) { if ($path === '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], null, 'dashboard' ); return $upgraded; // true = keep open } if ($path === '/Q/health') { $stats = Q_WebServer_Dashboard::getStats(); self::sendResponse($client, 200, json_encode(array('status' => 'ok') + $stats), 'application/json'); return false; } // Panel (control panel + API) $handled = Q_WebServer_Panel::handle($client, $parsed); if ($handled) return false; // Dashboard (live stats) $handled = Q_WebServer_Dashboard::handle($client, $parsed); if ($handled) return false; } // 2. Blocked paths if (self::isBlocked($path)) { self::sendResponse($client, 403, 'Forbidden'); return false; } // 3. Component cache check (Merkle tree — serves from cached slots) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); $cachedPage = Q_WebServer_Cache_Components::getPage($pageKey); if ($cachedPage !== null) { self::sendResponse($client, 200, $cachedPage, 'text/html; charset=utf-8', array('X-Cache' => 'HIT-COMPONENTS')); return false; } } // 4. Reverse cache check (before forking a worker) $cached = Q_WebServer_Cache::get($parsed); if ($cached) { self::sendResponse($client, $cached['status'], $cached['body'], $cached['headers']['Content-Type'] ?? 'text/html', $cached['headers']); return false; } // 4. Resolve filesystem path $fsPath = self::resolveStatic($path); // 4. Directory handling if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { self::sendRedirect($client, $path . '/'); return false; } // Check for index files foreach (array('index.html', 'index.php') as $idx) { $indexPath = $fsPath . DS . $idx; if (is_file($indexPath)) { $fsPath = $indexPath; break; } } if (is_dir($fsPath)) { // No index file → check if listings are enabled for this path if (self::isIndexed($path)) { $html = self::renderDirectoryListing($fsPath, $path); self::sendResponse($client, 200, $html, 'text/html; charset=utf-8', array('Cache-Control' => 'no-store')); } else { self::sendResponse($client, 403, 'Forbidden'); } return false; } } // 5. File handling if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // PHP scripts → worker pool or in-process if ($ext === 'php') { return self::handlePhp($client, $parsed, $fsPath); } // Static file if ($method === 'GET' || $method === 'HEAD') { self::serveStaticFile($client, $fsPath, $method, $parsed['headers'], !empty($parsed['_keepAlive'])); return false; } } // 6. Clean URL → route through index.php $indexPhp = self::$rootDir . 'index.php'; if (is_file($indexPhp)) { return self::handlePhp($client, $parsed, $indexPhp); } // 7. Not found self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8'); return false; } /** * Route a .php script to the worker pool or dispatch in-process. * @return {boolean} false (connection closes after response) */ private static function handlePhp($client, $parsed, $scriptPath) { if (self::$pool) { self::$lastStatus = 200; self::$pool->dispatch($client, $parsed, $scriptPath); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); return false; } // In-process: run through Headers for X-Accel-Redirect + compression $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $response['status'] ?? 200; // Store in cache if cacheable Q_WebServer_Cache::put($parsed, $response); return false; } // ── Static file serving ────────────────────────────── private static function serveStaticFile($client, $fsPath, $method, $reqHeaders, $keepAlive = false) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); if (!in_array($ext, self::$allowedExtensions)) { self::sendResponse($client, 403, 'Forbidden'); return; } $connKey = $keepAlive ? 'ka' : 'cl'; $now = microtime(true); // ── Try response cache ── if (isset(self::$fileCache[$fsPath])) { $cached = &self::$fileCache[$fsPath]; // Revalidate mtime periodically if (($now - $cached['checked']) >= self::$fileCacheCheckInterval) { clearstatcache(true, $fsPath); if (filemtime($fsPath) !== $cached['mtime']) { self::$fileCacheSize -= $cached['bodyLen'] * 2; unset(self::$fileCache[$fsPath]); } else { $cached['checked'] = $now; } } } if (isset(self::$fileCache[$fsPath])) { $cached = &self::$fileCache[$fsPath]; $etag = $cached['etag']; // 304 against cached etag if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) { self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive); return; } if (isset($reqHeaders['if-modified-since'])) { $since = strtotime($reqHeaders['if-modified-since']); if ($since !== false && $cached['mtime'] <= $since) { self::sendNotModified($client, $etag, $cached['mtime'], $keepAlive); return; } } // Serve from cache — single fwrite self::$lastStatus = 200; if ($method === 'HEAD') { @fwrite($client, $cached['head'][$connKey]); } else { @fwrite($client, $cached['full'][$connKey]); } return; } // ── Cache miss — build from disk ── clearstatcache(true, $fsPath); $mtime = filemtime($fsPath); $size = filesize($fsPath); $etag = '"' . dechex($mtime) . '-' . dechex($size) . '"'; // 304 Not Modified if (isset($reqHeaders['if-none-match']) && trim($reqHeaders['if-none-match']) === $etag) { self::sendNotModified($client, $etag, $mtime, $keepAlive); return; } if (isset($reqHeaders['if-modified-since'])) { $since = strtotime($reqHeaders['if-modified-since']); if ($since !== false && $mtime <= $since) { self::sendNotModified($client, $etag, $mtime, $keepAlive); return; } } $contentType = self::mimeType($ext); $baseHeaders = "Content-Type: $contentType\r\n" . "ETag: $etag\r\n" . "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n" . "Cache-Control: public, max-age=0, must-revalidate\r\n"; // Companion .headers file $hf = $fsPath . '.headers'; if (file_exists($hf)) { foreach (file($hf, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { if ($line[0] === '#' || strpos($line, ':') === false) continue; $baseHeaders .= trim($line) . "\r\n"; } } $connHeader = $keepAlive ? 'keep-alive' : 'close'; // Pre-compressed siblings — not cached (different per Accept-Encoding) $preComp = Q_WebServer_Headers::findPreCompressed($fsPath, $reqHeaders); if ($preComp) { $out = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Encoding: " . $preComp['encoding'] . "\r\n" . "Content-Length: " . $preComp['size'] . "\r\n" . "Vary: Accept-Encoding\r\n" . "Connection: $connHeader\r\n\r\n"; self::$lastStatus = 200; @fwrite($client, $method === 'HEAD' ? $out : $out . file_get_contents($preComp['path'])); return; } // On-the-fly gzip — not cached (different per Accept-Encoding) if ($size < 5242880) { $gzHeaders = array(); if (Q_WebServer_Headers::shouldCompress($contentType, $size, $reqHeaders)) { $body = file_get_contents($fsPath); $body = Q_WebServer_Headers::maybeCompress($body, $contentType, $reqHeaders, $gzHeaders); $out = "HTTP/1.1 200 OK\r\n" . $baseHeaders; foreach ($gzHeaders as $k => $v) $out .= "$k: $v\r\n"; $out .= "Content-Length: " . strlen($body) . "\r\n" . "Connection: $connHeader\r\n\r\n"; self::$lastStatus = 200; @fwrite($client, $method === 'HEAD' ? $out : $out . $body); return; } } // ── Uncompressed — serve and cache ── $body = file_get_contents($fsPath); $kaHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Length: $size\r\nConnection: keep-alive\r\n\r\n"; $clHead = "HTTP/1.1 200 OK\r\n" . $baseHeaders . "Content-Length: $size\r\nConnection: close\r\n\r\n"; self::$lastStatus = 200; $headStr = $keepAlive ? $kaHead : $clHead; @fwrite($client, $method === 'HEAD' ? $headStr : $headStr . $body); // Cache if small enough if ($size <= self::$fileCacheMaxFile && self::$fileCacheSize + $size * 2 < self::$fileCacheMaxSize ) { self::$fileCache[$fsPath] = array( 'mtime' => $mtime, 'bodyLen' => $size, 'etag' => $etag, 'checked' => $now, 'head' => array('ka' => $kaHead, 'cl' => $clHead), 'full' => array('ka' => $kaHead . $body, 'cl' => $clHead . $body), ); self::$fileCacheSize += $size * 2; // Evict oldest if over limit while (self::$fileCacheSize > self::$fileCacheMaxSize && self::$fileCache) { $evict = array_key_first(self::$fileCache); self::$fileCacheSize -= self::$fileCache[$evict]['bodyLen'] * 2; unset(self::$fileCache[$evict]); } } } // ── Privacy / access control ───────────────────────── /** * Check if a URL path is blocked entirely (403 Forbidden). * * These paths cannot be accessed by any URL. They contain * server-side code, config, and internal data. * * Blocked: /config/, /classes/, /handlers/, /scripts/ * Also: dotfiles/dotdirs (except /.well-known/) * Also: paths in Q.web.blocked.paths config * * For true access control on files, use X-Accel-Redirect * (PHP checks permissions, server does file I/O). * * @method isBlocked * @static * @param {string} $urlPath * @return {boolean} */ static function isBlocked($urlPath) { // Core blocked directories (server internals) $blocked = array('/config/', '/classes/', '/handlers/', '/scripts/'); foreach ($blocked as $prefix) { if (strpos($urlPath, $prefix) === 0) return true; } // Dotfiles/dotdirs (except /.well-known/) if (preg_match('#/\.(?!well-known)#', $urlPath)) return true; // Config-based blocked paths $blockedPaths = Q_Config::get('Q', 'web', 'blocked', 'paths', array()); foreach ($blockedPaths as $pp => $v) { if ($v && strpos($urlPath, '/' . ltrim($pp, '/')) === 0) return true; } return false; } /** * Check if a URL path allows directory listing. * * Directory listings are OFF by default (more secure). * Only paths matching regexes in * Q.web.indexed.paths get listings. Default: /img/. * * Config: * "Q": { "web": { "indexed": { "paths": { * "#^/img/#": true, * "#^/downloads/#": true * }}}} * * For actual access control, use X-Accel-Redirect. * * @method isIndexed * @static * @param {string} $urlPath * @return {boolean} */ static function isIndexed($urlPath) { $patterns = Q_Config::get('Q', 'web', 'indexed', 'paths', array( '#^/img/#' => true )); foreach ($patterns as $regex => $enabled) { if ($enabled && preg_match($regex, $urlPath)) return true; } return false; // not indexed by default } // ── Directory listing ──────────────────────────────── /** * Render a responsive directory listing with media previews. * Only called for paths that pass isIndexed(). * Dotfiles are always hidden from listings. * * @method renderDirectoryListing * @static * @param {string} $dir Filesystem path * @param {string} $urlPath URL path * @return {string} HTML */ static function renderDirectoryListing($dir, $urlPath) { $maxImages = (int) Q_Config::get('Q', 'webserver', 'listing', 'images', 'max', 100); $items = scandir($dir); $dirs = array(); $files = array(); $media = array(); $imageExts = array('png','jpg','jpeg','gif','webp','svg'); $videoExts = array('mp4','webm','ogg'); $audioExts = array('mp3','wav','ogg'); foreach ($items as $name) { if ($name === '.' || $name === '..') continue; if ($name[0] === '.') continue; // dotfiles always hidden $full = $dir . DS . $name; $href = htmlspecialchars($urlPath . $name, ENT_QUOTES); $safe = htmlspecialchars($name, ENT_QUOTES); if (is_dir($full)) { $dirs[] = "ðŸ“{$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); $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SCRIPT_NAME'] = '/' . basename($parsed['_scriptPath'] ?? 'index.php'); $_SERVER['SCRIPT_FILENAME'] = $parsed['_scriptPath'] ?? self::$rootDir . 'index.php'; $host = $parsed['headers']['host'] ?? 'localhost'; $_SERVER['SERVER_NAME'] = explode(':', $host)[0]; // strip port from Host header $_SERVER['SERVER_PORT'] = self::$port; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } if (isset($parsed['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $parsed['headers']['content-type']; if (isset($parsed['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $parsed['headers']['content-length']; $_GET = $_POST = $_REQUEST = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($_SERVER['CONTENT_TYPE'] ?? ''); if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($parsed['body'], $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($parsed['body'], true) ?: array(); } $_REQUEST = array_merge($_GET, $_POST); ob_start(); $status = 200; $headers = array(); try { if (class_exists('Q_Dispatcher', false)) { // Full Qbix Platform mode Q_Dispatcher::dispatch(); } else { // Standalone mode — execute PHP script directly $scriptPath = $parsed['_scriptPath'] ?? $_SERVER['SCRIPT_FILENAME']; if (is_file($scriptPath)) { include $scriptPath; } else { $status = 404; echo 'Not Found'; } } foreach (headers_list() as $h) { if (strpos($h, ':') !== false) { list($k, $v) = explode(':', $h, 2); $headers[trim($k)] = trim($v); } } $code = http_response_code(); if ($code) $status = $code; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST) = $saved; // Process Merkle cache headers (strips X-Q-Cache-* from response) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); Q_WebServer_Cache_Components::processResponseHeaders($pageKey, $headers); } return compact('status', 'body', 'headers'); } // ── Request parsing ────────────────────────────────── static function parseRequest($raw) { $headerEnd = strpos($raw, "\r\n\r\n"); $headerBlock = substr($raw, 0, $headerEnd); $body = substr($raw, $headerEnd + 4); // Fast request line parse $rlEnd = strpos($headerBlock, "\r\n"); $requestLine = $rlEnd !== false ? substr($headerBlock, 0, $rlEnd) : $headerBlock; if (!preg_match('#^(\w+)\s+([^\s]+)\s+HTTP/(\d\.\d)#', $requestLine, $m)) { return array( 'method' => 'GET', 'uri' => '/', 'path' => '/', 'query' => '', 'headers' => array(), 'body' => '', 'httpVersion' => '1.0', '_malformed' => true ); } $method = strtoupper($m[1]); $uri = $m[2]; $httpVersion = $m[3]; // Fast path parsing — avoid parse_url for simple paths $qPos = strpos($uri, '?'); if ($qPos !== false) { $path = urldecode(substr($uri, 0, $qPos)); $query = substr($uri, $qPos + 1); } else { $path = urldecode($uri); $query = ''; } // Collapse double slashes if (strpos($path, '//') !== false) { $path = preg_replace('#/+#', '/', $path); } // Fast header parsing — scan for common headers first $headers = array(); $pos = $rlEnd !== false ? $rlEnd + 2 : strlen($headerBlock); $len = strlen($headerBlock); while ($pos < $len) { $nlPos = strpos($headerBlock, "\r\n", $pos); if ($nlPos === false) $nlPos = $len; $colonPos = strpos($headerBlock, ':', $pos); if ($colonPos !== false && $colonPos < $nlPos) { $k = strtolower(substr($headerBlock, $pos, $colonPos - $pos)); $v = ltrim(substr($headerBlock, $colonPos + 1, $nlPos - $colonPos - 1)); $headers[$k] = $v; } $pos = $nlPos + 2; } // HTTP/1.0 defaults to Connection: close if ($httpVersion === '1.0' && !isset($headers['connection'])) { $headers['connection'] = 'close'; } return compact('method', 'uri', 'path', 'query', 'headers', 'body', 'httpVersion'); } // ── Response helpers ───────────────────────────────── static function sendResponse($client, $status, $body, $type = 'text/plain; charset=utf-8', $extra = array()) { static $reasons = array( 200=>'OK', 301=>'Moved Permanently', 304=>'Not Modified', 400=>'Bad Request', 403=>'Forbidden', 404=>'Not Found', 413=>'Payload Too Large', 429=>'Too Many Requests', 431=>'Request Header Fields Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway' ); self::$lastStatus = $status; self::$lastBody = $body; $body = (string) $body; $conn = $extra['Connection'] ?? 'keep-alive'; unset($extra['Connection']); $out = "HTTP/1.1 $status " . ($reasons[$status] ?? 'OK') . "\r\nContent-Type: $type\r\nContent-Length: " . strlen($body) . "\r\nConnection: $conn\r\n"; foreach ($extra as $k => $v) $out .= "$k: $v\r\n"; @fwrite($client, $out . "\r\n" . $body); } private static function sendRedirect($client, $loc) { @fwrite($client, "HTTP/1.1 301 Moved Permanently\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); self::$lastStatus = 301; } private static function sendNotModified($client, $etag, $mtime, $keepAlive = false) { $conn = $keepAlive ? 'keep-alive' : 'close'; @fwrite($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n" . "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n" . "Cache-Control: public, max-age=0, must-revalidate\r\nContent-Length: 0\r\nConnection: $conn\r\n\r\n"); self::$lastStatus = 304; } private static function render404($path) { $safe = htmlspecialchars($path, ENT_QUOTES); return "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) { if (!Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false)) { return true; } $now = time(); $maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100); $window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60); $burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20); $burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1); // Clean old entries if (!isset(self::$rateLimitData[$ip])) { self::$rateLimitData[$ip] = array(); } $hits = &self::$rateLimitData[$ip]; $cutoff = $now - $window; $hits = array_filter($hits, function ($t) use ($cutoff) { return $t >= $cutoff; }); // Check window limit if (count($hits) >= $maxReqs) { return false; } // Check burst limit $burstCutoff = $now - $burstWindow; $recent = array_filter($hits, function ($t) use ($burstCutoff) { return $t >= $burstCutoff; }); if (count($recent) >= $burstReqs) { return false; } $hits[] = $now; // Periodic cleanup: remove IPs not seen in the last window if (mt_rand(0, 99) < 5) { // 5% chance per request foreach (self::$rateLimitData as $k => $v) { if (empty($v) || max($v) < $cutoff) { unset(self::$rateLimitData[$k]); } } } return true; } private static function resolveStatic($urlPath) { $rel = str_replace('/', DS, ltrim($urlPath, '/')); $fsPath = realpath(self::$rootDir . $rel); if (!$fsPath) return null; $fsPath = str_replace(array('/','\\'), DS, $fsPath); $root = rtrim(self::$rootDir, DS); if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) { return null; // path traversal } return (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null; } private static function closeClient($key) { if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); unset(self::$clientWatchers[$key]); } if (isset(self::$timeoutWatchers[$key])) { Q_Evented::cancel(self::$timeoutWatchers[$key]); unset(self::$timeoutWatchers[$key]); } if (isset(self::$clients[$key])) { @fclose(self::$clients[$key]); unset(self::$clients[$key]); } unset(self::$buffers[$key], self::$clientInfo[$key], self::$keepAliveCount[$key]); } // ── State ──────────────────────────────────────────── private static $socket = null; private static $tlsSocket = null; private static $tlsWatcher = null; private static $tlsPending = array(); private static $httpsPort = 0; static $clients = array(); static $clientWatchers = array(); private static $buffers = array(); private static $clientInfo = array(); // key => [ip, connectTime] private static $keepAliveCount = array(); // key => int private static $timeoutWatchers = array(); // key => evented timer id private static $acceptWatcher = null; private static $running = false; private static $lastStatus = 200; private static $lastBody = ''; static $allowedExtensions = array( 'html','htm','txt','md','json','xml','yaml','yml','csv','tsv','log', 'css','js','mjs','map','wasm', 'png','gif','webp','jpg','jpeg','svg','bmp','ico','avif', 'woff','woff2','ttf','otf', 'mp3','wav','ogg','mp4','webm', 'pdf','zip' ); private static $rateLimitData = array(); // ip => [timestamps] // ── Static file response cache ────────────────────── // Caches full response bytes (headers+body) keyed by fsPath. // Invalidated on mtime change. Saves stat/read/header-build per request. private static $fileCache = array(); // fsPath => [mtime, size, etag, responses => [connType => bytes]] private static $fileCacheSize = 0; // total bytes in cache private static $fileCacheMaxSize = 67108864; // 64MB default, configurable private static $fileCacheMaxFile = 1048576; // don't cache files > 1MB private static $fileCacheCheckInterval = 1; // seconds between mtime checks private static $fileCacheLastCheck = 0; } 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); } // ── 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_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; } } z¥{"÷ /®Ü)HrÖ©å›B"~#Âß”)Mœ %ëGBMB