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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60ea56a2ce
commit
8e4bb6e1f9
38 files changed
+1530
-139
No files matched your search
+62
-3
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user