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>
121 lines
4.4 KiB
PHP
121 lines
4.4 KiB
PHP
<?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');
|
||
}
|
||
}
|