Files
feuerwerk/api/lib/FileUsage.php
T
adminandClaude Opus 5.5 925a39a276
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s
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>
2026-10-16 06:58:30 +02:00

130 lines
4.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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',
];
}
}