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>
174 lines
6.2 KiB
PHP
174 lines
6.2 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Released games (#266): a frozen copy of a project that anybody with the link can play.
|
||
*
|
||
* data/releases/{token}.json { token, projectId, name, revision, publishedAt, project }
|
||
* data/releases/projects/{projectId}.json { token } which link a project has
|
||
*
|
||
* One link per project: publishing again replaces the copy and keeps the token, so a link handed out
|
||
* stays good. The token is 128 random bits and is the only secret, so both kinds of file are written
|
||
* 0640 – the web server serves `data/` as static files and must not hand them out by name.
|
||
*
|
||
* 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
|
||
{
|
||
public function __construct(private readonly string $dir)
|
||
{
|
||
}
|
||
|
||
public static function isValidToken(string $token): bool
|
||
{
|
||
return (bool) preg_match('/^[a-f0-9]{32}$/', $token);
|
||
}
|
||
|
||
/** @return array{token:string,revision:int,publishedAt:string}|null */
|
||
public function of(string $projectId): ?array
|
||
{
|
||
$token = $this->tokenOf($projectId);
|
||
$release = $token === null ? null : $this->load($token);
|
||
return $release === null ? null : self::summary($release);
|
||
}
|
||
|
||
/**
|
||
* Copies a stored project document into the project's release, under the token it already has.
|
||
*
|
||
* @param array{id:string,name:string,revision:int,project:array} $doc
|
||
* @return array{token:string,revision:int,publishedAt:string}
|
||
*/
|
||
public function publish(array $doc): array
|
||
{
|
||
$token = $this->tokenOf($doc['id']);
|
||
if ($token === null) {
|
||
do {
|
||
$token = bin2hex(random_bytes(16));
|
||
} while (file_exists($this->path($token)));
|
||
}
|
||
$release = [
|
||
'token' => $token,
|
||
'projectId' => $doc['id'],
|
||
'name' => $doc['name'],
|
||
'revision' => $doc['revision'],
|
||
'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||
'project' => $doc['project'],
|
||
];
|
||
// The copy first, the pointer to it second: a crash in between leaves an unused file, never
|
||
// a project pointing at nothing.
|
||
$this->write($this->path($token), $release);
|
||
$this->write($this->indexPath($doc['id']), ['token' => $token]);
|
||
return self::summary($release);
|
||
}
|
||
|
||
/** @return array{token:string,projectId:string,name:string,revision:int,publishedAt:string,project:array}|null */
|
||
public function load(string $token): ?array
|
||
{
|
||
if (!self::isValidToken($token)) {
|
||
return null;
|
||
}
|
||
$release = $this->read($this->path($token));
|
||
if ($release === null || !is_array($release['project'] ?? null) || !is_string($release['projectId'] ?? null)) {
|
||
return null;
|
||
}
|
||
return [
|
||
'token' => $token,
|
||
'projectId' => $release['projectId'],
|
||
'name' => is_string($release['name'] ?? null) ? $release['name'] : '',
|
||
'revision' => (int) ($release['revision'] ?? 0),
|
||
'publishedAt' => is_string($release['publishedAt'] ?? null) ? $release['publishedAt'] : '',
|
||
'project' => $release['project'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$token = $this->tokenOf($projectId);
|
||
$index = $this->indexPath($projectId);
|
||
if (is_file($index)) {
|
||
unlink($index);
|
||
}
|
||
if ($token === null) {
|
||
return false;
|
||
}
|
||
$path = $this->path($token);
|
||
if (is_file($path)) {
|
||
unlink($path);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function tokenOf(string $projectId): ?string
|
||
{
|
||
$token = $this->read($this->indexPath($projectId))['token'] ?? null;
|
||
return is_string($token) && self::isValidToken($token) ? $token : null;
|
||
}
|
||
|
||
/** @return array{token:string,revision:int,publishedAt:string} */
|
||
private static function summary(array $release): array
|
||
{
|
||
return [
|
||
'token' => $release['token'],
|
||
'revision' => $release['revision'],
|
||
'publishedAt' => $release['publishedAt'],
|
||
];
|
||
}
|
||
|
||
private function path(string $token): string
|
||
{
|
||
return $this->dir . '/' . $token . '.json';
|
||
}
|
||
|
||
private function indexPath(string $projectId): string
|
||
{
|
||
return $this->dir . '/projects/' . $projectId . '.json';
|
||
}
|
||
|
||
private function read(string $path): ?array
|
||
{
|
||
if (!is_file($path)) {
|
||
return null;
|
||
}
|
||
$raw = file_get_contents($path);
|
||
$data = $raw === false ? null : json_decode($raw, true);
|
||
return is_array($data) ? $data : null;
|
||
}
|
||
|
||
private function write(string $path, array $data): void
|
||
{
|
||
$folder = dirname($path);
|
||
if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) {
|
||
throw new RuntimeException("Cannot create release directory {$folder}");
|
||
}
|
||
$json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
if ($json === false) {
|
||
throw new RuntimeException('Cannot encode release');
|
||
}
|
||
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file');
|
||
}
|
||
}
|