Let guests try a demo project without an account (#428)
The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.
- Demo copies are frozen copies like a release, in data/demos/. An
administrator makes, updates and removes one with "Demo copy..." in
the project's main menu. GET demos and GET demos/{token} need no
session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
list carries it with demo: true, for accounts and guests alike.
Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
would write refuses in the tab before a request is made, and the
autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
pickers drop Upload and Delete, a dropped or pasted file is refused,
and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
"Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
on. It only reads the server's own modules.
Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce1f25f2eb
commit
925a39a276
30 files changed
+1397
-128
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+110
-41
@@ -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,38 +101,70 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
state.on('project', () => {
|
||||
if (!isDragFrame()) preloadProjectFiles(state.project);
|
||||
});
|
||||
const editor = new Editor(
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
new CelUploadStore(
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
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),
|
||||
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.
|
||||
changePassword: (current, next) => auth.changePassword(current, next),
|
||||
},
|
||||
);
|
||||
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,
|
||||
new CelUploadStore(
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
pixelCels,
|
||||
new HttpFileStore(API_ENDPOINT, 'pixels'),
|
||||
),
|
||||
textures,
|
||||
fonts,
|
||||
sounds,
|
||||
videos,
|
||||
new HttpSnapshotStore(API_ENDPOINT),
|
||||
new HttpReleaseStore(API_ENDPOINT),
|
||||
{
|
||||
user,
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
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),
|
||||
},
|
||||
);
|
||||
installDebugHook(app, state, editor);
|
||||
// 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();
|
||||
try {
|
||||
await auth.logOut();
|
||||
} catch (error) {
|
||||
console.error('[auth] Signing out did not reach the server:', error);
|
||||
// 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);
|
||||
}
|
||||
+42
-14
@@ -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,16 +484,20 @@ export class Editor implements PlayHost {
|
||||
: null;
|
||||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
step: () => this.play.step(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
});
|
||||
this.toolbar = new Toolbar(
|
||||
state,
|
||||
{
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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[] = [
|
||||
{ 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() },
|
||||
...(may('projects')
|
||||
? [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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';
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ class SoundPicker extends FilePicker<SoundItem> {
|
||||
},
|
||||
() => pickAndImportSounds(deps),
|
||||
(file) => deps.sounds.delete(file),
|
||||
[],
|
||||
deps.sounds.setDemo,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user