Let guests try a demo project without an account (#428)
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s

The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.

- Demo copies are frozen copies like a release, in data/demos/. An
  administrator makes, updates and removes one with "Demo copy..." in
  the project's main menu. GET demos and GET demos/{token} need no
  session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
  as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
  list carries it with demo: true, for accounts and guests alike.
  Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
  copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
  would write refuses in the tab before a request is made, and the
  autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
  pickers drop Upload and Delete, a dropped or pasted file is refused,
  and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
  "Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
  sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
  on. It only reads the server's own modules.

Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
  autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
  rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:58:30 +02:00
1 parent ce1f25f2eb
commit 925a39a276
30 files changed
+1397 -128

No files matched your search

+133 -23
View File
@@ -58,10 +58,19 @@ declare(strict_types=1);
* DELETE ?r=projects/{id}/release → 204 | 404
* (one link per project, kept when publishing again, #266)
*
* Released games are the one thing open to everybody, the token being the key:
* Released games are open to everybody, the token being the key:
*
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
*
* So are the demo copies a guest opens in the editor (#428), frozen copies like a release that an
* administrator makes and updates per project:
*
* GET ?r=demos → [{token,name,publishedAt}], by name
* GET ?r=demos/{token} → {name,publishedAt,project} | 404
* GET ?r=projects/{id}/demo → {token,revision,publishedAt} | null (administrators only)
* POST ?r=projects/{id}/demo → {token,revision,publishedAt} (administrators only)
* DELETE ?r=projects/{id}/demo → 204 | 404 (administrators only)
*
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt,owner,deletable}]
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
* DELETE ?r=textures/{file} → 204 | 403 | 404 | 409 (still used)
@@ -89,12 +98,22 @@ declare(strict_types=1);
* (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and
* nobody's, an administrator's every file; `owner` is the account a file belongs to (null for
* nobody, '' for a deleted account). A file goes only at its owner's or an administrator's hand,
* and only while no project, snapshot or release uses it.
* and only while no project, snapshot, release or demo copy uses it.
*
* An administrator offers a file to everybody as demo material (#428); every list carries those
* files too, with `demo: true`, and without a session a list holds nothing else. Only an
* administrator deletes one while it is offered.
*
* PATCH ?r=textures/{file} {demo} → 204 | 403 | 404 | 422 (administrators only)
* PATCH ?r=fonts/{file} {demo} → the same
* PATCH ?r=sounds/{file} {demo} → the same
* PATCH ?r=videos/{file} {demo} → the same
*
* POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130)
*
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
* GET ?r=plugins/{file} → the module source as text/javascript
* (both without a session as well: a guest's demo may switch one on, #428)
*
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`
@@ -107,6 +126,7 @@ declare(strict_types=1);
require __DIR__ . '/lib/AccountRules.php';
require __DIR__ . '/lib/AtomicFile.php';
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/DemoFiles.php';
require __DIR__ . '/lib/FileUsage.php';
require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it
require __DIR__ . '/lib/FontStore.php';
@@ -305,6 +325,14 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
if (($parts[2] ?? null) === 'release' && count($parts) === 3) {
routeRelease($method, $store, $releases, $id);
}
$demos = new ReleaseStore(DATA_DIR . '/demos');
if (($parts[2] ?? null) === 'demo' && count($parts) === 3) {
// What guests get to see is the administrators' call (#428).
if (!$seesAll) {
Response::error('Administrators only', 403);
}
routeRelease($method, $store, $demos, $id, 'Project has no demo copy');
}
if (count($parts) > 2) {
Response::error('Not found', 404);
}
@@ -349,6 +377,7 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
}
$snapshots->deleteAll($id);
$releases->takeDown($id);
$demos->takeDown($id);
Response::noContent();
}
@@ -432,11 +461,17 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor
}
/**
* The released copy of one project (#266). Whoever may load the project may publish it, update it
* and take it down; publishing copies the project as stored, not what a tab has not saved yet.
* The released copy of one project (#266), or its demo copy (#428). Whoever may load the project may
* publish it, update it and take it down; publishing copies the project as stored, not what a tab
* has not saved yet.
*/
function routeRelease(string $method, Store $store, ReleaseStore $releases, string $id): never
{
function routeRelease(
string $method,
Store $store,
ReleaseStore $releases,
string $id,
string $none = 'Project is not released',
): never {
$doc = $store->load($id);
if ($doc === null) {
Response::error('Project not found', 404);
@@ -449,26 +484,32 @@ function routeRelease(string $method, Store $store, ReleaseStore $releases, stri
}
if ($method === 'DELETE') {
if (!$releases->takeDown($id)) {
Response::error('Project is not released', 404);
Response::error($none, 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
/** The one route open without a sign-in: a released game, found by its token. */
function routeReleases(array $parts, string $method): never
/**
* What is open without a sign-in: a released game (#266) and a demo copy (#428), each found by its
* token. The demos are listed as well, for a guest to pick one.
*/
function routeCopies(array $parts, string $method, ReleaseStore $copies, string $missing, bool $listed): never
{
if ($method !== 'GET' || count($parts) !== 2) {
if ($method !== 'GET' || count($parts) > 2 || (count($parts) === 1 && !$listed)) {
Response::error('Not found', 404);
}
$release = (new ReleaseStore(DATA_DIR . '/releases'))->load($parts[1]);
if ($release === null) {
Response::error('Game not found', 404);
if (count($parts) === 1) {
Response::json($copies->list());
}
$copy = $copies->load($parts[1]);
if ($copy === null) {
Response::error($missing, 404);
}
// The copy goes out under the token, not the project's own id: that one is nobody's business.
$project = ['id' => $release['token']] + $release['project'];
Response::json(['name' => $release['name'], 'publishedAt' => $release['publishedAt'], 'project' => $project]);
$project = ['id' => $copy['token']] + $copy['project'];
Response::json(['name' => $copy['name'], 'publishedAt' => $copy['publishedAt'], 'project' => $project]);
}
function snapshotName(array $body): string
@@ -598,7 +639,8 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
* Images, fonts and sounds, stored under the hash of their bytes in a folder per account (#344).
* Everybody signed in uploads into their own folder and lists their own files and nobody's; an
* administrator lists every file. Deleting one is for its owner or an administrator, and refused
* while a project of any account still uses it.
* while a project of any account still uses it. What an administrator offers as demo material
* (#428) is in every list.
*
* @param list<string> $parts
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
@@ -616,9 +658,11 @@ function routeFiles(
string $invalid,
Closure $fits,
): never {
$kind = $parts[0];
$demos = new DemoFiles(DATA_DIR . '/demo-files.json');
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list($users->folderOf($user), $isAdmin, $users->folderOwners()));
Response::json(filesOffered($store, $kind, $user, $isAdmin, $users));
}
if ($method === 'POST') {
$tooLarge = "{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB';
@@ -662,6 +706,22 @@ function routeFiles(
Response::json($record);
}
if ($method === 'PATCH') {
$file = implode('/', array_slice($parts, 1));
if (!$isAdmin) {
Response::error('Administrators only', 403);
}
if (!$store->exists($file)) {
Response::error("{$noun} not found", 404);
}
$body = Response::body();
if (!is_bool($body['demo'] ?? null)) {
Response::error('Body must contain boolean "demo"', 422);
}
$demos->set($kind, $file, $body['demo']);
Response::noContent();
}
if ($method === 'DELETE') {
$file = implode('/', array_slice($parts, 1));
// Asked before whether there is one: somebody else's folder tells a stranger nothing.
@@ -672,17 +732,56 @@ function routeFiles(
if (!$store->exists($file)) {
Response::error("{$noun} not found", 404);
}
// Everybody may have it in a project by now; whether that ends is the administrators' call.
if (!$isAdmin && $demos->has($kind, $file)) {
Response::error('Offered to everybody as demo material: only an administrator can take that back', 409);
}
$uses = (new FileUsage(DATA_DIR))->of($file);
if ($uses !== []) {
Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409);
}
$store->delete($file);
$demos->set($kind, $file, false);
Response::noContent();
}
Response::error('Method not allowed', 405);
}
/**
* What a picker of `$kind` lists: the files of the one asking and nobody's, an administrator's every
* file (#344), and everything offered as demo material (#428), with `demo` saying which those are.
* Signed out (`$user` null) it is the demo material alone, without who uploaded it.
*
* @return list<array<string,mixed>>
*/
function filesOffered(HashedFileStore $store, string $kind, ?string $user, bool $isAdmin, UserStore $users): array
{
$demo = (new DemoFiles(DATA_DIR . '/demo-files.json'))->of($kind);
$owners = $users->folderOwners();
$items = $user === null ? [] : $store->list($users->folderOf($user), $isAdmin, $owners);
if ($demo !== [] && !$isAdmin) {
$listed = array_column($items, 'file');
$hidden = $user === null ? ['owner' => null] : [];
foreach ($store->list('', true, $owners) as $item) {
if (in_array($item['file'], $demo, true) && !in_array($item['file'], $listed, true)) {
$items[] = ['deletable' => false, ...$hidden] + $item;
}
}
}
return array_map(static fn(array $item): array => $item + ['demo' => in_array($item['file'], $demo, true)], $items);
}
function fileStore(string $kind): HashedFileStore
{
return match ($kind) {
'textures' => new TextureStore(DATA_DIR . '/textures'),
'fonts' => new FontStore(DATA_DIR . '/fonts'),
'sounds' => new SoundStore(DATA_DIR . '/sounds'),
'videos' => new VideoStore(DATA_DIR . '/videos'),
};
}
/** @param list<string> $parts */
function routePixels(array $parts, string $method): never
{
@@ -768,13 +867,24 @@ try {
routeUsers($parts, $method, $auth, $users);
}
if ($parts[0] === 'releases') {
routeReleases($parts, $method);
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/releases'), 'Game not found', false);
}
if ($parts[0] === 'demos') {
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true);
}
// A guest's editor runs the plugins a demo switches on (#428); they are the server's own code,
// put there by its administrator, and only to be read.
if ($parts[0] === 'plugins' && $method === 'GET') {
routePlugins($parts, $method);
}
// A guest's pickers list the demo material without a session (#428), and nothing else.
if (in_array($parts[0], DemoFiles::KINDS, true) && count($parts) === 1 && $method === 'GET' && $auth->user() === null) {
Response::json(filesOffered(fileStore($parts[0]), $parts[0], null, false, $users));
}
// Being signed in is the price of admission for everything below, so an uploaded file cannot be
// dropped in by a stranger.
$user = $auth->require();
$isAdmin = $auth->role($user) === 'admin';
$data = DATA_DIR;
$fits = static function (int $adding) use ($user, $isAdmin, $users): void {
if ($isAdmin) {
return;
@@ -793,7 +903,7 @@ try {
$user,
$isAdmin,
$users,
new TextureStore("{$data}/textures"),
fileStore('textures'),
MAX_TEXTURE_BYTES,
'Image',
InvalidImageException::class,
@@ -805,7 +915,7 @@ try {
$user,
$isAdmin,
$users,
new FontStore("{$data}/fonts"),
fileStore('fonts'),
MAX_FONT_BYTES,
'Font',
InvalidFontException::class,
@@ -817,7 +927,7 @@ try {
$user,
$isAdmin,
$users,
new SoundStore("{$data}/sounds"),
fileStore('sounds'),
MAX_SOUND_BYTES,
'Sound',
InvalidSoundException::class,
@@ -829,7 +939,7 @@ try {
$user,
$isAdmin,
$users,
new VideoStore("{$data}/videos"),
fileStore('videos'),
MAX_VIDEO_BYTES,
'Video',
InvalidVideoException::class,