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
+1366 -97

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ jobs:
php tests/php/files.test.php
php tests/php/videos.test.php
php tests/php/limits.test.php
php tests/php/demos.test.php
php tests/php/rights.test.php
suite:
+2
View File
@@ -7,6 +7,8 @@
/data/sign-ins/
/data/snapshots/
/data/releases/
/data/demos/
/data/demo-files.json
/data/users.json
/data/textures/
/data/fonts/
+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,
+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
{
+49
View File
@@ -0,0 +1,49 @@
import { apiRequest } from './http';
import type { ReleasedGame, ReleaseStore, ReleaseSummary } from './ReleaseStore';
/** A demo copy as a guest picks it (#428). */
export interface DemoSummary {
token: string;
name: string;
/** ISO time, UTC: when the copy was made or last updated. */
publishedAt: string;
}
/**
* Demo copies (#428): frozen copies of projects a guest opens in the editor without an account,
* stored like a release. Listing and loading one needs no sign-in; making, updating and removing the
* copy of a project (the `ReleaseStore` half) is for administrators.
*/
export interface DemoStore extends ReleaseStore {
list(): Promise<DemoSummary[]>;
/** The copy, under its token as the project id. */
load(token: string): Promise<ReleasedGame>;
}
export class HttpDemoStore implements DemoStore {
constructor(private readonly endpoint: string) {}
list(): Promise<DemoSummary[]> {
return apiRequest(this.endpoint, 'GET', 'demos');
}
load(token: string): Promise<ReleasedGame> {
return apiRequest(this.endpoint, 'GET', `demos/${token}`);
}
of(projectId: string): Promise<ReleaseSummary | null> {
return apiRequest(this.endpoint, 'GET', route(projectId));
}
publish(projectId: string): Promise<ReleaseSummary> {
return apiRequest(this.endpoint, 'POST', route(projectId));
}
takeDown(projectId: string): Promise<void> {
return apiRequest(this.endpoint, 'DELETE', route(projectId));
}
}
function route(projectId: string): string {
return `projects/${encodeURIComponent(projectId)}/demo`;
}
+7
View File
@@ -16,6 +16,8 @@ export interface StoredFile extends UploadedFile {
owner: string | null;
/** Whether the one asking may delete it – whether a project still uses it, only the delete finds out. */
deletable: boolean;
/** Offered to everybody as demo material by an administrator (#428); absent from an older server. */
demo?: boolean;
}
/**
@@ -30,4 +32,9 @@ export interface FileStore<U extends UploadedFile = UploadedFile, S extends Stor
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
list(): Promise<S[]>;
delete(file: string): Promise<void>;
/**
* Offers a file to everybody as demo material, or takes that back (#428). Only there for an
* administrator; the pickers show the switch when it is.
*/
setDemo?: (file: string, demo: boolean) => Promise<void>;
}
+14 -4
View File
@@ -1,5 +1,5 @@
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
import { apiRequest } from './http';
import { apiRequest, jsonBody } from './http';
import type { StoredVideo, UploadedVideo, VideoStore, VideoUploadOptions } from './VideoStore';
/**
@@ -13,10 +13,20 @@ export class HttpFileStore<
U extends UploadedFile = UploadedFile,
S extends StoredFile = StoredFile,
> implements FileStore<U, S> {
readonly setDemo?: (file: string, demo: boolean) => Promise<void>;
/** `offersDemos` for an administrator's session: the server refuses everybody else anyway (#428). */
constructor(
protected readonly endpoint: string,
private readonly route: FileRoute,
) {}
offersDemos = false,
) {
if (offersDemos) {
this.setDemo = async (file, demo) => {
await apiRequest<void>(this.endpoint, 'PATCH', `${this.route}/${file}`, jsonBody({ demo }), refusal);
};
}
}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<U> {
return this.send(bytes, type, { name });
@@ -42,8 +52,8 @@ export class HttpFileStore<
/** Videos: an upload may be kept without converting it, and converted later. */
export class HttpVideoStore extends HttpFileStore<UploadedVideo, StoredVideo> implements VideoStore {
constructor(endpoint: string) {
super(endpoint, 'videos');
constructor(endpoint: string, offersDemos = false) {
super(endpoint, 'videos', offersDemos);
}
override async upload(
+53
View File
@@ -0,0 +1,53 @@
import type { Project } from '../model/Project';
import {
type LoadedProject,
type ProjectStore,
type ProjectSummary,
StaleRevisionError,
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
* A guest's project (#428): one demo copy, held in this tab and nowhere else. Saving keeps it here,
* so nothing a guest changes reaches the server, and a reload starts from the copy again. There is
* no second project to create, open or delete.
*/
export class MemoryProjectStore implements ProjectStore {
private revision = 1;
private readonly updatedAt = new Date().toISOString();
constructor(
private readonly id: string,
private readonly name: string,
private raw: unknown,
private readonly refusal: string,
) {}
list(): Promise<ProjectSummary[]> {
return Promise.resolve([
{ id: this.id, name: this.name, revision: this.revision, updatedAt: this.updatedAt },
]);
}
async load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
if (id !== this.id) throw new Error(`Project not found: ${id}`);
await prepare?.(this.raw);
// Saves are matched by id, so the copy answers to the token whatever its file said.
const raw = typeof this.raw === 'object' && this.raw !== null ? { ...this.raw, id: this.id } : this.raw;
return { project: deserializeProject(raw, this.id), revision: this.revision };
}
save(project: Project, revision: number): Promise<number> {
if (revision !== this.revision) return Promise.reject(new StaleRevisionError(this.revision));
this.raw = serializeProject(project);
return Promise.resolve(++this.revision);
}
create(_name: string): Promise<LoadedProject> {
return Promise.reject(new Error(this.refusal));
}
delete(_id: string): Promise<void> {
return Promise.reject(new Error(this.refusal));
}
}
+89 -20
View File
@@ -1,11 +1,13 @@
import type { Application } from 'pixi.js';
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpReleaseStore } from '../core/store/HttpReleaseStore';
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
import { HttpUsersStore } from '../core/store/HttpUsersStore';
import { MemoryProjectStore } from '../core/store/MemoryProjectStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
import { loadPhysics } from '../runtime/physics/engine';
import { projectPictureFactory } from '../viewport/generatedPictures';
@@ -18,25 +20,40 @@ import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { guestFiles, guestReleases, guestSnapshots, guestUsers, guestVideos } from './guestStores';
import { isDragFrame } from './framePreview';
import { startPlugins } from './plugins/startPlugins';
import { CelUploadStore } from './pixelart/celUploadStore';
import { API_ENDPOINT } from '../core/site';
import { EditorState } from './state';
import { GUEST_REFUSAL, setSession } from './session';
/** Who the sign-in screen let in: an account, or a guest with the demo copy they picked (#428). */
type Entry = { account: SignedIn } | { demo: DemoSummary };
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* Shows the sign-in screen until somebody is signed in or picks a demo, and answers with who. Returns
* right away when the session from an earlier visit is still good – which is what lets a
* deployment replace the editor under an open tab without asking for the password again.
*/
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<SignedIn> {
async function signedInUser(
app: Application,
root: VStack,
auth: AuthStore,
demos: DemoStore,
): Promise<Entry> {
const existing = await auth.session();
if (existing !== null) return existing;
if (existing !== null) return { account: existing };
let done: (account: SignedIn) => void = () => {};
const screen = new LoginScreen(auth, (account) => done(account));
let done: (entry: Entry) => void = () => {};
const screen = new LoginScreen(
auth,
demos,
(account) => done({ account }),
(demo) => done({ demo }),
);
const removeHook = installLoginHook(app, screen);
const account = await new Promise<SignedIn>((resolve) => {
const entry = await new Promise<Entry>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
@@ -50,7 +67,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return account;
return entry;
}
/**
@@ -65,8 +82,13 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
const auth = new HttpAuthStore(API_ENDPOINT);
setUpProjectFiles(app.renderer);
const demos = new HttpDemoStore(API_ENDPOINT);
const openSession = async (): Promise<void> => {
const { user, role } = await signedInUser(app, root, auth);
const entry = await signedInUser(app, root, auth, demos);
const guest = 'demo' in entry;
const { user, role } = guest ? { user: 'Guest', role: 'user' as const } : entry.account;
setSession({ guest, role });
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
// also what tells two tabs on two accounts apart.
document.title = `Feuerwerk – ${user}`;
@@ -79,7 +101,39 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
state.on('project', () => {
if (!isDragFrame()) preloadProjectFiles(state.project);
});
const editor = new Editor(
const admin = role === 'admin';
const textures = new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures', admin);
const fonts = new HttpFileStore(API_ENDPOINT, 'fonts', admin);
const sounds = new HttpFileStore(API_ENDPOINT, 'sounds', admin);
const videos = new HttpVideoStore(API_ENDPOINT, admin);
const logOut = (): void =>
void endSession(editor, guest).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
});
// A guest (#428) gets the demo copy in a store of the tab's own, and every store that would
// write refuses before it asks: nothing a guest does reaches the server's writing routes.
const editor = guest
? new Editor(
app,
root,
state,
await guestProject(demos, entry.demo),
guestFiles(textures),
guestFiles(fonts),
guestFiles(sounds),
guestVideos(videos),
guestSnapshots,
guestReleases,
{
user,
role,
guest: true,
users: guestUsers,
logOut,
changePassword: () => Promise.reject(new Error(GUEST_REFUSAL)),
},
)
: new Editor(
app,
root,
state,
@@ -88,22 +142,20 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
pixelCels,
new HttpFileStore(API_ENDPOINT, 'pixels'),
),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
new HttpVideoStore(API_ENDPOINT),
textures,
fonts,
sounds,
videos,
new HttpSnapshotStore(API_ENDPOINT),
new HttpReleaseStore(API_ENDPOINT),
{
user,
role,
users: new HttpUsersStore(API_ENDPOINT),
logOut: () =>
void endSession(editor).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
}),
// Straight through to the store: the server checks the old password, and the session this
// tab holds is the one it keeps – so there is nothing to do here afterwards.
demos: admin ? demos : undefined,
logOut,
// Straight through to the store: the server checks the old password, and the session
// this tab holds is the one it keeps – so there is nothing to do here afterwards.
changePassword: (current, next) => auth.changePassword(current, next),
},
);
@@ -111,6 +163,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
// Before the project, not after it (#119): which plugins run is written in the project file,
// and `boot()` hands that list over before it reads the file into the model.
await startPlugins(API_ENDPOINT, state, editor);
// A guest has one project; a `?project=` of an account's would only fail to open.
if (guest) forgetProjectInAddress();
await editor.boot();
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
};
@@ -120,19 +174,34 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
* of everything, then the server drops the session, and the sign-in screen comes back. The
* editor goes first, so its last save still travels with a session the server accepts.
*/
const endSession = async (editor: Editor): Promise<void> => {
const endSession = async (editor: Editor, guest: boolean): Promise<void> => {
await editor.dispose();
document.title = 'Feuerwerk';
// The console panel is page-wide, the lines in it are not: what one account's project printed
// is none of the next one's business.
consoleLog.clear();
// A guest has no session on the server to end.
if (!guest) {
try {
await auth.logOut();
} catch (error) {
console.error('[auth] Signing out did not reach the server:', error);
}
}
await openSession();
};
await openSession();
}
/** The demo copy a guest picked, in a store of the tab's own (#428). */
async function guestProject(demos: DemoStore, demo: DemoSummary): Promise<MemoryProjectStore> {
const copy = await demos.load(demo.token);
return new MemoryProjectStore(demo.token, copy.name, copy.project, GUEST_REFUSAL);
}
function forgetProjectInAddress(): void {
const url = new URL(location.href);
url.searchParams.delete('project');
history.replaceState(null, '', url);
}
+34 -6
View File
@@ -4,6 +4,7 @@ import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
import { assetTitle } from '../core/music/embedded';
import { mixOf, videoBusesOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
import type { DemoStore } from '../core/store/DemoStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
@@ -158,7 +159,11 @@ export interface EditorAccount {
user: string;
/** What the editor offers: an administrator gets the user management (#180, #179). */
role: Role;
/** Somebody trying a demo copy without an account (#428): nothing they do is kept. */
guest?: boolean;
users: UsersStore;
/** An administrator's: making the demo copy of the open project (#428). */
demos?: DemoStore;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
@@ -365,6 +370,7 @@ export class Editor implements PlayHost {
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private readonly guest: boolean;
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
@@ -448,11 +454,13 @@ export class Editor implements PlayHost {
() => {
if (this.play.isPlaying) this.play.stop();
},
{ snapshots, releases, flush: () => this.autosave.flush() },
{ snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() },
{ guest: account.guest === true, role: account.role },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
);
this.guest = account.guest === true;
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
@@ -476,7 +484,9 @@ export class Editor implements PlayHost {
: null;
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
this.toolbar = new Toolbar(
state,
{
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
@@ -485,7 +495,9 @@ export class Editor implements PlayHost {
togglePause: () => this.play.togglePause(),
step: () => this.play.step(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
});
},
account.guest === true,
);
root.add(this.toolbar);
this.workspace = new HStack();
@@ -614,7 +626,10 @@ export class Editor implements PlayHost {
// A press in the editor takes the keys back from a window (#371).
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
this.autosave = attachAutosave(state, store);
// A guest's project lives in the tab alone (#428): there is nowhere to save it to.
this.autosave = this.guest
? { flush: () => Promise.resolve(), stop: () => {} }
: attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, {
play: () => this.play.play(),
@@ -863,6 +878,14 @@ export class Editor implements PlayHost {
* picked: it says who is signed in without costing the crowded toolbar another label.
*/
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
if (this.guest) {
new Menu([
{ label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} },
{ separator: true },
{ label: 'Sign in…', onSelect: () => account.logOut() },
]).openBelow(anchor);
return;
}
new Menu([
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
{ separator: true },
@@ -1348,8 +1371,13 @@ export class Editor implements PlayHost {
};
}
boot(): Promise<void> {
return this.projects.boot();
async boot(): Promise<void> {
await this.projects.boot();
if (this.guest) {
this.state.setNotice(
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
);
}
}
/**
+78 -12
View File
@@ -26,6 +26,7 @@ import {
crispText,
} from '../ui';
import { dialogButtons } from './FormDialog';
import { allows } from './session';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
export const PICKER_WIDTH = 560;
@@ -55,10 +56,15 @@ function ownerText(owner: string | null): string {
/** The line under the list for a picked file: its upload name and size, whose it is, and who wears it. */
function describeStored(item: PickerItem): string {
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
const owned = `${size} – ${ownerText(item.owner)}`;
const owned = `${size} – ${ownerText(item.owner)}${demoText(item)}`;
return item.assetName ? `${owned} – already in the project as "${item.assetName}"` : owned;
}
/** Said after what a file is when an administrator offers it to everybody (#428). */
export function demoText(item: StoredFile): string {
return item.demo ? ' – demo material' : '';
}
export interface PickerRow {
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
box: Container;
@@ -116,6 +122,10 @@ export abstract class PickerList<Item extends PickerItem, Row extends PickerRow>
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
get demoFiles(): string[] {
return this.items.filter((item) => item.demo === true).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
@@ -203,6 +213,16 @@ export abstract class RowList<Item extends PickerItem, Row extends PickerRow> ex
/** What the Delete button says while it waits for the second press that really deletes. */
const ARMED_LABEL = 'Delete for good';
const OFFER_LABEL = 'Offer as demo';
const WITHDRAW_LABEL = 'Stop offering';
interface ButtonInfo {
enabled: boolean;
label: string;
shown: string;
x: number;
y: number;
}
export interface PickerWords {
title: string;
@@ -243,6 +263,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
private readonly detail: Label;
private readonly use: Button;
private readonly remove: Button;
/** An administrator's switch for offering the picked file as demo material (#428). */
private readonly offer: Button | null;
private readonly buttons: HStack;
/** The file the Delete button was pressed for once; the second press deletes it. */
private armed: string | null = null;
@@ -254,6 +276,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
private readonly deleteFile: (file: string) => Promise<void>,
/** Buttons of a kind of picker of its own, after Delete; `picked` says what is picked. */
protected readonly extras: readonly Button[] = [],
/** There for an administrator only, see `FileStore.setDemo`. */
private readonly setDemo?: (file: string, demo: boolean) => Promise<void>,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label(words.title, { style: 'bodyBold' }));
@@ -280,36 +304,52 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.remove = new Button('Delete', { variant: 'danger' });
this.remove.onClick = () => void this.pressDelete();
this.remove.setEnabled(false);
this.buttons = dialogButtons(this.closeThen(), this.use, [uploadButton, this.remove, ...extras]);
this.offer = setDemo ? new Button(OFFER_LABEL) : null;
if (this.offer) {
this.offer.onClick = () => void this.toggleDemo();
this.offer.setEnabled(false);
}
// A guest (#428) uploads and deletes nothing: the buttons are not there rather than refused.
const writes = allows('upload') ? [uploadButton, this.remove] : [];
this.buttons = dialogButtons(this.closeThen(), this.use, [
...writes,
...extras,
...(this.offer ? [this.offer] : []),
]);
this.content.add(this.buttons);
}
/**
* What the dialog offers, which of it the project already wears, what is picked, the line under
* the list and the Delete button with its centre (test hook).
* the list, the Delete button with its centre and, for an administrator, the demo switch; a
* button that is not in the dialog is null (test hook).
*/
debugInfo(): {
files: string[];
used: string[];
demo: string[];
captions: string[];
selected: string | null;
detail: string;
deleteButton: { enabled: boolean; label: string; shown: string; x: number; y: number };
deleteButton: ButtonInfo | null;
uploadShown: boolean;
offerButton: ButtonInfo | null;
} {
const at = this.remove.getGlobalPosition();
const info = (button: Button): ButtonInfo => {
const at = button.getGlobalPosition();
const centre = { x: at.x + button.w / 2, y: at.y + button.h / 2 };
return { enabled: button.enabled, label: button.text, shown: button.shownText, ...centre };
};
return {
files: this.list.fileList,
used: this.list.usedFiles,
demo: this.list.demoFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
detail: this.detail.text,
deleteButton: {
enabled: this.remove.enabled,
label: this.remove.text,
shown: this.remove.shownText,
x: at.x + this.remove.w / 2,
y: at.y + this.remove.h / 2,
},
deleteButton: this.remove.parent ? info(this.remove) : null,
uploadShown: this.remove.parent !== null,
offerButton: this.offer ? info(this.offer) : null,
};
}
@@ -352,6 +392,11 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.setDeleteLabel('Delete');
this.use.setEnabled(item !== null && this.canUse(item));
this.remove.setEnabled(item?.deletable ?? false);
if (this.offer) {
this.offer.setEnabled(item !== null);
this.offer.setText(item?.demo ? WITHDRAW_LABEL : OFFER_LABEL);
this.buttons.layout();
}
this.picked(item);
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
this.refreshLayout();
@@ -401,6 +446,27 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.refreshLayout();
}
/** Offers the picked file to everybody as demo material, or takes that back (#428). */
private async toggleDemo(): Promise<void> {
const item = this.list.selected;
if (!item || !this.setDemo) return;
const offered = !item.demo;
try {
await this.setDemo(item.file, offered);
} catch (error) {
this.say(`Not changed: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (this.list.destroyed) return;
await this.load();
this.list.select(item.file);
this.say(
offered
? `Every account and every guest can use ${item.name || `this ${this.words.noun}`} now.`
: `${item.name || `This ${this.words.noun}`} is no longer offered as demo material.`,
);
}
private confirm(): void {
const item = this.list.selected;
if (!item || !this.canUse(item)) return;
+87 -6
View File
@@ -12,6 +12,7 @@ import {
SetWhilePlayingCommand,
} from '../core/commands/projectCommands';
import { assetRefChoices } from '../core/properties/assetRef';
import type { DemoStore } from '../core/store/DemoStore';
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
@@ -24,6 +25,7 @@ import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'
import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
import { SnapshotsDialog } from './SnapshotsDialog';
import { type Session, sessionAllows } from './session';
import type { EditorState } from './state';
export class ProjectSession {
@@ -41,10 +43,18 @@ export class ProjectSession {
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
/**
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
* what the autosave still holds, so a copy is of the stand on screen.
* What the server copies of the project: snapshots (#231), the release (#266) and, for an
* administrator, the demo copy (#428). `flush` writes what the autosave still holds, so a copy is
* of the stand on screen.
*/
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
private readonly copies: {
snapshots: SnapshotStore;
releases: ReleaseStore;
demos?: DemoStore;
flush: () => Promise<void>;
},
/** What the menu offers: a guest (#428) neither switches projects nor has copies made. */
private readonly session: Session,
/**
* Runs on the project as it came off the server, before it is read into the model: the plugins
* this project switches on are loaded there (#119), so that what they register is in place
@@ -79,6 +89,8 @@ export class ProjectSession {
this.beforeSwitch();
const loaded = await this.store.load(id, this.prepare);
this.state.loadProject(loaded.project, loaded.revision);
// A guest's demo is nobody's project: a reload brings the sign-in screen, not the copy (#428).
if (this.session.guest) return;
const url = new URL(location.href);
url.searchParams.set('project', id);
history.replaceState(null, '', url);
@@ -109,12 +121,20 @@ export class ProjectSession {
* the editor adds that is not about the project (#179).
*/
openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void {
const may = (action: Parameters<typeof sessionAllows>[1]) => sessionAllows(this.session, action);
const items: MenuEntry[] = [
...(may('projects')
? [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() },
{ separator: true } as const,
]
: []),
...(may('snapshots') ? [{ label: 'Snapshots…', onSelect: () => this.openSnapshots() }] : []),
...(may('release') ? [{ label: 'Release…', onSelect: () => this.openRelease() }] : []),
...(may('demoCopy') && this.copies.demos
? [{ label: 'Demo copy…', onSelect: () => this.openDemoCopy() }]
: []),
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
...project,
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
@@ -123,6 +143,67 @@ export class ProjectSession {
new Menu(items).openBelow(anchor);
}
/**
* The demo copy of the open project (#428): what a guest gets when they pick it on the sign-in
* screen. It is the project as stored, like a release, so the dialog says how old it is and
* whether the project has moved on since.
*/
openDemoCopy(): void {
const demos = this.copies.demos;
if (!demos) return;
const projectId = this.state.project.id;
void this.run(
(async () => {
const copy = await demos.of(projectId);
const cancel = { label: 'Close', onClick: () => {} };
const update = {
label: copy ? 'Update demo copy' : 'Make demo copy',
primary: true,
onClick: () => void this.run(this.publishDemo(projectId, copy !== null)),
};
if (!copy) {
new Dialog(
'Demo copy',
'Guests can try a copy of this project without an account. It is the project as it is saved now; later changes reach them when you update the copy.',
[cancel, update],
).openCentered();
return;
}
const behind = copy.revision !== this.state.revision ? ' The project has changed since.' : '';
new Dialog(
'Demo copy',
`Guests can pick this project. Their copy is from ${new Date(copy.publishedAt).toLocaleString()}.${behind}`,
[
{
label: 'Remove',
danger: true,
onClick: () =>
void this.run(
(async () => {
await demos.takeDown(projectId);
this.state.setNotice('Demo copy removed – guests no longer see this project');
})(),
),
},
cancel,
update,
],
).openCentered();
})(),
);
}
private async publishDemo(projectId: string, again: boolean): Promise<void> {
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus, 'demo');
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
await this.copies.demos?.publish(projectId);
this.state.setNotice(again ? 'Demo copy updated' : 'Demo copy made – guests can pick this project now');
}
/** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */
private clearSavedData(): void {
const id = this.state.project.id;
+34
View File
@@ -1,11 +1,13 @@
import { Graphics } from 'pixi.js';
import logoSvg from '../../../public/brand/feuerwerk-logo-stacked-dark.svg?raw';
import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore';
import type { DemoStore, DemoSummary } from '../../core/store/DemoStore';
import {
Button,
drawFloatingBox,
drawSvg,
Label,
Menu,
Spacer,
TextField,
Theme,
@@ -77,20 +79,26 @@ class Card extends VStack {
*
* It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is
* signed in, so there is nothing behind it to hide.
*
* Below the form a guest picks a demo copy to try the editor without an account (#428).
*/
export class LoginScreen extends VStack {
private readonly logo = new Logo();
private readonly user: TextField;
private readonly password: TextField;
private readonly submit: Button;
private readonly tryButton: Button;
private readonly status = new FormStatus((busy) => {
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
this.submit.setEnabled(!busy);
this.tryButton.setEnabled(!busy);
});
constructor(
private readonly auth: AuthStore,
private readonly demos: DemoStore,
private readonly onSignedIn: (account: SignedIn) => void,
private readonly onGuest: (demo: DemoSummary) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
@@ -126,6 +134,13 @@ export class LoginScreen extends VStack {
onClick: () => void this.signIn(),
});
card.add(this.submit);
card.add(new Spacer(Theme.padding));
card.add(new Label('No account? Try a demo project – nothing is saved.', { style: 'small' }));
this.tryButton = new Button('Try without an account', {
width: FIELD_WIDTH,
onClick: () => void this.pickDemo(),
});
card.add(this.tryButton);
this.add(card);
// Twice the space below as above, so logo and card sit a little above the middle.
@@ -142,6 +157,25 @@ export class LoginScreen extends VStack {
return this.status.info();
}
/** The demo copies under the button, one entry each; picking one starts the guest session. */
private async pickDemo(): Promise<void> {
if (this.status.busy) return;
let demos: DemoSummary[];
try {
demos = await this.demos.list();
} catch (error) {
this.status.say(`Could not list the demos: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (demos.length === 0) {
this.status.say('There are no demo projects yet');
return;
}
new Menu(demos.map((demo) => ({ label: demo.name, onSelect: () => this.onGuest(demo) }))).openBelow(
this.tryButton,
);
}
private async signIn(): Promise<void> {
if (this.status.busy) return;
const username = this.user.value.trim();
+6 -1
View File
@@ -3,6 +3,7 @@ import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImpo
import { importImages, type TextureImportDeps } from './textureImport';
import { openVideoPicker } from './video/VideoPicker';
import { isVideoFileName, uploadVideos, type VideoImportDeps } from './videoImport';
import { allows, GUEST_REFUSAL } from './session';
export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps & VideoImportDeps;
@@ -28,12 +29,16 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
return;
}
event.preventDefault();
if (!allows('upload')) {
deps.state.setNotice(GUEST_REFUSAL);
return;
}
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = sortFiles(event.clipboardData);
if (files.none) return;
if (files.none || !allows('upload')) return;
event.preventDefault();
run(files, deps);
};
+37
View File
@@ -0,0 +1,37 @@
import type { FileStore, StoredFile, UploadedFile } from '../core/store/FileStore';
import type { ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore } from '../core/store/SnapshotStore';
import type { UsersStore } from '../core/store/UsersStore';
import type { VideoStore } from '../core/store/VideoStore';
import { GUEST_REFUSAL } from './session';
/**
* What a guest's editor gets instead of the server's stores (#428). Files are listed as the server
* offers them to somebody signed out, which is the demo material; everything that would write is
* refused here in the tab, before a request is made. The rest of the editor works on these as on
* any store, so no path through it can reach the server's writing routes.
*/
function refuse(): Promise<never> {
return Promise.reject(new Error(GUEST_REFUSAL));
}
export function guestFiles<U extends UploadedFile, S extends StoredFile>(
server: FileStore<U, S>,
): FileStore<U, S> {
return { list: () => server.list(), upload: refuse, delete: refuse };
}
export function guestVideos(server: VideoStore): VideoStore {
return { list: () => server.list(), upload: refuse, delete: refuse, convert: refuse };
}
export const guestSnapshots: SnapshotStore = {
list: refuse,
create: refuse,
restore: refuse,
delete: refuse,
};
export const guestReleases: ReleaseStore = { of: refuse, publish: refuse, takeDown: refuse };
export const guestUsers: UsersStore = { list: refuse, create: refuse, update: refuse, remove: refuse };
+8 -2
View File
@@ -26,6 +26,8 @@ const SAVE_LABELS: Record<SaveStatus, string> = {
conflict: 'Conflict – reload',
};
const GUEST_LABEL = 'Guest – not saved';
/** Square, because an icon has no reading direction that would ask for a wider box. */
const ICON_BUTTON = Theme.controlHeight;
@@ -68,6 +70,8 @@ export class Toolbar extends HStack {
constructor(
private readonly state: EditorState,
actions: ToolbarActions,
/** A guest's project is never saved (#428), and the place of the save status says so. */
private readonly guest = false,
) {
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
@@ -195,8 +199,10 @@ export class Toolbar extends HStack {
this.pauseButton.invalidate();
this.stepButton.setEnabled(!editing && state.paused);
this.stepButton.invalidate();
this.status.setText(SAVE_LABELS[state.saveStatus]);
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
this.status.setText(this.guest ? GUEST_LABEL : SAVE_LABELS[state.saveStatus]);
this.status.setStyle(
this.guest || state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small',
);
}
/**
+18 -2
View File
@@ -1,13 +1,19 @@
import type { UploadedVideo } from '../core/store/VideoStore';
import type { MadeRecording } from '../viewport/RecordingTake';
import type { RecordingUploadOptions } from '../core/recording/recording';
import { downloadBlob } from '../ui';
import { allows } from './session';
import { adoptVideo, type VideoImportDeps } from './videoImport';
/** How often the server is asked whether a recording is converted, as the video picker does. */
const POLL_MS = 1000;
/** `kept`: uploaded without converting it; `converted`: converted, but not to be imported. */
type UploadState = 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed';
/**
* `kept`: uploaded without converting it; `converted`: converted, but not to be imported;
* `downloaded`: a guest's, handed to the browser instead (#428).
*/
type UploadState =
'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed' | 'downloaded';
/** What became of a recording on its way into the project (test hook). */
export interface RecordingUpload {
@@ -26,6 +32,8 @@ export interface RecordingUpload {
* while the editor is not playing: a run plays a copy of the project that is thrown away, and so
* would the asset be. A recording may stop short of either step, as "Start recording…" or the node
* asked: it is always uploaded, and the video picker has it.
*
* A guest keeps nothing on the server (#428): their recording is saved as a download instead.
*/
export class RecordingUploads {
private readonly uploads: RecordingUpload[] = [];
@@ -42,6 +50,14 @@ export class RecordingUploads {
error: '',
};
this.uploads.push(upload);
if (!allows('upload')) {
downloadBlob(recording.blob, `${recording.name}.mkv`);
upload.state = 'downloaded';
this.deps.state.setNotice(
`Saved the recording "${recording.name}" as a download – guests keep nothing on the server.`,
);
return;
}
void this.bring(recording, upload, options).catch((error: unknown) => {
upload.state = 'failed';
upload.error = error instanceof Error ? error.message : String(error);
+52
View File
@@ -0,0 +1,52 @@
import type { Role } from '../core/store/AuthStore';
/**
* Who sits at the editor (#428): somebody signed in, with their role, or a guest trying a demo
* copy without an account.
*/
export interface Session {
guest: boolean;
role: Role;
}
/**
* Everything the editor does that writes to the server or manages it. A guest's project lives in
* the tab alone, so a guest does none of these; an account does all of them but the
* administrators' own.
*/
export type SessionAction =
| 'save'
| 'upload'
| 'deleteFile'
| 'projects'
| 'snapshots'
| 'release'
| 'password'
| 'users'
| 'demoCopy'
| 'offerDemo';
const ADMINISTRATORS_ONLY: ReadonlySet<SessionAction> = new Set(['users', 'demoCopy', 'offerDemo']);
/** Whether `session` may do `action`. The server decides again; this is what the editor offers. */
export function sessionAllows(session: Session, action: SessionAction): boolean {
if (session.guest) return false;
return !ADMINISTRATORS_ONLY.has(action) || session.role === 'admin';
}
/** What a guest is told when something would need the server. */
export const GUEST_REFUSAL = 'Guests keep nothing on the server – sign in to keep your work';
let current: Session = { guest: false, role: 'user' };
/**
* The session of this page, for the places that are not handed the account: the pickers, a file
* dropped on the editor, a finished recording. One page holds one session at a time.
*/
export function setSession(session: Session): void {
current = session;
}
export function allows(action: SessionAction): boolean {
return sessionAllows(current, action);
}
+6 -3
View File
@@ -6,14 +6,17 @@ export function snapshotTime(at: Date): string {
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
const COPY_WORDS = { snapshot: 'take a snapshot of', release: 'release', demo: 'make a demo copy of' };
/**
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
* what it has stored, so the editor has to have saved everything first: a snapshot without the
* last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same.
* last edits would be a stand nobody ever saw. A release (#266) and a demo copy (#428) are copies as
* well and ask the same.
*/
export function snapshotBlocker(
status: SaveStatus,
copy: 'snapshot' | 'release' = 'snapshot',
copy: 'snapshot' | 'release' | 'demo' = 'snapshot',
): string | null {
switch (status) {
case 'saved':
@@ -21,7 +24,7 @@ export function snapshotBlocker(
case 'conflict':
return 'This project was changed elsewhere. Reload to continue.';
case 'error':
return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`;
return `The project could not be saved, so there is nothing current to ${COPY_WORDS[copy]}`;
default:
return 'The latest changes are not saved yet – stop the game or wait a moment';
}
+2
View File
@@ -99,6 +99,8 @@ class SoundPicker extends FilePicker<SoundItem> {
},
() => pickAndImportSounds(deps),
(file) => deps.sounds.delete(file),
[],
deps.sounds.setDemo,
);
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { formatBytes, type Size, textStyle, truncate, crispText } from '../../ui
import { textureLibrary } from '../../viewport/TextureLibrary';
import {
currentPicker,
demoText,
FilePicker,
openPicker,
PICKER_WIDTH,
@@ -112,6 +113,8 @@ class ImagePicker extends FilePicker<ImageItem> {
},
() => pickAndImportImages(deps),
(file) => deps.textures.delete(file),
[],
deps.textures.setDemo,
);
}
@@ -136,7 +139,7 @@ class ImagePicker extends FilePicker<ImageItem> {
protected override describe(item: ImageItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
const size = `${item.name ? `${item.name} – ${measurements}` : measurements}${demoText(item)}`;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { posterLibrary, videoFileUrl } from '../../viewport/VideoLibrary';
import { pickFiles } from '../assetImport';
import {
currentPicker,
demoText,
FilePicker,
openPicker,
type PickerItem,
@@ -108,6 +109,7 @@ class VideoPicker extends FilePicker<VideoItem> {
new Button('Download', { tooltip: 'Save the video as it was uploaded' }),
new Button('Convert', { tooltip: 'Convert it on the server, so a project can use it' }),
],
deps.videos.setDemo,
);
const [download, convert] = this.extras;
download!.onClick = () => this.download();
@@ -214,7 +216,7 @@ class VideoPicker extends FilePicker<VideoItem> {
protected override describe(item: VideoItem): string {
const size =
item.status === 'ready' || item.status === 'unconverted' ? ` – ${formatBytes(item.bytes)}` : '';
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}`;
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}${demoText(item)}`;
}
private itemOf(file: string): VideoItem | null {
+148
View File
@@ -0,0 +1,148 @@
/**
* Trying the editor without an account (#428): what a session may do, the guest's project held in
* the tab, the stores that refuse before they ask, and the routes of demo copies and demo material.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpDemoStore } from '../../src/core/store/DemoStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { MemoryProjectStore } from '../../src/core/store/MemoryProjectStore';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { serializeProject } from '../../src/core/store/serialize';
import { guestFiles, guestReleases, guestSnapshots, guestVideos } from '../../src/editor/guestStores';
import { GUEST_REFUSAL, type SessionAction, sessionAllows } from '../../src/editor/session';
import { snapshotBlocker } from '../../src/editor/snapshots';
import { makeProject } from '../helpers';
const TOKEN = '0123456789abcdef0123456789abcdef';
const FILE = `a590cab0f8b7/${'b'.repeat(64)}.png`;
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('what a session may do', () => {
const actions: SessionAction[] = [
'save',
'upload',
'deleteFile',
'projects',
'snapshots',
'release',
'password',
'users',
'demoCopy',
'offerDemo',
];
it('a guest writes nothing to the server and manages nothing', () => {
for (const action of actions)
expect(sessionAllows({ guest: true, role: 'user' }, action), action).toBe(false);
// Not even a guest who somehow carried an administrator's role.
for (const action of actions)
expect(sessionAllows({ guest: true, role: 'admin' }, action), action).toBe(false);
});
it('an account does all of it but the administrators’ own', () => {
const user = actions.filter((action) => sessionAllows({ guest: false, role: 'user' }, action));
expect(user).toEqual(['save', 'upload', 'deleteFile', 'projects', 'snapshots', 'release', 'password']);
expect(actions.every((action) => sessionAllows({ guest: false, role: 'admin' }, action))).toBe(true);
});
});
describe("a guest's project, held in the tab", () => {
const raw = () => serializeProject(makeProject().project);
it('is the one project there is, under the token, and loads after `prepare` saw it', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
expect(await store.list()).toEqual([
expect.objectContaining({ id: TOKEN, name: 'Platformer', revision: 1 }),
]);
const seen: unknown[] = [];
const loaded = await store.load(TOKEN, (json) => {
seen.push(json);
return Promise.resolve();
});
expect(seen).toHaveLength(1);
expect(loaded.project.id).toBe(TOKEN);
expect(loaded.revision).toBe(1);
await expect(store.load('another')).rejects.toThrow(/not found/);
});
it('keeps a save in the tab, with the revision moving on as on the server', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
const { project } = await store.load(TOKEN);
project.name = 'Changed';
expect(await store.save(project, 1)).toBe(2);
expect((await store.load(TOKEN)).project.name).toBe('Changed');
await expect(store.save(project, 1)).rejects.toBeInstanceOf(StaleRevisionError);
});
it('refuses to make or delete a project, with what a guest is told', async () => {
const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL);
await expect(store.create('New')).rejects.toThrow(GUEST_REFUSAL);
await expect(store.delete(TOKEN)).rejects.toThrow(GUEST_REFUSAL);
});
});
describe("a guest's stores", () => {
it('list what the server offers, and refuse every write without asking it', async () => {
const mock = stubFetch(JSON.stringify([{ file: FILE, demo: true }]), { status: 200 });
const textures = guestFiles(new HttpFileStore('/api', 'textures'));
expect(await textures.list()).toEqual([{ file: FILE, demo: true }]);
expect(mock).toHaveBeenCalledTimes(1);
await expect(textures.upload(new Blob(['x']), 'image/png', 'x.png')).rejects.toThrow(GUEST_REFUSAL);
await expect(textures.delete(FILE)).rejects.toThrow(GUEST_REFUSAL);
const videos = guestVideos({
...new HttpFileStore('/api', 'videos'),
convert: () => Promise.reject(new Error('unreached')),
} as never);
await expect(videos.convert('x.mp4')).rejects.toThrow(GUEST_REFUSAL);
await expect(guestSnapshots.create(TOKEN, 'one')).rejects.toThrow(GUEST_REFUSAL);
await expect(guestReleases.publish(TOKEN)).rejects.toThrow(GUEST_REFUSAL);
expect(mock).toHaveBeenCalledTimes(1);
});
});
describe('demo copies and demo material', () => {
it('are listed and loaded without a project id, and made per project', async () => {
const mock = stubFetch(JSON.stringify([]), { status: 200 });
const demos = new HttpDemoStore('/api');
await demos.list();
await demos.load(TOKEN);
await demos.publish('p1');
await demos.takeDown('p1');
const calls = mock.mock.calls.map(
([url, init]) => `${init?.method} ${decodeURIComponent(url as string)}`,
);
expect(calls).toEqual([
'GET /api?r=demos',
`GET /api?r=demos/${TOKEN}`,
'POST /api?r=projects/p1/demo',
'DELETE /api?r=projects/p1/demo',
]);
});
it('offering a file is only there for an administrator, and sends the switch', async () => {
expect(new HttpFileStore('/api', 'textures').setDemo).toBeUndefined();
const mock = stubFetch(null, { status: 204 });
await new HttpFileStore('/api', 'textures', true).setDemo!(FILE, true);
const [url, init] = mock.mock.calls[0]!;
expect(decodeURIComponent(url as string)).toBe(`/api?r=textures/${FILE}`);
expect(init?.method).toBe('PATCH');
expect(JSON.parse(init?.body as string)).toEqual({ demo: true });
});
it('is not made of a stand that was not saved', () => {
expect(snapshotBlocker('error', 'demo')).toBe(
'The project could not be saved, so there is nothing current to make a demo copy of',
);
expect(snapshotBlocker('saved', 'demo')).toBeNull();
});
});
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Demo copies and demo material (#428): the list of offered files, the copies a guest picks from,
* and that a demo copy counts as a use of a file. Plain PHP like releases.test.php; CI runs it with
* `php tests/php/demos.test.php` and reads the last line.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/DemoFiles.php';
require __DIR__ . '/../../api/lib/FileUsage.php';
require __DIR__ . '/../../api/lib/ReleaseStore.php';
require __DIR__ . '/../../api/lib/Store.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got, JSON_UNESCAPED_UNICODE), "\n";
}
$data = sys_get_temp_dir() . '/feuerwerk-php-demos-' . bin2hex(random_bytes(4));
mkdir("{$data}/projects", 0775, true);
// --- DemoFiles -------------------------------------------------------------------------------------
$files = new DemoFiles("{$data}/demo-files.json");
$tree = 'a590cab0f8b7/' . str_repeat('1', 64) . '.png';
$song = 'a590cab0f8b7/' . str_repeat('2', 64) . '.ogg';
check('nothing is offered before an administrator offers it', $files->of('textures') === [] && !$files->has('textures', $tree));
check('offering a file says it changed something', $files->set('textures', $tree, true) === true);
check('and it is offered', $files->has('textures', $tree) && $files->of('textures') === [$tree]);
check('offering it again changes nothing', $files->set('textures', $tree, true) === false && $files->of('textures') === [$tree]);
$files->set('sounds', $song, true);
check('each kind has its own list', $files->of('sounds') === [$song] && !$files->has('sounds', $tree));
check('a new reader finds the same', (new DemoFiles("{$data}/demo-files.json"))->of('textures') === [$tree]);
check('the list is not for the web server', (fileperms("{$data}/demo-files.json") & 0o007) === 0);
check('taking the offer back', $files->set('textures', $tree, false) === true && $files->of('textures') === []);
check('leaves the other kinds', $files->of('sounds') === [$song]);
$threw = false;
try {
$files->set('pixels', $tree, true);
} catch (InvalidArgumentException) {
$threw = true;
}
check('a kind that cannot be offered is refused', $threw);
// --- Demo copies -----------------------------------------------------------------------------------
$demos = new ReleaseStore("{$data}/demos");
check('without demo copies the list is empty', $demos->list() === []);
$store = new Store("{$data}/projects", 'admin');
$platformer = $store->create('Platformer');
$empty = $store->create('Empty project');
$saved = $store->save($platformer['id'], 1, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]);
$first = $demos->publish($saved);
$demos->publish($empty);
$list = $demos->list();
check('the list names every copy, by name', array_column($list, 'name') === ['Empty project', 'Platformer'], $list);
check('with its token and when it was made', ReleaseStore::isValidToken($list[1]['token']) && $list[1]['publishedAt'] !== '', $list[1]);
check('and nothing of the project itself', !array_key_exists('project', $list[0]) && !array_key_exists('projectId', $list[0]));
check('the token loads the frozen copy', $demos->load($first['token'])['project']['assets'][0]['file'] === $song);
$store->save($platformer['id'], 2, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]);
check('changing the project leaves the copy as it was', $demos->load($first['token'])['project']['assets'] !== []);
$again = $demos->publish($store->load($platformer['id']));
check('updating it keeps the token and takes the new stand', $again['token'] === $first['token'] && $demos->load($first['token'])['project']['assets'] === []);
// --- A demo copy uses its files --------------------------------------------------------------------
$store->save($platformer['id'], 3, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]);
$demos->publish($store->load($platformer['id']));
$store->save($platformer['id'], 4, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]);
$uses = (new FileUsage($data))->of($song);
check('a file only the demo copy still names counts as used', count($uses) === 1 && $uses[0]['id'] === $platformer['id'], $uses);
check('and the refusal says so', str_contains(FileUsage::refusal($uses, 'admin', true), 'demo copies'));
$demos->takeDown($platformer['id']);
check('once the copy is gone, so is the use', (new FileUsage($data))->of($song) === []);
exec('rm -rf ' . escapeshellarg($data));
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);
+3 -3
View File
@@ -127,17 +127,17 @@ $uses = [
];
check(
'the refusal names the asker\'s projects only',
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases',
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots, releases and demo copies',
FileUsage::refusal($uses, 'anna', false),
);
check(
'an administrator learns whose the others are',
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases',
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots, releases and demo copies',
FileUsage::refusal($uses, 'anna', true),
);
check(
'somebody with none of them learns only how many',
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases',
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots, releases and demo copies',
FileUsage::refusal($uses, 'carl', false),
);
+61 -9
View File
@@ -149,10 +149,13 @@ $saved = call('PUT', "projects/{$project['id']}", $alice, [
]);
$snapshot = call('POST', "projects/{$project['id']}/snapshots", $alice, ['name' => 'One'])['body'];
$release = call('POST', "projects/{$project['id']}/release", $alice)['body'];
// An administrator makes a demo copy of it and offers her image to everybody (#428).
$demo = call('POST', "projects/{$project['id']}/demo", $admin)['body'];
$offered = call('PATCH', "textures/{$texture}", $admin, ['demo' => true])['status'];
check(
'alice has her project, image, sound, snapshot and release',
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token']),
[$saved['status'], $texture, $sound, $snapshot, $release],
'alice has her project, image, sound, snapshot, release and a demo copy, her image is offered',
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204,
[$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered],
);
$folder = explode('/', $texture)[0];
$p = $project['id'];
@@ -190,23 +193,32 @@ $matrix = [
['POST', "projects/{$p}/release", null, 'POST projects/{}/release', [401, 200, 404, 200]],
['DELETE', "projects/{$p}/release", null, 'DELETE projects/{}/release', [401, null, 404, null]],
['GET', "releases/{$release['token']}", null, 'GET releases/{}', [200, 200, 200, 200]],
['GET', 'demos', null, 'GET demos', [200, 200, 200, 200]],
['GET', "demos/{$demo['token']}", null, 'GET demos/{}', [200, 200, 200, 200]],
['GET', "projects/{$p}/demo", null, 'GET projects/{}/demo', [401, 403, 403, 200]],
['POST', "projects/{$p}/demo", null, 'POST projects/{}/demo', [401, 403, 403, 200]],
['DELETE', "projects/{$p}/demo", null, 'DELETE projects/{}/demo', [401, 403, 403, null]],
['GET', 'textures', null, 'GET textures', [401, 200, 200, 200]],
['GET', 'textures', null, 'GET textures', [200, 200, 200, 200]],
['POST', 'textures&name=b.png', $png, 'POST textures', [401, 201, 201, 201]],
['DELETE', "textures/{$texture}", null, 'DELETE textures/{}', [401, 409, 403, 409]],
['GET', 'fonts', null, 'GET fonts', [401, 200, 200, 200]],
['PATCH', "textures/{$texture}", ['demo' => true], 'PATCH textures/{}', [401, 403, 403, 204]],
['GET', 'fonts', null, 'GET fonts', [200, 200, 200, 200]],
['POST', 'fonts&name=x.ttf', 'not a font at all', 'POST fonts', [401, 422, 422, 422]],
['DELETE', "fonts/{$missing}.ttf", null, 'DELETE fonts/{}', [401, 404, 403, 404]],
['GET', 'sounds', null, 'GET sounds', [401, 200, 200, 200]],
['PATCH', "fonts/{$missing}.ttf", ['demo' => true], 'PATCH fonts/{}', [401, 403, 403, 404]],
['GET', 'sounds', null, 'GET sounds', [200, 200, 200, 200]],
['POST', 'sounds&name=b.wav', $wav(32, 'b'), 'POST sounds', [401, 201, 201, 201]],
['DELETE', "sounds/{$sound}", null, 'DELETE sounds/{}', [401, null, 403, null]],
['GET', 'videos', null, 'GET videos', [401, 200, 200, 200]],
['PATCH', "sounds/{$sound}", ['demo' => 'yes'], 'PATCH sounds/{}', [401, 403, 403, 422]],
['GET', 'videos', null, 'GET videos', [200, 200, 200, 200]],
['POST', 'videos&name=x.mov&convert=0', 'not a video at all', 'POST videos', [401, 422, 422, 422]],
['POST', "videos/{$missing}.mp4/convert", null, 'POST videos/{}/convert', [401, 404, 403, 404]],
['DELETE', "videos/{$missing}.mp4", null, 'DELETE videos/{}', [401, 404, 403, 404]],
['PATCH', "videos/{$missing}.mp4", ['demo' => true], 'PATCH videos/{}', [401, 403, 403, 404]],
['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]],
['GET', 'plugins', null, 'GET plugins', [401, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [401, 404, 404, 404]],
['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]],
];
$actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin];
@@ -238,6 +250,41 @@ $unasked = array_values(array_diff($documentedRoutes, $asked));
check('every route of the API is in the matrix', count($documentedRoutes) > 30 && $unasked === [], $unasked ?: count($documentedRoutes));
check('and the matrix asks about no route the API does not name', array_values(array_diff($asked, $documentedRoutes)) === [], array_values(array_diff($asked, $documentedRoutes)));
// --- Demo copies and demo material (#428) ---------------------------------------------------------
$signedOut = call('GET', 'textures')['body'];
check(
'signed out, the image list is the offered image alone, without who uploaded it',
count($signedOut) === 1 && $signedOut[0]['file'] === $texture && $signedOut[0]['demo'] === true
&& $signedOut[0]['owner'] === null && $signedOut[0]['deletable'] === false,
$signedOut,
);
$bobs = call('GET', 'textures', $bob)['body'];
$hers = array_values(array_filter($bobs, static fn(array $item): bool => $item['file'] === $texture));
check(
'another account lists it beside its own, offered and not for it to delete',
count($hers) === 1 && $hers[0]['demo'] === true && $hers[0]['deletable'] === false && $hers[0]['owner'] === 'alice',
$hers,
);
check(
'its owner sees it marked as well',
in_array(true, array_column(call('GET', 'textures', $alice)['body'], 'demo'), true),
);
check('the signed-out sound list is empty while nothing is offered', call('GET', 'sounds')['body'] === []);
call('PATCH', "sounds/{$sound}", $admin, ['demo' => true]);
$kept = call('DELETE', "sounds/{$sound}", $alice);
check(
'its owner cannot delete a file while everybody is offered it',
$kept['status'] === 409 && str_contains((string) ($kept['body']['error'] ?? ''), 'demo material'),
$kept,
);
$demos = call('GET', 'demos')['body'];
check('the demo list names the copy, signed out', array_column($demos, 'name') === ['Alice'] && $demos[0]['token'] === $demo['token'], $demos);
$opened = call('GET', "demos/{$demo['token']}")['body'];
check('a demo copy goes out under its token, not the project id', ($opened['project']['id'] ?? null) === $demo['token'], $opened);
check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404);
check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404);
// --- Paths, depth and unknown routes --------------------------------------------------------------
check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401);
@@ -278,6 +325,11 @@ $big = ['id' => $p, 'name' => 'Alice', 'assets' => [], 'pad' => str_repeat('x',
check('so is a save that would grow a project past it', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => $big])['status'] === 413);
check('a save that keeps it small goes through', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => ['name' => 'Alice']])['status'] === 200);
// --- Deleting the project takes its copies along ---------------------------------------------------
check('deleting the project', call('DELETE', "projects/{$p}", $alice)['status'] === 204);
check('takes its demo copy along', call('GET', "demos/{$demo['token']}")['status'] === 404 && call('GET', 'demos')['body'] === []);
// --- Sign-ins: the brake -------------------------------------------------------------------------
$gone = signIn('carol', 'password-carol');
+248
View File
@@ -0,0 +1,248 @@
import { authHeaders, e2eAdminPassword, E2E_ADMIN, logIn, SESSION_COOKIE } from '../lib/auth.mjs';
import { sceneObject } from '../lib/builders.mjs';
import { pickAssetType } from './lib/scaffold.mjs';
/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */
const TEMP_NAME = 'Guest 70';
function game(id) {
return {
formatVersion: 2,
id,
name: TEMP_NAME,
activeSceneId: 'scene',
assets: [
{ id: 'scene', type: 'scene', name: 'Level', props: {}, objects: [sceneObject('crate', 'crate')] },
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 40, height: 40, color: 0xe0553f } },
],
};
}
/**
* Trying the editor without an account (#428): an administrator makes the demo copy of a project
* from the main menu and offers a sound as demo material; a guest picks the copy on the sign-in
* screen, changes it, and not one request that writes leaves the tab. Signing in from the account
* menu and picking the demo again brings the copy back as it was.
*
* Works on a throwaway project of its own, like step 60, and hands the editor back signed in.
*/
export async function guest(t) {
const { check, cdp, json, locate, find, click, mouse, sleep, waitUntil, settle, press } = t;
const { clickToolbar, clickText, shot, baseUrl, navigate, exceptions, saved } = t;
const admin = await logIn(baseUrl, E2E_ADMIN, await e2eAdminPassword());
const send = async (method, route, { as = 'e2e', body, type = 'application/json' } = {}) => {
const cookie = as === 'admin' ? { Cookie: admin } : as === 'e2e' ? await authHeaders(baseUrl) : {};
return fetch(`${baseUrl}api/index.php?r=${route}`, {
method,
headers: { ...cookie, 'Content-Type': type },
body: body instanceof Uint8Array || body === undefined ? body : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
};
const sweep = async () => {
for (const project of await (await send('GET', 'projects')).json()) {
if (project.name === TEMP_NAME) await send('DELETE', `projects/${project.id}`);
}
};
// Pressed by hand, as in step 45: the sign-in screen comes and goes between the frames `click` waits for.
const pressAt = async (spot) => {
await mouse('mouseMoved', spot.x, spot.y);
await mouse('mousePressed', spot.x, spot.y);
await mouse('mouseReleased', spot.x, spot.y);
};
const clickLast = async (text) => {
await settle();
const at = await locate(text, true);
await click(at.x, at.y);
};
const mainMenu = async () => {
await clickToolbar('menu');
const rows = await json('__gm.menuRows()');
await press('Escape');
return rows;
};
// Every request of the tab that is not a read, from the moment the guest picks a demo on.
let watching = false;
const writes = [];
cdp.on((msg) => {
if (!watching || msg.method !== 'Network.requestWillBeSent') return;
const { method, url } = msg.params.request;
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') writes.push(`${method} ${url}`);
});
await saved();
await sweep();
const made = await (await send('POST', 'projects', { body: { name: TEMP_NAME } })).json();
const put = await send('PUT', `projects/${made.id}`, {
body: { revision: made.revision, project: game(made.id) },
});
if (!put.ok) throw new Error(`could not write the project: ${put.status}`);
// A sound of this run's own to offer: random bytes behind a WAV header, as in step 34.
const bytes = crypto.getRandomValues(new Uint8Array(48));
bytes.set(
[...'RIFF'].map((c) => c.charCodeAt(0)),
0,
);
bytes.set(
[...'WAVE'].map((c) => c.charCodeAt(0)),
8,
);
const spare = (
await (await send('POST', 'sounds&name=demo-70.wav', { body: bytes, type: 'audio/wav' })).json()
).file;
try {
// 70a. What guests get is the administrators' call: an account can neither make a demo copy nor
// offer a file.
const refused = {
copy: (await send('POST', `projects/${made.id}/demo`)).status,
offer: (await send('PATCH', `sounds/${spare}`, { body: { demo: true } })).status,
};
check(
'an account that is no administrator makes no demo copy and offers no file (403)',
refused.copy === 403 && refused.offer === 403,
JSON.stringify(refused),
);
// 70b. The administrator makes the copy from the main menu of the project.
await cdp.send('Network.setCookie', { name: SESSION_COOKIE, value: admin.split('=')[1], url: baseUrl });
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
await sleep(4000);
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
timeout: 15000,
label: 'the administrator has the throwaway project open',
});
const adminMenu = await mainMenu();
await clickToolbar('menu');
await clickText('Demo copy…');
await clickLast('Make demo copy');
await waitUntil('__gm.state.notice.startsWith("Demo copy made")', { label: 'the demo copy is made' });
const listed = await (await send('GET', 'demos', { as: 'nobody' })).json();
const token = listed.find((demo) => demo.name === TEMP_NAME)?.token ?? '';
check(
"the administrator's main menu makes the demo copy, which is listed without a session",
adminMenu.includes('Demo copy…') && /^[a-f0-9]{32}$/.test(token),
JSON.stringify({ adminMenu, listed }),
);
// 70c. Demo material: offered by the administrator, listed to nobody in particular, and no longer
// the uploader's to delete.
const offered = (await send('PATCH', `sounds/${spare}`, { as: 'admin', body: { demo: true } })).status;
const open = await (await send('GET', 'sounds', { as: 'nobody' })).json();
const mine = (await (await send('GET', 'sounds')).json()).find((item) => item.file === spare);
const keep = (await send('DELETE', `sounds/${spare}`)).status;
check(
'an offered file is listed without a session, without its owner, and only an administrator deletes it',
offered === 204 &&
open.some((item) => item.file === spare && item.demo === true && item.owner === null) &&
open.every((item) => item.demo === true) &&
mine?.demo === true &&
keep === 409,
JSON.stringify({ offered, open: open.map((i) => [i.file, i.demo, i.owner]), mine, keep }),
);
// 70d. Without a session the sign-in screen offers the demos; picking one opens it as a guest.
await cdp.send('Network.clearBrowserCookies');
await cdp.send('Page.navigate', { url: baseUrl });
await waitUntil('Boolean(window.__gm?.login)', { timeout: 15000, label: 'the sign-in screen is up' });
watching = true;
const pickDemo = async () => {
await pressAt(await locate('Try without an account'));
await sleep(600);
await pressAt(await locate(TEMP_NAME));
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(token)}`, {
timeout: 15000,
label: 'the guest has the demo open',
});
await sleep(500);
};
await pickDemo();
const entered = {
title: await json('document.title'),
notice: await json('__gm.state.notice'),
status: Boolean(await find('Guest – not saved')),
assets: await json('__gm.state.project.assets.length'),
address: await json('location.search'),
};
const guestMenu = await mainMenu();
check(
'a guest picks the demo on the sign-in screen and gets the copy, with the toolbar saying nothing is saved',
entered.title === 'Feuerwerk – Guest' &&
entered.notice.startsWith('You are trying a demo as a guest') &&
entered.status &&
entered.assets === 2 &&
!entered.address.includes('project='),
JSON.stringify(entered),
);
check(
"a guest's main menu neither switches projects nor has copies made",
['New project', 'Open project…', 'Snapshots…', 'Release…', 'Demo copy…'].every(
(row) => !guestMenu.includes(row),
) && guestMenu.includes('Project settings…'),
JSON.stringify(guestMenu),
);
await shot('70-guest');
// 70e. The pickers hold the demo material and no button that would write.
await clickText('+');
await pickAssetType(t, 'Sound');
await sleep(500);
const picker = await json('__gm.soundPicker()');
await press('Escape');
await settle();
check(
"a guest's picker lists the demo material, without Upload, Delete or the demo switch",
picker?.files.includes(spare) &&
picker.demo.includes(spare) &&
picker.files.every((file) => picker.demo.includes(file)) &&
!picker.uploadShown &&
picker.deleteButton === null &&
picker.offerButton === null,
JSON.stringify(picker),
);
// 70f. A change stays in the tab: well past the autosave's second, nothing has gone out.
await clickText('+');
await pickAssetType(t, 'Sprite');
await sleep(2500);
const changed = await json('__gm.state.project.assets.length');
const copy = await (await send('GET', `demos/${token}`, { as: 'nobody' })).json();
check(
"a guest's change stays in the tab: no request that writes, and the copy on the server as it was",
changed === 3 && writes.length === 0 && copy.project.assets.length === 2,
JSON.stringify({ changed, writes, served: copy.project.assets.length }),
);
// 70g. Sign in… goes back to the sign-in screen; the demo picked again is the copy as it was.
await pressAt(await locate('Account'));
await sleep(300);
const accountMenu = await json('({ rows: __gm.menuRows(), enabled: __gm.menuEnabled() })');
await pressAt(await locate('Sign in…'));
await waitUntil('Boolean(window.__gm?.login) && !window.__gm?.state', {
label: 'the sign-in screen is back',
});
await pickDemo();
const again = await json('__gm.state.project.assets.length');
watching = false;
check(
"the guest's account menu signs in instead, and the demo picked again is the copy as it was",
accountMenu.rows[0] === 'Guest – nothing is saved' &&
accountMenu.enabled[0] === false &&
accountMenu.rows.includes('Sign in…') &&
!accountMenu.rows.includes('Logout') &&
again === 2 &&
writes.length === 0,
JSON.stringify({ accountMenu, again, writes }),
);
check('the guest session threw nothing', exceptions().length === 0, exceptions().join(' | '));
} finally {
watching = false;
await sweep();
await send('PATCH', `sounds/${spare}`, { as: 'admin', body: { demo: false } });
await send('DELETE', `sounds/${spare}`);
await send('POST', 'auth/logout', { as: 'admin' });
await navigate();
}
}
+2
View File
@@ -72,6 +72,7 @@ import { video } from './66-video.mjs';
import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
import { compositionTab } from './69-composition-tab.mjs';
import { guest } from './70-guest.mjs';
export const STEPS = [
hierarchy,
@@ -143,4 +144,5 @@ export const STEPS = [
recording,
composition,
compositionTab,
guest,
];