Files
feuerwerk/api/lib/SessionStore.php
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:43 +02:00

121 lines
4.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Logins, kept as one file per session in `data/sessions/`, named after the SHA-256 of the token
* the browser holds in its cookie – so the file on disk cannot be replayed as a token.
*
* Deliberately not PHP's own session handler: those expire after `session.gc_maxlifetime`
* (24 minutes by default), and a login here has to outlive a deployment and a night, since
* `npm run build` replaces the editor under the very tab that is signed in.
*/
final class SessionStore
{
/** How long a login lasts without being used; every request that finds it pushes it out again. */
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
public const COOKIE = 'feuerwerk_session';
public function __construct(private readonly string $dir) {}
/** Starts a session for `$username` and returns the token for the cookie. */
public function create(string $username): string
{
$this->sweep();
$token = bin2hex(random_bytes(32));
$this->write($token, ['username' => $username, 'createdAt' => time(), 'seenAt' => time()]);
return $token;
}
/** The user behind a token, or null when it is unknown or has expired. */
public function user(string $token): ?string
{
$path = $this->path($token);
if ($path === null || !is_file($path)) {
return null;
}
$raw = file_get_contents($path);
$data = $raw === false ? null : json_decode($raw, true);
if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['seenAt'] ?? null)) {
return null;
}
if (time() - $data['seenAt'] > self::LIFETIME_SECONDS) {
@unlink($path);
return null;
}
// Touched at most once an hour: every request rewriting the file would cost a write per
// autosave, and the lifetime is measured in weeks.
if (time() - $data['seenAt'] > 3600) {
$data['seenAt'] = time();
$this->write($token, $data);
}
return $data['username'];
}
public function destroy(string $token): void
{
$path = $this->path($token);
if ($path !== null) {
@unlink($path);
}
}
/**
* Ends every login of `$username` but the one behind `$keep` – what a password change does, so
* that a session started with the old password cannot outlive it. Returns how many were ended.
*/
public function destroyAllFor(string $username, string $keep = ''): int
{
$keepPath = $keep === '' ? null : $this->path($keep);
$ended = 0;
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
if ($file === $keepPath) {
continue;
}
$raw = file_get_contents($file);
$data = $raw === false ? null : json_decode($raw, true);
if (is_array($data) && strcasecmp((string) ($data['username'] ?? ''), $username) === 0) {
@unlink($file);
$ended++;
}
}
return $ended;
}
/** Throws out what has timed out, so the directory does not grow with every login. */
private function sweep(): void
{
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
if (time() - (int) @filemtime($file) > self::LIFETIME_SECONDS) {
@unlink($file);
}
}
}
private function path(string $token): ?string
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
return $this->dir . '/' . hash('sha256', $token) . '.json';
}
private function write(string $token, array $data): void
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create session directory {$this->dir}");
}
$path = $this->path($token);
if ($path === null) {
throw new RuntimeException('Invalid session token');
}
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Cannot encode session');
}
// Replaced rather than rewritten in place: a request reading it meanwhile would find it
// empty, and an empty session is a signed-out one.
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'session file');
}
}