mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
2170 lines
74 KiB
PHP
2170 lines
74 KiB
PHP
<?php
|
|
/**
|
|
* @module Q
|
|
*/
|
|
|
|
/**
|
|
* Pure-PHP web server for Qbix apps.
|
|
*
|
|
* Serves static files with readfile(), ETag/304, companion .headers.
|
|
* Routes .php scripts to pre-forked workers (Q_WebServer_Pool).
|
|
* Upgrades WebSocket connections via Q_WebSocket.
|
|
* Responsive directory listings with media previews.
|
|
* Runs entirely on Q_Evented.
|
|
*
|
|
* @class Q_WebServer
|
|
*/
|
|
class Q_WebServer
|
|
{
|
|
/** @property $pool Q_WebServer_Pool|null */
|
|
static $pool = null;
|
|
/** @property $rootDir Document root with trailing DS */
|
|
public static $rootDir;
|
|
/** @property $host Bound host */
|
|
public static $host;
|
|
/** @property $port Bound port */
|
|
public static $port;
|
|
/** @property $onRequest Logging callback(method, uri, status, ms) */
|
|
static $onRequest = null;
|
|
|
|
// ── Lifecycle ────────────────────────────────────────
|
|
|
|
/**
|
|
* @method start
|
|
* @static
|
|
* @param {string} $dir Document root
|
|
* @param {string} [$host='0.0.0.0']
|
|
* @param {int} [$port=8080]
|
|
* @param {int} [$workers=0] 0=in-process, N=prefork pool
|
|
*/
|
|
static function start($dir, $host = '0.0.0.0', $port = 8080, $workers = 0)
|
|
{
|
|
if (self::$running) {
|
|
throw new Exception("Q_WebServer already running");
|
|
}
|
|
|
|
// Ignore SIGPIPE — prevents crash when writing to a closed socket
|
|
// (e.g. client disconnects, or worker dies mid-response)
|
|
if (function_exists('pcntl_signal')) {
|
|
pcntl_signal(SIGPIPE, SIG_IGN);
|
|
}
|
|
|
|
$root = realpath($dir);
|
|
if (!$root || !is_dir($root)) {
|
|
throw new Exception("Invalid document root: $dir");
|
|
}
|
|
self::$rootDir = rtrim(str_replace(array('/','\\'), DS, $root), DS) . DS;
|
|
self::$host = $host;
|
|
self::$port = $port;
|
|
|
|
if ($ext = Q_Config::get('Q', 'webserver', 'extensions', null)) {
|
|
self::$allowedExtensions = $ext;
|
|
}
|
|
|
|
// File response cache config
|
|
self::$fileCacheMaxSize = Q_Config::get('Q', 'webserver', 'fileCache', 'maxSize', 67108864);
|
|
self::$fileCacheMaxFile = Q_Config::get('Q', 'webserver', 'fileCache', 'maxFile', 1048576);
|
|
self::$fileCacheCheckInterval = Q_Config::get('Q', 'webserver', 'fileCache', 'checkInterval', 1);
|
|
|
|
// ── HTTP listener ────────────────────────────────
|
|
$errno = $errstr = 0;
|
|
self::$socket = stream_socket_server(
|
|
"tcp://{$host}:{$port}", $errno, $errstr,
|
|
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
|
|
);
|
|
if (!self::$socket) {
|
|
throw new Exception("Could not bind to {$host}:{$port} — $errstr");
|
|
}
|
|
stream_set_blocking(self::$socket, false);
|
|
self::$acceptWatcher = Q_Evented::onReadable(
|
|
self::$socket, array(__CLASS__, 'onAccept')
|
|
);
|
|
|
|
// ── HTTPS listener (if certs configured) ─────────
|
|
$httpsConfig = Q_Config::get('Q', 'web', 'https', array());
|
|
$httpsPort = (int) Q::ifset($httpsConfig, 'port', 0);
|
|
if ($httpsPort || Q::ifset($httpsConfig, 'mode', '')) {
|
|
if (!$httpsPort) $httpsPort = 443;
|
|
self::$httpsPort = $httpsPort;
|
|
|
|
$domain = Q::ifset($httpsConfig, 'domain', '');
|
|
$certsReady = Q_WebServer_Certs::init($domain);
|
|
|
|
if ($certsReady) {
|
|
self::startTls($host, $httpsPort);
|
|
} else {
|
|
echo "[HTTPS] No valid certs yet, HTTPS disabled. "
|
|
. "HTTP still running on port $port.\n";
|
|
}
|
|
}
|
|
|
|
// ── Preload classes (before forking) ─────────────
|
|
$preload = Q_Config::get('Q', 'webserver', 'preload', array());
|
|
if (!empty($preload)) {
|
|
// Load the autoloader first (e.g. Composer's)
|
|
$autoload = is_string($preload)
|
|
? $preload
|
|
: (isset($preload['autoload']) ? $preload['autoload'] : null);
|
|
if ($autoload) {
|
|
$autoloadPath = $autoload;
|
|
// Resolve relative to the document root's parent (project root)
|
|
if ($autoloadPath[0] !== '/' && $autoloadPath[0] !== '\\') {
|
|
$projectRoot = dirname(rtrim(self::$rootDir, DS));
|
|
$autoloadPath = $projectRoot . DS . $autoloadPath;
|
|
}
|
|
if (file_exists($autoloadPath)) {
|
|
require_once $autoloadPath;
|
|
$count = count(get_declared_classes());
|
|
echo " Autoloader: " . basename($autoload) . "\n";
|
|
} else {
|
|
echo " Warning: autoload file not found: $autoloadPath\n";
|
|
}
|
|
}
|
|
// Then load each named class (triggers the autoloader)
|
|
$classes = isset($preload['classes']) ? $preload['classes'] : array();
|
|
if (!empty($classes)) {
|
|
$loaded = 0;
|
|
foreach ($classes as $class) {
|
|
if (!class_exists($class, true) && !interface_exists($class, true)
|
|
&& !trait_exists($class, true)
|
|
) {
|
|
echo " Warning: could not preload $class\n";
|
|
} else {
|
|
$loaded++;
|
|
}
|
|
}
|
|
echo " Preloaded: $loaded classes\n";
|
|
}
|
|
}
|
|
|
|
// ── Worker pool ──────────────────────────────────
|
|
if ($workers > 0 && function_exists('pcntl_fork')) {
|
|
self::$pool = new Q_WebServer_Pool($workers);
|
|
}
|
|
|
|
self::$running = true;
|
|
}
|
|
|
|
/**
|
|
* Start or restart the TLS listener.
|
|
* Uses tcp:// + stream_socket_enable_crypto() for non-blocking
|
|
* TLS handshake. The handshake happens per-connection in the
|
|
* event loop, not during accept.
|
|
*
|
|
* @method startTls
|
|
* @static
|
|
*/
|
|
static function startTls($host, $port)
|
|
{
|
|
if (self::$tlsWatcher) {
|
|
Q_Evented::cancel(self::$tlsWatcher);
|
|
self::$tlsWatcher = null;
|
|
}
|
|
if (self::$tlsSocket) {
|
|
@fclose(self::$tlsSocket);
|
|
self::$tlsSocket = null;
|
|
}
|
|
|
|
if (!Q_WebServer_Certs::validateCerts()) return;
|
|
|
|
// Listen on plain tcp:// — TLS handshake happens after accept
|
|
$errno = $errstr = 0;
|
|
self::$tlsSocket = stream_socket_server(
|
|
"tcp://{$host}:{$port}", $errno, $errstr,
|
|
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
|
|
);
|
|
if (!self::$tlsSocket) {
|
|
echo "[HTTPS] Could not bind to {$host}:{$port} — $errstr\n";
|
|
return;
|
|
}
|
|
stream_set_blocking(self::$tlsSocket, false);
|
|
|
|
self::$tlsWatcher = Q_Evented::onReadable(
|
|
self::$tlsSocket,
|
|
function ($sock) { Q_WebServer::onAcceptTls($sock); }
|
|
);
|
|
echo "[HTTPS] Listening on https://{$host}:{$port}\n";
|
|
}
|
|
|
|
/**
|
|
* Accept a connection on the TLS port and begin
|
|
* non-blocking crypto handshake.
|
|
*
|
|
* @method onAcceptTls
|
|
* @static
|
|
*/
|
|
static function onAcceptTls($serverSocket)
|
|
{
|
|
$client = @stream_socket_accept($serverSocket, 0);
|
|
if (!$client) return;
|
|
|
|
stream_set_blocking($client, false);
|
|
|
|
// Set SSL context options on this specific socket
|
|
$certPath = Q_WebServer_Certs::$certPath;
|
|
$keyPath = Q_WebServer_Certs::$keyPath;
|
|
stream_context_set_option($client, 'ssl', 'local_cert', $certPath);
|
|
stream_context_set_option($client, 'ssl', 'local_pk', $keyPath);
|
|
stream_context_set_option($client, 'ssl', 'allow_self_signed', true);
|
|
stream_context_set_option($client, 'ssl', 'verify_peer', false);
|
|
|
|
$key = (int) $client;
|
|
self::$clients[$key] = $client;
|
|
self::$buffers[$key] = '';
|
|
self::$tlsPending[$key] = true;
|
|
|
|
// Start the handshake — may need multiple attempts
|
|
self::continueTlsHandshake($key);
|
|
}
|
|
|
|
/**
|
|
* Continue a non-blocking TLS handshake.
|
|
* stream_socket_enable_crypto() returns:
|
|
* true → handshake complete
|
|
* false → handshake failed
|
|
* 0 → handshake in progress, try again
|
|
*
|
|
* @method continueTlsHandshake
|
|
* @static
|
|
*/
|
|
static function continueTlsHandshake($key)
|
|
{
|
|
if (!isset(self::$clients[$key])) return;
|
|
$client = self::$clients[$key];
|
|
|
|
$cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_SERVER;
|
|
if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) {
|
|
$cryptoMethod |= STREAM_CRYPTO_METHOD_TLSv1_3_SERVER;
|
|
}
|
|
|
|
$result = @stream_socket_enable_crypto($client, true, $cryptoMethod);
|
|
|
|
if ($result === true) {
|
|
// Handshake complete — treat like a normal client
|
|
unset(self::$tlsPending[$key]);
|
|
self::$clientWatchers[$key] = Q_Evented::onReadable(
|
|
$client,
|
|
function ($c) { Q_WebServer::onClientData($c); }
|
|
);
|
|
} elseif ($result === 0) {
|
|
// In progress — watch for readability to retry
|
|
self::$clientWatchers[$key] = Q_Evented::onReadable(
|
|
$client,
|
|
function ($c) {
|
|
$k = (int) $c;
|
|
// Cancel this watcher and retry handshake
|
|
if (isset(Q_WebServer::$clientWatchers[$k])) {
|
|
Q_Evented::cancel(Q_WebServer::$clientWatchers[$k]);
|
|
unset(Q_WebServer::$clientWatchers[$k]);
|
|
}
|
|
Q_WebServer::continueTlsHandshake($k);
|
|
}
|
|
);
|
|
} else {
|
|
// Failed
|
|
self::closeClient($key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload TLS after cert renewal. Called by Q_WebServer_Certs.
|
|
* New connections will use the new certs. Existing connections
|
|
* keep their old certs until they close (normal behavior).
|
|
*
|
|
* @method reloadTls
|
|
* @static
|
|
*/
|
|
static function reloadTls()
|
|
{
|
|
if (self::$httpsPort) {
|
|
// No need to restart the listener — we set SSL context
|
|
// per-connection in onAcceptTls, so new connections
|
|
// will pick up the new cert files automatically.
|
|
echo "[HTTPS] Certificates reloaded for new connections.\n";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Graceful shutdown: stop accepting new connections,
|
|
* wait for in-flight requests to complete (up to timeout),
|
|
* then close everything.
|
|
* @method stop
|
|
* @static
|
|
* @param {float} $drainTimeout Max seconds to wait for in-flight requests
|
|
*/
|
|
static function stop($drainTimeout = 5.0)
|
|
{
|
|
if (!self::$running) return;
|
|
self::$running = false;
|
|
|
|
// 1. Stop accepting new connections
|
|
if (self::$acceptWatcher) {
|
|
Q_Evented::cancel(self::$acceptWatcher);
|
|
self::$acceptWatcher = null;
|
|
}
|
|
if (self::$tlsWatcher) {
|
|
Q_Evented::cancel(self::$tlsWatcher);
|
|
self::$tlsWatcher = null;
|
|
}
|
|
if (self::$socket) { @fclose(self::$socket); self::$socket = null; }
|
|
if (self::$tlsSocket) { @fclose(self::$tlsSocket); self::$tlsSocket = null; }
|
|
|
|
// 2. Wait for in-flight connections to drain (up to timeout)
|
|
$deadline = microtime(true) + $drainTimeout;
|
|
while (!empty(self::$clients) && microtime(true) < $deadline) {
|
|
Q_Evented::tick(0.1); // process pending I/O briefly
|
|
}
|
|
|
|
// 3. Force-close remaining connections
|
|
foreach (self::$timeoutWatchers as $id) Q_Evented::cancel($id);
|
|
self::$timeoutWatchers = array();
|
|
foreach (self::$clientWatchers as $id) Q_Evented::cancel($id);
|
|
self::$clientWatchers = array();
|
|
foreach (self::$clients as $c) @fclose($c);
|
|
self::$clients = array();
|
|
self::$buffers = array();
|
|
self::$clientInfo = array();
|
|
self::$keepAliveCount = array();
|
|
|
|
// 4. Disconnect WebSockets
|
|
Q_WebSocket::disconnectAll();
|
|
|
|
// 5. Gracefully shut down worker pool (SIGTERM → wait → SIGKILL)
|
|
if (self::$pool) { self::$pool->shutdown(); self::$pool = null; }
|
|
}
|
|
|
|
static function run()
|
|
{
|
|
if (!self::$running) return;
|
|
if (function_exists('pcntl_signal')) {
|
|
Q_Evented::onSignal(SIGINT, function () {
|
|
echo "\n Graceful shutdown (SIGINT)...\n";
|
|
self::stop();
|
|
Q_Evented::stop();
|
|
});
|
|
Q_Evented::onSignal(SIGTERM, function () {
|
|
echo "\n Graceful shutdown (SIGTERM)...\n";
|
|
self::stop();
|
|
Q_Evented::stop();
|
|
});
|
|
// 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'
|
|
<?php
|
|
$json = '';
|
|
while (!feof(STDIN)) { $c = fread(STDIN, 65536); if ($c === false || $c === '') break; $json .= $c; }
|
|
$req = json_decode($json, true);
|
|
if (!$req) { echo json_encode(['status'=>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[] = "<a href=\"{$href}/\" class=\"item dir\"><span class=\"icon\">📁</span><span class=\"name\">{$safe}/</span></a>";
|
|
continue;
|
|
}
|
|
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, self::$allowedExtensions)) continue;
|
|
|
|
$size = filesize($full);
|
|
$sizeStr = $size < 1024 ? "${size} B"
|
|
: ($size < 1048576 ? round($size/1024,1).' KB'
|
|
: round($size/1048576,1).' MB');
|
|
|
|
$files[] = "<a href=\"{$href}\" class=\"item file\"><span class=\"icon\">📄</span><span class=\"name\">{$safe}</span><span class=\"size\">{$sizeStr}</span></a>";
|
|
|
|
// Collect media for preview grid
|
|
if (count($media) < $maxImages) {
|
|
if (in_array($ext, $imageExts)) {
|
|
$media[] = "<div class=\"media-item\"><a href=\"{$href}\"><img src=\"{$href}\" loading=\"lazy\" alt=\"{$safe}\"></a><div class=\"caption\">{$safe}</div></div>";
|
|
} elseif (in_array($ext, $videoExts)) {
|
|
$media[] = "<div class=\"media-item\"><video src=\"{$href}\" controls preload=\"metadata\"></video><div class=\"caption\">{$safe}</div></div>";
|
|
} elseif (in_array($ext, $audioExts)) {
|
|
$media[] = "<div class=\"media-item\"><audio src=\"{$href}\" controls preload=\"metadata\"></audio><div class=\"caption\">{$safe}</div></div>";
|
|
}
|
|
}
|
|
}
|
|
|
|
$safePath = htmlspecialchars($urlPath, ENT_QUOTES);
|
|
$upLink = ($urlPath !== '/')
|
|
? '<a href="../" class="item dir up"><span class="icon">⬆</span><span class="name">Parent Directory</span></a>'
|
|
: '';
|
|
|
|
$mediaSection = '';
|
|
if ($media) {
|
|
$mediaSection = '<div class="divider">Media Preview</div><div class="media-grid">'
|
|
. implode("\n", $media) . '</div>';
|
|
}
|
|
|
|
return <<<HTML
|
|
<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Index of {$safePath}</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,system-ui,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
background:#f8f9fa;color:#333;padding:20px;max-width:960px;margin:0 auto}
|
|
h1{font-size:20px;font-weight:600;padding:16px 0;border-bottom:2px solid #e9ecef;margin-bottom:12px;
|
|
word-break:break-all}
|
|
.listing{display:flex;flex-direction:column;gap:2px}
|
|
.item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;
|
|
text-decoration:none;color:#333;transition:background .15s}
|
|
.item:hover{background:#e9ecef}
|
|
.icon{font-size:18px;flex-shrink:0;width:24px;text-align:center}
|
|
.name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
|
|
.size{color:#868e96;font-size:12px;flex-shrink:0}
|
|
.dir .name{color:#1971c2;font-weight:500}
|
|
.file .name{color:#333}
|
|
.up{border-bottom:1px solid #e9ecef;margin-bottom:4px;padding-bottom:10px}
|
|
.divider{font-size:12px;color:#868e96;text-transform:uppercase;letter-spacing:.5px;
|
|
padding:20px 0 8px;border-top:1px solid #e9ecef;margin-top:16px}
|
|
.media-grid{display:flex;flex-wrap:wrap;gap:12px;padding:8px 0}
|
|
.media-item{max-width:200px;text-align:center}
|
|
.media-item img{max-width:200px;max-height:200px;height:auto;border-radius:6px;
|
|
display:block;margin:0 auto 4px;object-fit:cover}
|
|
.media-item video{max-width:200px;max-height:200px;border-radius:6px;display:block;margin:0 auto 4px}
|
|
.media-item audio{max-width:200px;display:block;margin:0 auto 4px}
|
|
.caption{font-size:11px;color:#868e96;word-break:break-all;max-width:200px}
|
|
@media(max-width:600px){
|
|
body{padding:12px}
|
|
.item{padding:10px 8px}
|
|
.media-item{max-width:calc(50vw - 24px)}
|
|
.media-item img,.media-item video{max-width:100%}
|
|
}
|
|
</style>
|
|
</head><body>
|
|
<h1>Index of {$safePath}</h1>
|
|
<div class="listing">
|
|
{$upLink}
|
|
HTML
|
|
. implode("\n", $dirs) . "\n"
|
|
. implode("\n", $files)
|
|
. "\n</div>\n"
|
|
. $mediaSection
|
|
. "\n</body></html>";
|
|
}
|
|
|
|
// ── MIME types ────────────────────────────────────────
|
|
|
|
static function mimeType($ext)
|
|
{
|
|
static $types = array(
|
|
'html'=>'text/html; charset=utf-8', 'htm'=>'text/html; charset=utf-8',
|
|
'css'=>'text/css; charset=utf-8', 'js'=>'application/javascript; charset=utf-8',
|
|
'mjs'=>'application/javascript; charset=utf-8', 'json'=>'application/json; charset=utf-8',
|
|
'xml'=>'application/xml', 'txt'=>'text/plain; charset=utf-8',
|
|
'md'=>'text/plain; charset=utf-8', 'csv'=>'text/csv; charset=utf-8',
|
|
'yaml'=>'text/yaml', 'yml'=>'text/yaml', 'log'=>'text/plain; charset=utf-8',
|
|
'map'=>'application/json',
|
|
'png'=>'image/png', 'jpg'=>'image/jpeg', 'jpeg'=>'image/jpeg',
|
|
'gif'=>'image/gif', 'webp'=>'image/webp', 'svg'=>'image/svg+xml',
|
|
'bmp'=>'image/bmp', 'ico'=>'image/x-icon', 'avif'=>'image/avif',
|
|
'woff'=>'font/woff', 'woff2'=>'font/woff2',
|
|
'ttf'=>'font/ttf', 'otf'=>'font/otf',
|
|
'mp3'=>'audio/mpeg', 'wav'=>'audio/wav', 'ogg'=>'audio/ogg',
|
|
'mp4'=>'video/mp4', 'webm'=>'video/webm',
|
|
'pdf'=>'application/pdf', 'zip'=>'application/zip',
|
|
'wasm'=>'application/wasm',
|
|
);
|
|
return $types[$ext] ?? 'application/octet-stream';
|
|
}
|
|
|
|
// ── Q_Dispatcher bridge ──────────────────────────────
|
|
|
|
static function dispatchToQ($parsed)
|
|
{
|
|
$saved = array($_SERVER, $_GET, $_POST, $_REQUEST, $_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 "<!DOCTYPE html><html><head><title>404</title>"
|
|
. "<style>body{font-family:sans-serif;padding:40px;text-align:center;color:#666}"
|
|
. "h1{font-size:72px;color:#ddd}p{margin-top:12px}</style></head>"
|
|
. "<body><h1>404</h1><p>{$safe} not found</p></body></html>";
|
|
}
|
|
|
|
// ── Path resolution ──────────────────────────────────
|
|
|
|
/**
|
|
* Parse a Cookie header string into an associative array
|
|
* @method parseCookieHeader
|
|
* @static
|
|
* @param {string} $header The raw Cookie header value
|
|
* @return {array} name => value pairs
|
|
*/
|
|
static function parseCookieHeader($header)
|
|
{
|
|
$cookies = array();
|
|
if (empty($header)) return $cookies;
|
|
$pairs = explode(';', $header);
|
|
foreach ($pairs as $pair) {
|
|
$pair = trim($pair);
|
|
if ($pair === '') continue;
|
|
$eq = strpos($pair, '=');
|
|
if ($eq === false) {
|
|
$cookies[$pair] = '';
|
|
} else {
|
|
$name = trim(substr($pair, 0, $eq));
|
|
$value = trim(substr($pair, $eq + 1));
|
|
$cookies[$name] = urldecode($value);
|
|
}
|
|
}
|
|
return $cookies;
|
|
}
|
|
|
|
/**
|
|
* Check rate limit for a client IP. Returns true if allowed, false if over limit.
|
|
* Configured via Q.webserver.rateLimit:
|
|
* { "enabled": true, "requests": 100, "window": 60, "burstRequests": 20, "burstWindow": 1 }
|
|
* @method checkRateLimit
|
|
* @static
|
|
* @param {string} $ip Client IP address
|
|
* @return {boolean} true if request is allowed
|
|
*/
|
|
static function checkRateLimit($ip)
|
|
{
|
|
if (!Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false)) {
|
|
return true;
|
|
}
|
|
$now = time();
|
|
$maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100);
|
|
$window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60);
|
|
$burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20);
|
|
$burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1);
|
|
|
|
// Clean old entries
|
|
if (!isset(self::$rateLimitData[$ip])) {
|
|
self::$rateLimitData[$ip] = array();
|
|
}
|
|
$hits = &self::$rateLimitData[$ip];
|
|
$cutoff = $now - $window;
|
|
$hits = array_filter($hits, function ($t) use ($cutoff) {
|
|
return $t >= $cutoff;
|
|
});
|
|
|
|
// Check window limit
|
|
if (count($hits) >= $maxReqs) {
|
|
return false;
|
|
}
|
|
|
|
// Check burst limit
|
|
$burstCutoff = $now - $burstWindow;
|
|
$recent = array_filter($hits, function ($t) use ($burstCutoff) {
|
|
return $t >= $burstCutoff;
|
|
});
|
|
if (count($recent) >= $burstReqs) {
|
|
return false;
|
|
}
|
|
|
|
$hits[] = $now;
|
|
|
|
// Periodic cleanup: remove IPs not seen in the last window
|
|
if (mt_rand(0, 99) < 5) { // 5% chance per request
|
|
foreach (self::$rateLimitData as $k => $v) {
|
|
if (empty($v) || max($v) < $cutoff) {
|
|
unset(self::$rateLimitData[$k]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static function resolveStatic($urlPath)
|
|
{
|
|
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
|
|
// 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;
|
|
}
|