User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
game-maker / gate (push) Successful in 2m5s
game-maker / suite (push) Failing after 6m22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 12:50:52 +02:00
1 parent 60ea56a2ce
commit 8e4bb6e1f9
38 files changed
+1530 -139

No files matched your search

+62 -3
View File
@@ -7,6 +7,13 @@ declare(strict_types=1);
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
* site's nginx configuration does not pass PATH_INFO to PHP.
*
* GET ?r=auth/session → {user} (null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user} | 401
* POST ?r=auth/logout → 204
*
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
* of somebody else answers like one that is not there.
*
* GET ?r=projects → [{id,name,revision,updatedAt}]
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
@@ -36,13 +43,16 @@ declare(strict_types=1);
* `data/sounds/` as static files; the API only writes them.
*/
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/PluginStore.php';
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SessionStore.php';
require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php';
require __DIR__ . '/lib/UserStore.php';
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
const MAX_BODY_BYTES = 20 * 1024 * 1024;
@@ -61,9 +71,47 @@ set_error_handler(static function (int $severity, string $message, string $file,
});
/** @param list<string> $parts */
function routeProjects(array $parts, string $method): never
function routeAuth(array $parts, string $method, Auth $auth): never
{
$store = new Store(dirname(__DIR__) . '/data/projects');
$what = $parts[1] ?? '';
if ($what === 'session') {
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
// Always 200, never 401: "nobody is signed in" is an answer, not a failure, and the
// login screen asks this before it can know either way.
Response::json(['user' => $auth->user()]);
}
if ($what === 'login') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$body = Response::body();
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
if ($user === null) {
// One message for both halves: which of the two was wrong is the attacker's question.
Response::error('Wrong user name or password', 401);
}
Response::json(['user' => $user]);
}
if ($what === 'logout') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$auth->logOut();
Response::noContent();
}
Response::error('Not found', 404);
}
/** @param list<string> $parts */
function routeProjects(array $parts, string $method, string $owner): never
{
$store = new Store(dirname(__DIR__) . '/data/projects', $owner);
if (count($parts) === 1) {
if ($method === 'GET') {
@@ -274,8 +322,19 @@ try {
Response::error('Not found', 404);
}
$auth = new Auth(
new UserStore(dirname(__DIR__) . '/data/users.json'),
new SessionStore(dirname(__DIR__) . '/data/sessions'),
);
if ($parts[0] === 'auth') {
routeAuth($parts, $method, $auth);
}
// Images, fonts, sounds and plugins are shared between accounts; being signed in is still the
// price of admission, so an uploaded file cannot be dropped in by a stranger.
$user = $auth->require();
match ($parts[0]) {
'projects' => routeProjects($parts, $method),
'projects' => routeProjects($parts, $method, $user),
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method),
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Who is asking. The browser sends the session cookie; the check tools log in the same way and
* send the same cookie, so there is exactly one way into the API.
*/
final class Auth
{
public function __construct(
private readonly UserStore $users,
private readonly SessionStore $sessions,
) {}
/** The signed-in user, or null when nobody is. */
public function user(): ?string
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
return $token === '' ? null : $this->sessions->user($token);
}
/** The signed-in user, or a 401 answer – every route but `auth/*` goes through here. */
public function require(): string
{
$user = $this->user();
if ($user === null) {
Response::error('Sign in first', 401);
}
return $user;
}
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
public function logIn(string $username, string $password): ?string
{
$name = $this->users->verify($username, $password);
if ($name === null) {
return null;
}
$this->setCookie($this->sessions->create($name), time() + SessionStore::LIFETIME_SECONDS);
return $name;
}
public function logOut(): void
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
if ($token !== '') {
$this->sessions->destroy($token);
}
$this->setCookie('', time() - 3600);
}
private function setCookie(string $value, int $expires): void
{
setcookie(SessionStore::COOKIE, $value, [
'expires' => $expires,
'path' => self::basePath(),
'httponly' => true,
'samesite' => 'Lax',
'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
]);
}
/**
* The path the editor is served under (`/game-maker/`), read off the script rather than
* written down: the same checkout answers under a different prefix in CI.
*/
private static function basePath(): string
{
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/game-maker/api/index.php');
$base = dirname(dirname($script));
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
}
}
+96
View File
@@ -0,0 +1,96 @@
<?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 = 'game_maker_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);
}
}
/** 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 || file_put_contents($path, $json, LOCK_EX) === false) {
throw new RuntimeException('Cannot write session file');
}
@chmod($path, 0640);
}
}
+27 -8
View File
@@ -12,14 +12,20 @@ final class StaleRevisionException extends RuntimeException
/**
* File-based project store: one JSON document per project in `data/projects/{id}.json`.
* Each file wraps the project with metadata: { id, name, revision, updatedAt, project }.
* Each file wraps the project with metadata: { id, name, owner, revision, updatedAt, project }.
* Writes are atomic (temp file + rename) and guarded by an integer revision so that a
* stale client (second tab, old session) cannot silently overwrite newer work.
*
* A store belongs to one signed-in user: it lists, reads and writes only the projects whose
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
* because "this id exists" is already more than a stranger should learn.
*/
final class Store
{
public function __construct(private readonly string $dir)
{
public function __construct(
private readonly string $dir,
private readonly string $owner,
) {
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create data directory {$this->dir}");
}
@@ -41,7 +47,7 @@ final class Store
$items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$doc = $this->readFile($file);
if ($doc === null) {
if ($doc === null || $doc['owner'] !== $this->owner) {
continue;
}
$items[] = [
@@ -55,13 +61,13 @@ final class Store
return $items;
}
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array}|null */
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array}|null */
public function load(string $id): ?array
{
return $this->readFile($this->path($id));
return $this->own($this->readFile($this->path($id)));
}
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array} */
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array} */
public function create(string $name): array
{
do {
@@ -93,7 +99,7 @@ final class Store
throw new RuntimeException('Cannot acquire project lock');
}
try {
$current = $this->readFile($path);
$current = $this->own($this->readFile($path));
if ($current === null) {
return null;
}
@@ -114,10 +120,19 @@ final class Store
public function delete(string $id): bool
{
if ($this->load($id) === null) {
return false;
}
$path = $this->path($id);
return file_exists($path) && unlink($path);
}
/** The document, or null when it belongs to someone else. */
private function own(?array $doc): ?array
{
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
}
private function path(string $id): string
{
return $this->dir . '/' . $id . '.json';
@@ -128,6 +143,7 @@ final class Store
return [
'id' => $id,
'name' => $name,
'owner' => $this->owner,
'revision' => $revision,
'updatedAt' => gmdate('c'),
'project' => $project,
@@ -145,6 +161,9 @@ final class Store
return null;
}
$doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled';
// A file without an owner belongs to nobody and is therefore invisible to everybody –
// the safe way round, and the one that shows up straight away if a migration is missed.
$doc['owner'] = is_string($doc['owner'] ?? null) ? $doc['owner'] : '';
$doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : '';
$doc['revision'] = (int) $doc['revision'];
return $doc;
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
* where `password` is a hash from `password_hash` – the plain one is never stored.
*
* Accounts are made from the command line (`tools/make-user.php`), not through the API: signing
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
*/
final class UserStore
{
public function __construct(private readonly string $file) {}
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
}
/** The name as stored, or null when the pair does not match an account. */
public function verify(string $username, string $password): ?string
{
$user = $this->find($username);
if ($user === null || !password_verify($password, $user['password'])) {
return null;
}
return $user['username'];
}
public function exists(string $username): bool
{
return $this->find($username) !== null;
}
/** @return list<string> */
public function names(): array
{
return array_map(static fn(array $u): string => $u['username'], $this->all());
}
/** Adds the account or replaces the password of one that is already there. */
public function put(string $username, string $password): void
{
if (!self::isValidName($username)) {
throw new InvalidArgumentException("Invalid user name: {$username}");
}
$users = array_values(
array_filter(
$this->all(),
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
),
);
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
$this->write($users);
}
/** @return array{username:string,password:string}|null */
private function find(string $username): ?array
{
foreach ($this->all() as $user) {
if (strcasecmp($user['username'], $username) === 0) {
return $user;
}
}
return null;
}
/** @return list<array{username:string,password:string}> */
private function all(): array
{
if (!is_file($this->file)) {
return [];
}
$raw = file_get_contents($this->file);
$data = $raw === false ? null : json_decode($raw, true);
if (!is_array($data) || !is_array($data['users'] ?? null)) {
return [];
}
$users = [];
foreach ($data['users'] as $user) {
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
$users[] = ['username' => $user['username'], 'password' => $user['password']];
}
}
return $users;
}
/** @param list<array{username:string,password:string}> $users */
private function write(array $users): void
{
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException("Cannot create data directory {$dir}");
}
$json = json_encode(['users' => $users], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Cannot encode users');
}
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) {
@unlink($tmp);
throw new RuntimeException('Cannot write users file');
}
@chmod($this->file, 0640);
}
}