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

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