diff --git a/setup/SetupManager.php b/setup/SetupManager.php index 3508c02..aaac69c 100644 --- a/setup/SetupManager.php +++ b/setup/SetupManager.php @@ -97,6 +97,8 @@ public function loadFormValues(array $env): array return [ 'storage_path' => $env['STORAGE_PATH'] ?? 'data', 'session_lifetime' => $env['SESSION_LIFETIME_HOURS'] ?? '24', + 'session_mode' => ($env['SESSION_ID'] ?? '') !== '' ? 'static' : 'random', + 'session_id' => $env['SESSION_ID'] ?? '', ]; } @@ -155,6 +157,15 @@ public function saveEnv(array $body): array $content .= "# Session Settings\n"; $content .= 'SESSION_LIFETIME_HOURS=' . (is_string($body['session_lifetime'] ?? null) ? $body['session_lifetime'] : '24') . "\n"; + $sessionMode = is_string($body['session_mode'] ?? null) ? $body['session_mode'] : 'random'; + $sessionId = ''; + + if ($sessionMode === 'static' && is_string($body['session_id'] ?? null) && $body['session_id'] !== '') { + $sessionId = $body['session_id']; + } + + $content .= 'SESSION_ID=' . $sessionId . "\n"; + if (file_put_contents($this->envPath, $content) === false) { return [ 'success' => false, diff --git a/setup/assets/script.js b/setup/assets/script.js index 26d0292..650d204 100644 --- a/setup/assets/script.js +++ b/setup/assets/script.js @@ -14,15 +14,36 @@ /** * Collects and returns the current values of all configuration fields. * - * @returns {{ storage_path: string, session_lifetime: string }} + * @returns {{ storage_path: string, session_lifetime: string, session_mode: string, session_id: string }} */ function getFormData() { + let sessionModeInput = document.querySelector('input[name="sessionMode"]:checked'); + let sessionMode = sessionModeInput ? sessionModeInput.value : 'random'; + let sessionId = sessionMode === 'static' ? (document.getElementById('sessionId')?.value || '') : ''; + return { storage_path: document.getElementById('storagePath').value, session_lifetime: document.getElementById('sessionLifetime').value, + session_mode: sessionMode, + session_id: sessionId, }; } + /** + * Toggles visibility of the static session ID input field. + * + * @returns {void} + */ + window.toggleSessionIdInput = function () { + let staticRadio = document.querySelector('input[name="sessionMode"][value="static"]'); + let staticRow = document.getElementById('staticSessionIdRow'); + + if (!staticRadio || !staticRow) return; + + let isStatic = staticRadio.checked; + staticRow.style.display = isStatic ? 'block' : 'none'; + }; + /** * Displays an alert message inside the element with the given ID. * diff --git a/setup/template.php b/setup/template.php index f114c3d..81414b1 100644 --- a/setup/template.php +++ b/setup/template.php @@ -181,6 +181,38 @@ function renderWizard(bool $envExists, array $values): void
+ +
Session ID Mode
+
+
+ + +
+
+ +
+
+ + + + Leave empty to auto-generate a static ID on first request + +
+
+
Session
diff --git a/src/Application.php b/src/Application.php index 234afda..7367ca1 100644 --- a/src/Application.php +++ b/src/Application.php @@ -86,6 +86,9 @@ private function registerRoutes(Controller $controller, StreamController $stream { $this->router->get('/', [$controller, 'dashboard']); + $this->router->get('/api/config', [$controller, 'getConfig']); + + $this->router->post('/api/session', [$controller, 'createSession']); $this->router->post('/api/session', [$controller, 'createSession']); $this->router->delete('/api/session/{id}', [$controller, 'deleteSession']); diff --git a/src/Config.php b/src/Config.php index ab2e929..936ae3b 100644 --- a/src/Config.php +++ b/src/Config.php @@ -29,7 +29,7 @@ final class Config { /** @var string */ - private string $version = '0.2.2'; + private string $version = '0.3.0'; /** @var self */ private static self $instance; @@ -50,6 +50,9 @@ final class Config /** @var string */ private string $storagePath; + /** @var string */ + private string $sessionId; + private function __construct() { $sessionLifetime = isset($_ENV['SESSION_LIFETIME_HOURS']) && is_numeric($_ENV['SESSION_LIFETIME_HOURS']) @@ -62,6 +65,10 @@ private function __construct() ? $_ENV['STORAGE_PATH'] : 'data'; + $this->sessionId = isset($_ENV['SESSION_ID']) && is_string($_ENV['SESSION_ID']) + ? $_ENV['SESSION_ID'] + : ''; + $scriptName = isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '/index.php'; @@ -135,4 +142,14 @@ private static function instance(): self return self::$instance; } + + /** + * Returns the configured static session ID (empty string if random mode). + * + * @return string + */ + public static function sessionId(): string + { + return self::instance()->sessionId; + } } diff --git a/src/Http/Controller.php b/src/Http/Controller.php index 1dc8077..0f40c96 100644 --- a/src/Http/Controller.php +++ b/src/Http/Controller.php @@ -20,6 +20,7 @@ use DebugPHP\Server\Storage\MetricRepository; use DebugPHP\Server\Storage\SessionRepository; use DebugPHP\Server\Request; +use DebugPHP\Server\Config; /** * Handles all HTTP requests for the DebugPHP API. @@ -124,13 +125,11 @@ public function storeEntry(): void if ($sessionId === '') { $this->json(['error' => 'Missing session ID'], 400); - return; } - if ($this->sessions->find($sessionId) === null) { + if (!$this->ensureSession($sessionId)) { $this->json(['error' => 'Session not found or expired'], 404); - return; } @@ -248,13 +247,11 @@ public function storeMetric(): void if ($sessionId === '') { $this->json(['error' => 'Missing session ID'], 400); - return; } - if ($this->sessions->find($sessionId) === null) { + if (!$this->ensureSession($sessionId)) { $this->json(['error' => 'Session not found or expired'], 404); - return; } @@ -301,13 +298,11 @@ public function storeEnvironment(): void if ($sessionId === '') { $this->json(['error' => 'Missing session ID'], 400); - return; } - if ($this->sessions->find($sessionId) === null) { + if (!$this->ensureSession($sessionId)) { $this->json(['error' => 'Session not found or expired'], 404); - return; } @@ -330,4 +325,43 @@ private function json(array $data, int $status = 200): void header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); } + + /** + * Returns the public server configuration. + * + * GET /api/config + * Response: JSON with static_session flag. + */ + public function getConfig(): void + { + $this->json([ + 'static_session' => Config::sessionId() !== '', + 'version' => Config::version(), + ]); + } + + /** + * Ensures a session exists. For static sessions, creates them auto-matically if missing. + * For random sessions, returns false if not found. + * + * @param string $sessionId The session ID from the request. + * + * @return bool True if session exists/was created, false otherwise. + */ + private function ensureSession(string $sessionId): bool + { + $staticId = Config::sessionId(); + + if ($staticId !== '' && $sessionId === $staticId) { + $session = $this->sessions->find($sessionId); + + if ($session === null) { + $this->sessions->create(); + } + + return true; + } + + return $this->sessions->find($sessionId) !== null; + } } diff --git a/src/Http/StreamController.php b/src/Http/StreamController.php index 0595de1..c77a890 100644 --- a/src/Http/StreamController.php +++ b/src/Http/StreamController.php @@ -19,6 +19,7 @@ use DebugPHP\Server\Storage\EnvironmentRepository; use DebugPHP\Server\Storage\MetricRepository; use DebugPHP\Server\Storage\SessionRepository; +use DebugPHP\Server\Config; /** * Server-Sent Events (SSE) stream controller. @@ -80,11 +81,16 @@ public function __construct( */ public function handle(string $sessionId): void { - if ($this->sessions->find($sessionId) === null) { + $staticId = Config::sessionId(); + if ($staticId !== '' && $sessionId === $staticId) { + $session = $this->sessions->find($sessionId); + if ($session === null) { + $this->sessions->create(); + } + } elseif ($this->sessions->find($sessionId) === null) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'Session not found or expired']); - return; } diff --git a/src/Router.php b/src/Router.php index 2062ad0..548cd97 100644 --- a/src/Router.php +++ b/src/Router.php @@ -118,9 +118,10 @@ public function dispatch(Request $request): void /** * Adds a route and converts the path into a regex pattern. * - * Supports two placeholder types: - * {name} → (?P[a-f0-9]{32}) matches 32-char hex session IDs - * {name:int} → (?P\d+) matches numeric entry IDs + * Supports three placeholder types: + * {name} → (?P[a-zA-Z0-9_-]+) matches alphanumeric session IDs (min 1 char) + * {name:hex} → (?P[a-f0-9]{32}) matches 32-char hex session IDs + * {name:int} → (?P\d+) matches numeric entry IDs * * @param string $method HTTP method (GET, POST, DELETE). * @param string $path The URL path with optional {placeholders}. @@ -137,11 +138,12 @@ static function (array $matches) use (&$params): string { /** @var list $params */ $params[] = $matches[1]; - $type = $matches[2] ?? 'hex'; + $type = $matches[2] ?? 'default'; return match ($type) { 'int' => '(?P<' . $matches[1] . '>\d+)', - default => '(?P<' . $matches[1] . '>[a-f0-9]{32})', + 'hex' => '(?P<' . $matches[1] . '>[a-f0-9]{32})', + default => '(?P<' . $matches[1] . '>[a-zA-Z0-9_-]+)', }; }, $path, diff --git a/src/Storage/SessionRepository.php b/src/Storage/SessionRepository.php index fe479a4..4b5f02a 100644 --- a/src/Storage/SessionRepository.php +++ b/src/Storage/SessionRepository.php @@ -56,7 +56,29 @@ public function create(): array { $this->deleteExpired(); - $id = bin2hex(random_bytes(16)); + $staticId = Config::sessionId(); + + if ($staticId !== '') { + $existing = $this->find($staticId); + + if ($existing !== null) { + $expiresAt = date('Y-m-d H:i:s', time() + (Config::sessionLifetimeHours() * 3600)); + $existing['expires_at'] = $expiresAt; + + file_put_contents( + $this->storage->sessionFile($staticId), + json_encode($existing, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + LOCK_EX, + ); + + return $existing; + } + + $id = $staticId; + } else { + $id = bin2hex(random_bytes(16)); + } + $createdAt = date('Y-m-d H:i:s'); $expiresAt = date('Y-m-d H:i:s', time() + (Config::sessionLifetimeHours() * 3600)); @@ -107,10 +129,26 @@ public function find(string $id): ?array /** @var array{id: string, created_at: string, expires_at: string} $session */ $session = json_decode($content, true, 512, JSON_THROW_ON_ERROR); - // Check expiry - if (strtotime($session['expires_at']) <= time()) { - $this->delete($id); + $staticId = Config::sessionId(); + + if ($staticId !== '' && $session['id'] === $staticId) { + $currentExpiry = strtotime($session['expires_at']); + $now = time(); + + if ($currentExpiry <= $now) { + $session['expires_at'] = date('Y-m-d H:i:s', $now + (Config::sessionLifetimeHours() * 3600)); + + file_put_contents( + $file, + json_encode($session, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + LOCK_EX, + ); + } + + return $session; + } + if (strtotime($session['expires_at']) <= time()) { return null; }