Let guests try a demo project without an account (#428)
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:
1 parent
ce1f25f2eb
commit
925a39a276
30 files changed
+1397
-128
No files matched your search
+133
-23
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user