0 && function_exists('pcntl_fork')) { self::$pool = new Q_WebServer_Pool($workers); } self::$running = true; } /** * Start or restart the TLS listener. * Uses tcp:// + stream_socket_enable_crypto() for non-blocking * TLS handshake. The handshake happens per-connection in the * event loop, not during accept. * * @method startTls * @static */ static function startTls($host, $port) { if (self::$tlsWatcher) { Q_Evented::cancel(self::$tlsWatcher); self::$tlsWatcher = null; } if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; } if (!Q_WebServer_Certs::validateCerts()) return; // Listen on plain tcp:// — TLS handshake happens after accept $errno = $errstr = 0; self::$tlsSocket = stream_socket_server( "tcp://{$host}:{$port}", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN ); if (!self::$tlsSocket) { echo "[HTTPS] Could not bind to {$host}:{$port} — $errstr\n"; return; } stream_set_blocking(self::$tlsSocket, false); self::$tlsWatcher = Q_Evented::onReadable( self::$tlsSocket, function ($sock) { Q_WebServer::onAcceptTls($sock); } ); echo "[HTTPS] Listening on https://{$host}:{$port}\n"; } /** * Accept a connection on the TLS port and begin * non-blocking crypto handshake. * * @method onAcceptTls * @static */ static function onAcceptTls($serverSocket) { $client = @stream_socket_accept($serverSocket, 0); if (!$client) return; stream_set_blocking($client, false); // Set SSL context options on this specific socket $certPath = Q_WebServer_Certs::$certPath; $keyPath = Q_WebServer_Certs::$keyPath; stream_context_set_option($client, 'ssl', 'local_cert', $certPath); stream_context_set_option($client, 'ssl', 'local_pk', $keyPath); stream_context_set_option($client, 'ssl', 'allow_self_signed', true); stream_context_set_option($client, 'ssl', 'verify_peer', false); $key = (int) $client; self::$clients[$key] = $client; self::$buffers[$key] = ''; self::$tlsPending[$key] = true; // Start the handshake — may need multiple attempts self::continueTlsHandshake($key); } /** * Continue a non-blocking TLS handshake. * stream_socket_enable_crypto() returns: * true → handshake complete * false → handshake failed * 0 → handshake in progress, try again * * @method continueTlsHandshake * @static */ static function continueTlsHandshake($key) { if (!isset(self::$clients[$key])) return; $client = self::$clients[$key]; $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) { $cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_SERVER; } $result = @stream_socket_enable_crypto($client, true, $cryptoMethod); if ($result === true) { // Handshake complete — treat like a normal client unset(self::$tlsPending[$key]); self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { Q_WebServer::onClientData($c); } ); } elseif ($result === 0) { // In progress — watch for readability to retry self::$clientWatchers[$key] = Q_Evented::onReadable( $client, function ($c) { $k = (int) $c; // Cancel this watcher and retry handshake if (isset(Q_WebServer::$clientWatchers[$k])) { Q_Evented::cancel(Q_WebServer::$clientWatchers[$k]); unset(Q_WebServer::$clientWatchers[$k]); } Q_WebServer::continueTlsHandshake($k); } ); } else { // Failed self::closeClient($key); } } /** * Reload TLS after cert renewal. Called by Q_WebServer_Certs. * New connections will use the new certs. Existing connections * keep their old certs until they close (normal behavior). * * @method reloadTls * @static */ static function reloadTls() { if (self::$httpsPort) { // No need to restart the listener — we set SSL context // per-connection in onAcceptTls, so new connections // will pick up the new cert files automatically. echo "[HTTPS] Certificates reloaded for new connections.\n"; } } /** * Graceful shutdown: stop accepting new connections, * wait for in-flight requests to complete (up to timeout), * then close everything. * @method stop * @static * @param {float} $drainTimeout Max seconds to wait for in-flight requests */ static function stop($drainTimeout = 5.0) { if (!self::$running) return; self::$running = false; // 1. Stop accepting new connections if (self::$acceptWatcher) { Q_Evented::cancel(self::$acceptWatcher); self::$acceptWatcher = null; } if (self::$tlsWatcher) { Q_Evented::cancel(self::$tlsWatcher); self::$tlsWatcher = null; } if (self::$socket) { @fclose(self::$socket); self::$socket = null; } if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; } // 2. Wait for in-flight connections to drain (up to timeout) $deadline = microtime(true) + $drainTimeout; while (!empty(self::$clients) && microtime(true) < $deadline) { Q_Evented::tick(0.1); // process pending I/O briefly } // 3. Force-close remaining connections foreach (self::$timeoutWatchers as $id) Q_Evented::cancel($id); self::$timeoutWatchers = array(); foreach (self::$clientWatchers as $id) Q_Evented::cancel($id); self::$clientWatchers = array(); foreach (self::$clients as $c) @fclose($c); self::$clients = array(); self::$buffers = array(); self::$clientInfo = array(); self::$keepAliveCount = array(); // 4. Disconnect WebSockets Q_WebSocket::disconnectAll(); // 5. Gracefully shut down worker pool (SIGTERM → wait → SIGKILL) if (self::$pool) { self::$pool->shutdown(); self::$pool = null; } } static function run() { if (!self::$running) return; if (function_exists('pcntl_signal')) { Q_Evented::onSignal(SIGINT, function () { echo "\n Graceful shutdown (SIGINT)...\n"; self::stop(); Q_Evented::stop(); }); Q_Evented::onSignal(SIGTERM, function () { echo "\n Graceful shutdown (SIGTERM)...\n"; self::stop(); Q_Evented::stop(); }); // Reap zombie children from fork-per-request PHP execution Q_Evented::onSignal(SIGCHLD, function () { while (pcntl_waitpid(-1, $st, WNOHANG) > 0) {} }); } Q_Evented::run(); } // ── Connection handling ────────────────────────────── static function onAccept($socket) { // Max connections check $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']); $parsed['_remoteAddr'] = $parsed['clientIp']; $peer = stream_socket_get_name($client, true); $parsed['_remotePort'] = $peer ? (int) substr(strrchr($peer, ':'), 1) : 0; // Determine keep-alive before handling request $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. WebSocket upgrade on any path $upgrade = strtolower($parsed['headers']['upgrade'] ?? ''); if ($upgrade === 'websocket' && $path !== '/Q/ws') { $upgraded = Q_WebSocket::upgrade( $client, $parsed['headers'], function ($sk, $msg) use ($path) { Q_WebSocket::dispatchEvent($sk, $msg, $path); } ); return $upgraded; } // 3. Blocked paths if (self::isBlocked($path)) { self::sendResponse($client, 403, 'Forbidden'); return false; } // 3. Component cache check (Merkle tree — serves from cached slots) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); $cachedPage = Q_WebServer_Cache_Components::getPage($pageKey); if ($cachedPage !== null) { self::sendResponse($client, 200, $cachedPage, 'text/html; charset=utf-8', array('X-Cache' => 'HIT-COMPONENTS')); return false; } } // 4. Reverse cache check (before forking a worker) $cached = Q_WebServer_Cache::get($parsed); if ($cached) { self::sendResponse($client, $cached['status'], $cached['body'], $cached['headers']['Content-Type'] ?? 'text/html', $cached['headers']); return false; } // 4. Resolve filesystem path $fsPath = self::resolveStatic($path); // 4. Directory handling if ($fsPath && is_dir($fsPath)) { if (substr($path, -1) !== '/') { self::sendRedirect($client, $path . '/'); return false; } // Check for index files foreach (array('index.html', 'index.php') as $idx) { $indexPath = $fsPath . DS . $idx; if (is_file($indexPath)) { $fsPath = $indexPath; break; } } if (is_dir($fsPath)) { // No index file → check if listings are enabled for this path if (self::isIndexed($path)) { $html = self::renderDirectoryListing($fsPath, $path); self::sendResponse($client, 200, $html, 'text/html; charset=utf-8', array('Cache-Control' => 'no-store')); } else { self::sendResponse($client, 403, 'Forbidden'); } return false; } } // 5. File handling if ($fsPath && is_file($fsPath)) { $ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION)); // PHP scripts → worker pool or in-process if ($ext === 'php') { return self::handlePhp($client, $parsed, $fsPath); } // Static file if ($method === 'GET' || $method === 'HEAD') { self::serveStaticFile($client, $fsPath, $method, $parsed['headers'], !empty($parsed['_keepAlive'])); return false; } } // 6. Route dispatch — if Q.routes configured, match URL to handler // Q_Uri caches compiled patterns and path→URI results in memory. static $routingEnabled = null; if ($routingEnabled === null) { $routingEnabled = Q_Config::get('Q', 'routes', null) !== null && class_exists('Q_Uri', true); } if ($routingEnabled) { $uri = Q_Uri::fromPath($path); if ($uri && !empty($uri->module) && !empty($uri->action)) { return self::handleRoute($client, $parsed, $uri); } } // 7. Clean URL → route through index.php (if exists) $indexPhp = self::$rootDir . 'index.php'; if (is_file($indexPhp)) { return self::handlePhp($client, $parsed, $indexPhp); } // 8. Configurable fallback (SPA routing, custom 404 page, etc.) // Q.webserver.fallback can be: // - string: path to a static file relative to web/ (e.g. "index.html") // - object with "handler": event name to dispatch via Q::event() // - object with "file": static file + auto-detect Content-Type $fallback = Q_Config::get('Q', 'webserver', 'fallback', null); if ($fallback !== null) { if (is_string($fallback)) { // Static file (SPA catch-all: serve index.html for all routes) $fbPath = self::$rootDir . str_replace('/', DS, $fallback); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } elseif (is_array($fallback)) { if (!empty($fallback['handler'])) { // Route to a handler — build a synthetic Q_Uri $uri = Q_Uri::from(array( 'module' => dirname($fallback['handler']), 'action' => basename($fallback['handler']), '_originalPath' => $path, )); if ($uri) { return self::handleRoute($client, $parsed, $uri); } } elseif (!empty($fallback['file'])) { $fbPath = self::$rootDir . str_replace('/', DS, $fallback['file']); if (is_file($fbPath)) { return self::serveFile($client, $parsed, $fbPath); } } } } // 9. Not found self::sendResponse($client, 404, self::render404($path), 'text/html; charset=utf-8'); return false; } /** * Handle a routed request via Q::event() dispatch pipeline. * Fires the same events as Qbix Platform's Q_Dispatcher: * {module}/{action}/validate → validate input * {module}/{action}/{method} → handle GET/POST/PUT/DELETE * {module}/{action}/response → render response * * @method handleRoute * @static * @private * @param {resource} $client * @param {array} $parsed * @param {Q_Uri} $uri * @return {boolean} */ private static function handleRoute($client, $parsed, $uri) { $module = $uri->module; $action = $uri->action; $routed = $uri->toArray(); $method = strtolower($parsed['method']); // get, post, put, delete // Set up superglobals $parsed['_scriptPath'] = ''; // no script — handler-based $saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE); $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SERVER_NAME'] = explode(':', $parsed['headers']['host'] ?? 'localhost')[0]; $_SERVER['SERVER_PORT'] = self::$port; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0'; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); $_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1'; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } $_GET = $_POST = $_REQUEST = $_FILES = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($parsed['headers']['content-type'] ?? ''); $rawBody = $parsed['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($rawBody, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($rawBody, true) ?: array(); } elseif (strpos($ct, 'multipart/form-data') !== false) { $origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''; self::parseMultipart($origCt, $rawBody, $_POST, $_FILES); } $_REQUEST = array_merge($_GET, $_POST); // Make raw body available Q_Request::$input = $rawBody; // If pcntl available, fork to isolate if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === 0) { // ── CHILD: run dispatch pipeline ── while (ob_get_level()) ob_end_clean(); ob_start(); $status = 200; $headers = array(); try { // 1. Validate Q::event("$module/$action/validate", $routed, false, true); // 2. Method handler (get, post, put, delete) if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } // 3. Response Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } elseif ($pid > 0) { @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; return false; } // Fork failed — fall through to in-process } // In-process fallback while (ob_get_level()) ob_end_clean(); header_remove(); http_response_code(200); ob_start(); $status = 200; $headers = array(); try { Q::event("$module/$action/validate", $routed, false, true); if (Q::canHandle("$module/$action/$method")) { Q::event("$module/$action/$method", $routed); } elseif ($method !== 'get') { $status = 405; echo 'Method Not Allowed'; } Q::event("$module/$action/response", $routed, false, true); $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; $response = compact('status', 'body', 'headers'); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $status; Q_WebServer_Cache::put($parsed, $response); return false; } /** * Route a .php script to the worker pool or dispatch in-process. * @return {boolean} false (connection closes after response) */ private static function handlePhp($client, $parsed, $scriptPath) { if (self::$pool) { self::$lastStatus = 200; self::$pool->dispatch($client, $parsed, $scriptPath); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); return false; } // No pool — fork a single child if pcntl is available. // This protects the server from exit()/die() in scripts // and prevents blocking the event loop during PHP execution. if (function_exists('pcntl_fork')) { $pid = pcntl_fork(); if ($pid === -1) { // Fork failed — fall through to in-process execution } elseif ($pid === 0) { // ── CHILD: handle request, write response to client, exit ── $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); @fclose($client); exit(0); } else { // ── PARENT: close client socket (child owns it now), reap later ── @fclose($client); $key = (int) $client; if (isset(self::$clientWatchers[$key])) { Q_Evented::cancel(self::$clientWatchers[$key]); } unset(self::$clientWatchers[$key], self::$clients[$key], self::$buffers[$key]); // Non-blocking reap — don't wait for child pcntl_waitpid($pid, $st, WNOHANG); self::$lastStatus = 200; return false; } } // Fallback: use proc_open to run PHP in a subprocess (Windows, // or fork failed). Safe against exit()/die() — the subprocess // dies, not the server. Slower than fork (no shared classes) // but still provides isolation. return self::handlePhpSubprocess($client, $parsed, $scriptPath); } /** * Execute a PHP script in a subprocess via proc_open. * Used on Windows (no pcntl_fork) or as fallback when fork fails. * The subprocess gets request data via stdin, returns response via stdout. * @method handlePhpSubprocess * @static * @private */ private static function handlePhpSubprocess($client, $parsed, $scriptPath) { // Build a small inline PHP worker that: // 1. Reads request JSON from stdin // 2. Sets up superglobals // 3. Loads Q.php for autoloader/events // 4. Includes the target script // 5. Writes response JSON to stdout $qFile = dirname(__FILE__) . DS . 'Q.php'; $workerCode = <<<'WORKER' 500,'body'=>'Bad request','headers'=>[]]); exit; } if (isset($req['qFile']) && file_exists($req['qFile'])) { require_once $req['qFile']; if (isset($req['projectRoot'])) Q::init($req['projectRoot']); } $_SERVER['REQUEST_METHOD'] = $req['method'] ?? 'GET'; $_SERVER['REQUEST_URI'] = $req['uri'] ?? '/'; $_SERVER['QUERY_STRING'] = $req['query'] ?? ''; $_SERVER['SCRIPT_FILENAME'] = $req['scriptPath'] ?? ''; $_SERVER['SCRIPT_NAME'] = '/' . basename($req['scriptPath'] ?? 'index.php'); $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; $_SERVER['PATH_TRANSLATED'] = $req['scriptPath'] ?? ''; $_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? ''; $_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME']; $_SERVER['SERVER_NAME'] = $req['serverName'] ?? 'localhost'; $_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080'; $_SERVER['SERVER_ADDR'] = '127.0.0.1'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/1.0'; $_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1'; $_SERVER['REDIRECT_STATUS'] = 200; $_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1'; $_SERVER['REMOTE_PORT'] = $req['remotePort'] ?? 0; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); $_SERVER['REQUEST_SCHEME'] = ($req['https'] ?? false) ? 'https' : 'http'; $_SERVER['HTTPS'] = ($req['https'] ?? false) ? 'on' : ''; foreach ($req['headers'] ?? [] as $k=>$v) $_SERVER['HTTP_'.strtoupper(str_replace('-','_',$k))] = $v; if (isset($req['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $req['headers']['content-type']; if (isset($req['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length']; // Parse cookies $_COOKIE = []; $ck = $req['headers']['cookie'] ?? ''; if ($ck) { foreach (explode(';',$ck) as $p) { $p=trim($p); if(!$p)continue; $e=strpos($p,'='); if($e===false)continue; $_COOKIE[urldecode(trim(substr($p,0,$e)))]=urldecode(trim(substr($p,$e+1))); } } // Parse Basic auth $auth = $req['headers']['authorization'] ?? ''; if (stripos($auth,'Basic ')===0) { $d=base64_decode(substr($auth,6)); if($d&&strpos($d,':')!==false) { [$u,$pw]=explode(':',$d,2); $_SERVER['PHP_AUTH_USER']=$u; $_SERVER['PHP_AUTH_PW']=$pw; $_SERVER['AUTH_TYPE']='Basic'; } } $_GET = $_POST = $_REQUEST = $_FILES = []; if (!empty($req['query'])) parse_str($req['query'], $_GET); $ct = strtolower($_SERVER['CONTENT_TYPE'] ?? ''); $raw = $req['body'] ?? ''; if (strpos($ct,'application/x-www-form-urlencoded') !== false) parse_str($raw, $_POST); elseif (strpos($ct,'application/json') !== false) $_POST = json_decode($raw, true) ?: []; elseif (strpos($ct,'multipart/form-data') !== false) { $oct=$req['headers']['content-type']??''; Q_WebServer::parseMultipart($oct, $raw, $_POST, $_FILES); } $_REQUEST = array_merge($_COOKIE, $_GET, $_POST); if (class_exists('Q_Request',false)) Q_Request::$input = $raw; ob_start(); $status = 200; $headers = []; try { if (is_file($req['scriptPath'])) include $req['scriptPath']; else { $status = 404; echo 'Not Found'; } $headers = Q::getResponseHeaders(); $code = http_response_code(); if (Q::$_responseCode !== 200) $status = Q::$_responseCode; if ($code) $status = $code; } catch (Throwable $e) { $status = 500; ob_clean(); echo $e->getMessage(); $headers['Content-Type']='text/plain'; } $body = ob_get_clean(); echo json_encode(compact('status','body','headers'), JSON_UNESCAPED_SLASHES); WORKER; // Write worker code to a temp file (reuse across requests) static $workerFile = null; if (!$workerFile || !file_exists($workerFile)) { $workerFile = tempnam(sys_get_temp_dir(), 'qbix_worker_'); file_put_contents($workerFile, $workerCode); register_shutdown_function(function () use (&$workerFile) { @unlink($workerFile); }); } // Build request payload $host = $parsed['headers']['host'] ?? 'localhost'; $payload = json_encode(array( 'method' => $parsed['method'], 'uri' => $parsed['uri'], 'query' => $parsed['query'], 'headers' => $parsed['headers'], 'body' => $parsed['body'] ?? '', 'scriptPath' => $scriptPath, 'documentRoot'=> rtrim(self::$rootDir, DS), 'serverName' => explode(':', $host)[0], 'serverPort' => (string) self::$port, 'remoteAddr' => $parsed['_remoteAddr'] ?? '127.0.0.1', 'remotePort' => $parsed['_remotePort'] ?? 0, 'https' => !empty(self::$tlsSocket), 'qFile' => $qFile, 'projectRoot' => dirname(rtrim(self::$rootDir, DS)), ), JSON_UNESCAPED_SLASHES); // Launch subprocess $descriptors = array( 0 => array('pipe', 'r'), // stdin 1 => array('pipe', 'w'), // stdout 2 => array('pipe', 'w'), // stderr ); $phpBin = defined('PHP_BINARY') ? PHP_BINARY : 'php'; $process = proc_open($phpBin . ' ' . escapeshellarg($workerFile), $descriptors, $pipes); if (!is_resource($process)) { // proc_open failed — last resort, run in-process $parsed['_scriptPath'] = $scriptPath; $response = self::dispatchToQ($parsed); Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $response['status'] ?? 200; return false; } // Send request data to child's stdin fwrite($pipes[0], $payload); fclose($pipes[0]); // Read response from child's stdout $stdout = ''; while (!feof($pipes[1])) { $chunk = fread($pipes[1], 65536); if ($chunk === false || $chunk === '') break; $stdout .= $chunk; } fclose($pipes[1]); fclose($pipes[2]); proc_close($process); // Parse response $response = json_decode($stdout, true); if (!$response) { $response = array( 'status' => 502, 'body' => 'Worker subprocess failed', 'headers' => array('Content-Type' => 'text/plain'), ); } Q_WebServer_Headers::processResponse($client, $response, $parsed['headers']); self::$lastStatus = $response['status'] ?? 200; Q_WebServer_Cache::put($parsed, $response); return false; } // ── 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, $_COOKIE); $scriptPath = $parsed['_scriptPath'] ?? self::$rootDir . 'index.php'; $host = $parsed['headers']['host'] ?? 'localhost'; $hostParts = explode(':', $host); // ── Standard CGI variables ────────────────────── $_SERVER['REQUEST_METHOD'] = $parsed['method']; $_SERVER['REQUEST_URI'] = $parsed['uri']; $_SERVER['QUERY_STRING'] = $parsed['query']; $_SERVER['SCRIPT_NAME'] = '/' . basename($scriptPath); $_SERVER['SCRIPT_FILENAME'] = $scriptPath; $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; // WordPress uses this $_SERVER['PATH_TRANSLATED'] = $scriptPath; $_SERVER['PATH_INFO'] = ''; $_SERVER['DOCUMENT_ROOT'] = rtrim(self::$rootDir, DS); $_SERVER['DOCUMENT_URI'] = $_SERVER['SCRIPT_NAME']; $_SERVER['SERVER_NAME'] = $hostParts[0]; $_SERVER['SERVER_PORT'] = isset($hostParts[1]) ? $hostParts[1] : self::$port; $_SERVER['SERVER_ADDR'] = self::$host === '0.0.0.0' ? '127.0.0.1' : self::$host; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/' . ($parsed['httpVersion'] ?? '1.1'); $_SERVER['SERVER_SOFTWARE'] = 'QbixServer/' . (defined('QBIX_SERVER_VERSION') ? QBIX_SERVER_VERSION : '1.0'); $_SERVER['GATEWAY_INTERFACE'] = 'CGI/1.1'; $_SERVER['REDIRECT_STATUS'] = 200; $_SERVER['REMOTE_ADDR'] = $parsed['_remoteAddr'] ?? '127.0.0.1'; $_SERVER['REMOTE_PORT'] = $parsed['_remotePort'] ?? 0; $_SERVER['REQUEST_TIME'] = time(); $_SERVER['REQUEST_TIME_FLOAT']= microtime(true); // ── HTTPS detection (direct TLS or proxy header) ── $isHttps = !empty(self::$tlsSocket); $fwdProto = $parsed['headers']['x-forwarded-proto'] ?? ''; if (strtolower($fwdProto) === 'https') $isHttps = true; // CloudFront $cfProto = $parsed['headers']['cloudfront-forwarded-proto'] ?? ''; if (strtolower($cfProto) === 'https') $isHttps = true; // Cloudflare $cfVisitor = $parsed['headers']['cf-visitor'] ?? ''; if (strpos($cfVisitor, '"https"') !== false) $isHttps = true; $_SERVER['REQUEST_SCHEME'] = $isHttps ? 'https' : 'http'; $_SERVER['HTTPS'] = $isHttps ? 'on' : ''; // ── Request headers → HTTP_* ──────────────────── // All request headers become HTTP_HEADERNAME (uppercase, hyphens→underscores) foreach ($parsed['headers'] as $k => $v) { $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v; } // Content-Type and Content-Length are special (no HTTP_ prefix per CGI spec) if (isset($parsed['headers']['content-type'])) $_SERVER['CONTENT_TYPE'] = $parsed['headers']['content-type']; if (isset($parsed['headers']['content-length'])) $_SERVER['CONTENT_LENGTH'] = $parsed['headers']['content-length']; // ── Basic auth parsing ────────────────────────── $auth = $parsed['headers']['authorization'] ?? ''; if (stripos($auth, 'Basic ') === 0) { $decoded = base64_decode(substr($auth, 6)); if ($decoded && strpos($decoded, ':') !== false) { list($user, $pass) = explode(':', $decoded, 2); $_SERVER['PHP_AUTH_USER'] = $user; $_SERVER['PHP_AUTH_PW'] = $pass; $_SERVER['AUTH_TYPE'] = 'Basic'; } } elseif (stripos($auth, 'Bearer ') === 0) { $_SERVER['HTTP_AUTHORIZATION'] = $auth; // already set by loop $_SERVER['AUTH_TYPE'] = 'Bearer'; } // ── $_COOKIE ──────────────────────────────────── $_COOKIE = array(); $cookieHeader = $parsed['headers']['cookie'] ?? ''; if ($cookieHeader) { $pairs = explode(';', $cookieHeader); foreach ($pairs as $pair) { $pair = trim($pair); if ($pair === '') continue; $eqPos = strpos($pair, '='); if ($eqPos === false) continue; $name = urldecode(trim(substr($pair, 0, $eqPos))); $value = urldecode(trim(substr($pair, $eqPos + 1))); $_COOKIE[$name] = $value; } } // ── $_GET, $_POST, $_FILES, $_REQUEST ─────────── $_GET = $_POST = $_REQUEST = $_FILES = array(); if ($parsed['query']) parse_str($parsed['query'], $_GET); $ct = strtolower($_SERVER['CONTENT_TYPE'] ?? ''); $rawBody = $parsed['body'] ?? ''; if (strpos($ct, 'application/x-www-form-urlencoded') !== false) { parse_str($rawBody, $_POST); } elseif (strpos($ct, 'application/json') !== false) { $_POST = json_decode($rawBody, true) ?: array(); } elseif (strpos($ct, 'multipart/form-data') !== false) { $origCt = $parsed['headers']['content-type'] ?? $_SERVER['CONTENT_TYPE'] ?? ''; self::parseMultipart($origCt, $rawBody, $_POST, $_FILES); } $_REQUEST = array_merge($_COOKIE, $_GET, $_POST); // PHP default order // Make raw body available Q_Request::$input = $rawBody; // Clear any stale headers and output from previous in-process requests, // then start fresh output buffering. This prevents "headers already sent" // errors when scripts call header() after prior output leaked through. while (ob_get_level()) ob_end_clean(); header_remove(); http_response_code(200); Q::clearResponseHeaders(); if (class_exists('Q_Response', false)) Q_Response::clear(); ob_start(); $status = 200; $headers = array(); try { if (class_exists('Q_Dispatcher', false)) { // Full Qbix Platform mode Q_Dispatcher::dispatch(); } else { // Standalone mode — execute PHP script directly $scriptPath = $parsed['_scriptPath'] ?? $_SERVER['SCRIPT_FILENAME']; if (is_file($scriptPath)) { include $scriptPath; } else { $status = 404; echo 'Not Found'; } } $headers = Q::getResponseHeaders(); $code = http_response_code(); if ($code && $code !== 200) $status = $code; if (Q::$_responseCode !== 200) $status = Q::$_responseCode; } catch (\Throwable $e) { $status = 500; ob_clean(); echo json_encode(array('error' => $e->getMessage())); $headers['Content-Type'] = 'application/json'; } $body = ob_get_clean(); header_remove(); list($_SERVER, $_GET, $_POST, $_REQUEST, $_COOKIE) = $saved; // Process Merkle cache headers (strips X-Q-Cache-* from response) if (Q_WebServer_Cache_Components::enabled()) { $pageKey = $parsed['path'] . '?' . ($parsed['query'] ?? ''); Q_WebServer_Cache_Components::processResponseHeaders($pageKey, $headers); } return compact('status', 'body', 'headers'); } // ── Request parsing ────────────────────────────────── static function parseRequest($raw) { $headerEnd = strpos($raw, "\r\n\r\n"); $headerBlock = substr($raw, 0, $headerEnd); $body = substr($raw, $headerEnd + 4); // Fast request line parse $rlEnd = strpos($headerBlock, "\r\n"); $requestLine = $rlEnd !== false ? substr($headerBlock, 0, $rlEnd) : $headerBlock; if (!preg_match('#^(\w+)\s+([^\s]+)\s+HTTP/(\d\.\d)#', $requestLine, $m)) { return array( 'method' => 'GET', 'uri' => '/', 'path' => '/', 'query' => '', 'headers' => array(), 'body' => '', 'httpVersion' => '1.0', '_malformed' => true ); } $method = strtoupper($m[1]); $uri = $m[2]; $httpVersion = $m[3]; // Fast path parsing — avoid parse_url for simple paths $qPos = strpos($uri, '?'); if ($qPos !== false) { $path = urldecode(substr($uri, 0, $qPos)); $query = substr($uri, $qPos + 1); } else { $path = urldecode($uri); $query = ''; } // Collapse double slashes if (strpos($path, '//') !== false) { $path = preg_replace('#/+#', '/', $path); } // Fast header parsing — scan for common headers first $headers = array(); $pos = $rlEnd !== false ? $rlEnd + 2 : strlen($headerBlock); $len = strlen($headerBlock); while ($pos < $len) { $nlPos = strpos($headerBlock, "\r\n", $pos); if ($nlPos === false) $nlPos = $len; $colonPos = strpos($headerBlock, ':', $pos); if ($colonPos !== false && $colonPos < $nlPos) { $k = strtolower(substr($headerBlock, $pos, $colonPos - $pos)); $v = ltrim(substr($headerBlock, $colonPos + 1, $nlPos - $colonPos - 1)); $headers[$k] = $v; } $pos = $nlPos + 2; } // HTTP/1.0 defaults to Connection: close if ($httpVersion === '1.0' && !isset($headers['connection'])) { $headers['connection'] = 'close'; } return compact('method', 'uri', 'path', 'query', 'headers', 'body', 'httpVersion'); } /** * Parse multipart/form-data body into $_POST and $_FILES arrays. * Handles file uploads by writing to temp files (same as php-fpm). * @method parseMultipart * @static * @param {string} $contentType Full Content-Type header value * @param {string} $body Raw request body * @param {array} &$post Populated with form field values * @param {array} &$files Populated with file upload entries */ static function parseMultipart($contentType, $body, &$post, &$files) { // Extract boundary from Content-Type if (!preg_match('/boundary=(?:"([^"]+)"|([^\s;]+))/i', $contentType, $bm)) { return; } $boundary = '--' . ($bm[1] ?: $bm[2]); $endBoundary = $boundary . '--'; $parts = explode($boundary, $body); array_shift($parts); // before first boundary foreach ($parts as $part) { $part = ltrim($part, "\r\n"); if ($part === '--' || $part === "--\r\n" || $part === '') continue; if (strpos($part, '--') === 0) continue; // end boundary // Split headers from body $headerEnd = strpos($part, "\r\n\r\n"); if ($headerEnd === false) continue; $headerBlock = substr($part, 0, $headerEnd); $partBody = substr($part, $headerEnd + 4); // Remove trailing \r\n if (substr($partBody, -2) === "\r\n") { $partBody = substr($partBody, 0, -2); } // Parse part headers $partHeaders = array(); foreach (explode("\r\n", $headerBlock) as $line) { $colonPos = strpos($line, ':'); if ($colonPos !== false) { $k = strtolower(trim(substr($line, 0, $colonPos))); $v = trim(substr($line, $colonPos + 1)); $partHeaders[$k] = $v; } } $disp = $partHeaders['content-disposition'] ?? ''; if (strpos($disp, 'form-data') === false) continue; // Extract name $name = null; if (preg_match('/\bname="([^"]*)"/', $disp, $nm)) { $name = $nm[1]; } elseif (preg_match("/\bname='([^']*)'/", $disp, $nm)) { $name = $nm[1]; } if ($name === null) continue; // Check if it's a file upload $filename = null; if (preg_match('/\bfilename="([^"]*)"/', $disp, $fm)) { $filename = $fm[1]; } elseif (preg_match("/\bfilename='([^']*)'/", $disp, $fm)) { $filename = $fm[1]; } if ($filename !== null) { // File upload — write to temp file $tmpPath = tempnam(sys_get_temp_dir(), 'qbix_upload_'); file_put_contents($tmpPath, $partBody); $fileEntry = array( 'name' => $filename, 'type' => $partHeaders['content-type'] ?? 'application/octet-stream', 'tmp_name' => $tmpPath, 'error' => UPLOAD_ERR_OK, 'size' => strlen($partBody), ); // Handle array notation: files[0], files[photo], etc. if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) { $files[$am[1]]['name'][$am[2]] = $fileEntry['name']; $files[$am[1]]['type'][$am[2]] = $fileEntry['type']; $files[$am[1]]['tmp_name'][$am[2]] = $fileEntry['tmp_name']; $files[$am[1]]['error'][$am[2]] = $fileEntry['error']; $files[$am[1]]['size'][$am[2]] = $fileEntry['size']; } else { $files[$name] = $fileEntry; } } else { // Regular form field // Handle array notation: tags[], data[key], etc. if (preg_match('/^([^\[]+)\[([^\]]*)\]$/', $name, $am)) { if ($am[2] === '') { $post[$am[1]][] = $partBody; } else { $post[$am[1]][$am[2]] = $partBody; } } else { $post[$name] = $partBody; } } } } // ── Response helpers ───────────────────────────────── static function sendResponse($client, $status, $body, $type = 'text/plain; charset=utf-8', $extra = array()) { static $reasons = array( 200=>'OK', 301=>'Moved Permanently', 304=>'Not Modified', 400=>'Bad Request', 403=>'Forbidden', 404=>'Not Found', 413=>'Payload Too Large', 429=>'Too Many Requests', 431=>'Request Header Fields Too Large', 500=>'Internal Server Error', 502=>'Bad Gateway' ); self::$lastStatus = $status; self::$lastBody = $body; $body = (string) $body; $conn = $extra['Connection'] ?? 'keep-alive'; unset($extra['Connection']); $out = "HTTP/1.1 $status " . ($reasons[$status] ?? 'OK') . "\r\nContent-Type: $type\r\nContent-Length: " . strlen($body) . "\r\nConnection: $conn\r\n"; foreach ($extra as $k => $v) $out .= "$k: $v\r\n"; @fwrite($client, $out . "\r\n" . $body); } private static function sendRedirect($client, $loc) { @fwrite($client, "HTTP/1.1 301 Moved Permanently\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); self::$lastStatus = 301; } private static function sendNotModified($client, $etag, $mtime, $keepAlive = false) { $conn = $keepAlive ? 'keep-alive' : 'close'; @fwrite($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n" . "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n" . "Cache-Control: public, max-age=0, must-revalidate\r\nContent-Length: 0\r\nConnection: $conn\r\n\r\n"); self::$lastStatus = 304; } private static function render404($path) { $safe = htmlspecialchars($path, ENT_QUOTES); return "404" . "" . "

404

{$safe} not found

"; } // ── Path resolution ────────────────────────────────── /** * Parse a Cookie header string into an associative array * @method parseCookieHeader * @static * @param {string} $header The raw Cookie header value * @return {array} name => value pairs */ static function parseCookieHeader($header) { $cookies = array(); if (empty($header)) return $cookies; $pairs = explode(';', $header); foreach ($pairs as $pair) { $pair = trim($pair); if ($pair === '') continue; $eq = strpos($pair, '='); if ($eq === false) { $cookies[$pair] = ''; } else { $name = trim(substr($pair, 0, $eq)); $value = trim(substr($pair, $eq + 1)); $cookies[$name] = urldecode($value); } } return $cookies; } /** * Check rate limit for a client IP. Returns true if allowed, false if over limit. * Configured via Q.webserver.rateLimit: * { "enabled": true, "requests": 100, "window": 60, "burstRequests": 20, "burstWindow": 1 } * @method checkRateLimit * @static * @param {string} $ip Client IP address * @return {boolean} true if request is allowed */ static function checkRateLimit($ip) { 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, '/')); // Block null bytes (directory traversal via null byte injection) if (strpos($rel, "\0") !== false) return null; $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; }