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

+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* Uploaded files an administrator offers to everybody as demo material (#428): every account's
* picker lists them beside its own, and a guest's picker lists nothing else. The files stay where
* they were uploaded, in their owner's folder; this only says which of them are offered.
*
* data/demo-files.json { "textures": ["{folder}/{sha256}.png", …], "sounds": […], … }
*
* Written 0640 like everything that is not a file to hand out.
*/
final class DemoFiles
{
public const KINDS = ['textures', 'fonts', 'sounds', 'videos'];
public function __construct(private readonly string $path) {}
/** @return list<string> the offered files of one kind, in the order they were offered */
public function of(string $kind): array
{
$files = $this->read()[$kind] ?? [];
return array_values(array_filter($files, 'is_string'));
}
public function has(string $kind, string $file): bool
{
return in_array($file, $this->of($kind), true);
}
/** Offers a file or takes the offer back; answers whether that changed anything. */
public function set(string $kind, string $file, bool $offered): bool
{
if (!in_array($kind, self::KINDS, true)) {
throw new InvalidArgumentException("Unknown kind: {$kind}");
}
$all = $this->read();
$files = $this->of($kind);
$has = in_array($file, $files, true);
if ($has === $offered) {
return false;
}
$all[$kind] = $offered ? [...$files, $file] : array_values(array_diff($files, [$file]));
$json = json_encode($all, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false) {
throw new RuntimeException('Cannot encode the demo files');
}
AtomicFile::write($this->path, $json . "\n", AtomicFile::HIDDEN, 'demo file list');
return true;
}
/** @return array<string,list<string>> */
private function read(): array
{
if (!is_file($this->path)) {
return [];
}
$raw = file_get_contents($this->path);
$data = $raw === false ? null : json_decode($raw, true);
return is_array($data) ? $data : [];
}
}
+6 -4
View File
@@ -4,8 +4,9 @@ declare(strict_types=1);
/**
* Which projects still use a stored image, font or sound (#344) – asked before one is deleted.
*
* A project counts when the file is named in the project itself, in one of its snapshots or in its
* release: restoring a snapshot or playing a released game must not find a hole. Any string value
* A project counts when the file is named in the project itself, in one of its snapshots, in its
* release or in its demo copy: restoring a snapshot, playing a released game or trying a demo must
* not find a hole. Any string value
* equal to the stored name counts, wherever in the document it is; that may refuse a file nothing
* really draws, but never lets one go that something does.
*/
@@ -31,7 +32,8 @@ final class FileUsage
$used[basename(dirname($path))] = true;
}
}
foreach (glob("{$this->data}/releases/*.json") ?: [] as $path) {
// A demo copy (#428) is a release a guest opens in the editor, and stored like one.
foreach ([...glob("{$this->data}/releases/*.json") ?: [], ...glob("{$this->data}/demos/*.json") ?: []] as $path) {
$release = $this->decoded($path, $file);
if ($release !== null && is_string($release['projectId'] ?? null) && self::uses($release['project'] ?? null, $file)) {
$used[$release['projectId']] = true;
@@ -74,7 +76,7 @@ final class FileUsage
$named[] = $others === 1 ? 'a project of another account' : "{$others} projects of other accounts";
}
$list = count($named) > 1 ? implode(', ', array_slice($named, 0, -1)) . ' and ' . end($named) : $named[0];
return "Still used by {$list}, counting snapshots and releases";
return "Still used by {$list}, counting snapshots, releases and demo copies";
}
/** Whether a value holds `$file` as a string anywhere inside it. */
+22
View File
@@ -13,6 +13,9 @@ declare(strict_types=1);
*
* Like the snapshots, the store checks nothing about who asks: the router reaches the project half
* only after `Store::load` found the project for that user.
*
* Demo copies (#428) are the same thing on `data/demos/`: a frozen copy a guest opens in the editor
* instead of the player, made and updated by an administrator.
*/
final class ReleaseStore
{
@@ -82,6 +85,25 @@ final class ReleaseStore
];
}
/**
* Every copy there is, by name: what a guest picks a demo from (#428), which is this store on a
* directory of its own.
*
* @return list<array{token:string,name:string,publishedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
$release = $this->load(basename($path, '.json'));
if ($release !== null) {
$items[] = ['token' => $release['token'], 'name' => $release['name'], 'publishedAt' => $release['publishedAt']];
}
}
usort($items, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name']));
return $items;
}
/** Makes the link dead. False when the project had none. */
public function takeDown(string $projectId): bool
{