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
+
+
+
+
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;
}