Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions setup/SetupManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? '',
];
}

Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion setup/assets/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
32 changes: 32 additions & 0 deletions setup/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,38 @@ function renderWizard(bool $envExists, array $values): void

<div class="form-divider"></div>

<!-- Session ID Mode -->
<div class="section-label">Session ID Mode</div>
<div class="form-row full">
<div class="form-group">
<label class="form-label">
<input type="radio" name="sessionMode" value="random"
<?= ($values['session_mode'] ?? 'random') === 'random' ? 'checked' : '' ?>
onchange="toggleSessionIdInput()">
Random (new ID per dashboard load)
</label>
<label class="form-label" style="margin-top: 8px;">
<input type="radio" name="sessionMode" value="static"
<?= ($values['session_mode'] ?? 'random') === 'static' ? 'checked' : '' ?>
onchange="toggleSessionIdInput()">
Static (fixed Session ID)
</label>
</div>
</div>

<div class="form-row full" id="staticSessionIdRow"
style="display: <?= ($values['session_mode'] ?? 'random') === 'static' ? 'block' : 'none' ?>">
<div class="form-group">
<label class="form-label">Static Session ID</label>
<input class="form-input" id="sessionId" type="text"
value="<?= e($values['session_id']) ?>"
placeholder="e.g. my-debug-session">
<small style="color: var(--text-muted); font-size: 13px;">
Leave empty to auto-generate a static ID on first request
</small>
</div>
</div>

<!-- Session -->
<div class="section-label">Session</div>
<div class="form-row full">
Expand Down
3 changes: 3 additions & 0 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
19 changes: 18 additions & 1 deletion src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'])
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
}
52 changes: 43 additions & 9 deletions src/Http/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
10 changes: 8 additions & 2 deletions src/Http/StreamController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
12 changes: 7 additions & 5 deletions src/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<name>[a-f0-9]{32}) matches 32-char hex session IDs
* {name:int} → (?P<name>\d+) matches numeric entry IDs
* Supports three placeholder types:
* {name} → (?P<name>[a-zA-Z0-9_-]+) matches alphanumeric session IDs (min 1 char)
* {name:hex} → (?P<name>[a-f0-9]{32}) matches 32-char hex session IDs
* {name:int} → (?P<name>\d+) matches numeric entry IDs
*
* @param string $method HTTP method (GET, POST, DELETE).
* @param string $path The URL path with optional {placeholders}.
Expand All @@ -137,11 +138,12 @@ static function (array $matches) use (&$params): string {
/** @var list<string> $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,
Expand Down
46 changes: 42 additions & 4 deletions src/Storage/SessionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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;
}

Expand Down