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>
130 lines
4.8 KiB
PHP
130 lines
4.8 KiB
PHP
<?php
|
||
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, 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.
|
||
*/
|
||
final class FileUsage
|
||
{
|
||
public function __construct(private readonly string $data) {}
|
||
|
||
/**
|
||
* The projects using `$file`, each once.
|
||
*
|
||
* @return list<array{id:string,owner:string,name:string}>
|
||
*/
|
||
public function of(string $file): array
|
||
{
|
||
$used = [];
|
||
foreach (glob("{$this->data}/projects/*.json") ?: [] as $path) {
|
||
if ($this->names($path, $file)) {
|
||
$used[basename($path, '.json')] = true;
|
||
}
|
||
}
|
||
foreach (glob("{$this->data}/snapshots/*/*.json") ?: [] as $path) {
|
||
if ($this->names($path, $file)) {
|
||
$used[basename(dirname($path))] = true;
|
||
}
|
||
}
|
||
// 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;
|
||
}
|
||
}
|
||
$uses = [];
|
||
foreach (array_keys($used) as $id) {
|
||
$uses[] = ['id' => (string) $id, ...$this->project((string) $id)];
|
||
}
|
||
return $uses;
|
||
}
|
||
|
||
/**
|
||
* Why a file cannot go. The asker's own projects are named; somebody else's only for an
|
||
* administrator, everybody else learns no more than that there is one.
|
||
*
|
||
* @param list<array{id:string,owner:string,name:string}> $uses
|
||
*/
|
||
public static function refusal(array $uses, string $asker, bool $seesAll): string
|
||
{
|
||
/** @var array<string,int> $labels each name once, with how many projects carry it */
|
||
$labels = [];
|
||
$others = 0;
|
||
foreach ($uses as $use) {
|
||
if (strcasecmp($use['owner'], $asker) === 0) {
|
||
$label = "“{$use['name']}”";
|
||
} elseif ($seesAll) {
|
||
$label = "“{$use['name']}” of " . ($use['owner'] === '' ? 'nobody' : $use['owner']);
|
||
} else {
|
||
$others++;
|
||
continue;
|
||
}
|
||
$labels[$label] = ($labels[$label] ?? 0) + 1;
|
||
}
|
||
$named = [];
|
||
foreach ($labels as $label => $count) {
|
||
$named[] = $count === 1 ? $label : "{$label} ({$count} projects)";
|
||
}
|
||
if ($others > 0) {
|
||
$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, releases and demo copies";
|
||
}
|
||
|
||
/** Whether a value holds `$file` as a string anywhere inside it. */
|
||
public static function uses(mixed $value, string $file): bool
|
||
{
|
||
if (is_string($value)) {
|
||
return $value === $file;
|
||
}
|
||
if (is_array($value)) {
|
||
foreach ($value as $item) {
|
||
if (self::uses($item, $file)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private function names(string $path, string $file): bool
|
||
{
|
||
$doc = $this->decoded($path, $file);
|
||
return $doc !== null && self::uses($doc['project'] ?? null, $file);
|
||
}
|
||
|
||
/**
|
||
* The document, or null when it cannot name the file. The text is searched for the hash first,
|
||
* which has no slash to be escaped: most documents are never decoded.
|
||
*/
|
||
private function decoded(string $path, string $file): ?array
|
||
{
|
||
$raw = file_get_contents($path);
|
||
if ($raw === false || !str_contains($raw, basename($file))) {
|
||
return null;
|
||
}
|
||
$doc = json_decode($raw, true);
|
||
return is_array($doc) ? $doc : null;
|
||
}
|
||
|
||
/** @return array{owner:string,name:string} */
|
||
private function project(string $id): array
|
||
{
|
||
$path = "{$this->data}/projects/{$id}.json";
|
||
$raw = is_file($path) ? file_get_contents($path) : false;
|
||
$doc = $raw === false ? null : json_decode($raw, true);
|
||
return [
|
||
'owner' => is_string($doc['owner'] ?? null) ? $doc['owner'] : '',
|
||
'name' => is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled',
|
||
];
|
||
}
|
||
}
|