Files
feuerwerk/api/lib/Store.php
T
adminandClaude Opus 5.5 cbda6d64a7
game-maker / gate (push) Successful in 2m36s
game-maker / suite (push) Successful in 13m2s
Let only the owner delete a project, not an administrator (#272)
#179 decided that nobody deletes a project on someone else's behalf.
The API still let an administrator do it, because the admin store
treated every project as its own. Option A from the issue is now in
place: opening, editing, snapshots and release stay open to the
administrator, deleting stays with the owner.

- Store::delete checks the owner under the save lock and throws
  NotOwnerException for anyone else. It follows the pattern of
  StaleRevisionException.
- The route answers that with 403 "Only its owner may delete a
  project". A file without an owner is covered the same way.
  Non-admins still get 404 for a project that is not theirs.
- The editor has no way to delete a foreign project, the project
  picker lists only one's own. Nothing changes there.
- An account that owns projects can no longer be deleted by an
  administrator at all. Its owner has to delete them first.
- e2e 49 deleted the throwaway projects as administrator, both in
  its sweep and in 49i. It now signs in as the owner for that.
  49i checks both answers.

Tests:
- tests/php/accounts.test.php: 56 checks passed (was 52, one check
  turned around, five new)
- e2e 49: 22 checks passed (was 21), deleteByAdmin 403,
  deleteByOwner 204
- Gate: 197 files, 2855 tests passed
- Counter-checks: without the owner check 4 PHP checks and 49i fail
- The e2e total of 1603 is computed (1602 + 1), not measured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:13:20 +02:00

254 lines
9.3 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);
/** Thrown by Store::save when the client's revision is older than the stored one. */
final class StaleRevisionException extends RuntimeException
{
public function __construct(public readonly int $currentRevision)
{
parent::__construct("Project was modified elsewhere (revision {$currentRevision})");
}
}
/** Thrown by Store::delete when an administrator reaches a project that is not their own (#272). */
final class NotOwnerException extends RuntimeException
{
public function __construct()
{
parent::__construct('Only its owner may delete a project');
}
}
/**
* File-based project store: one JSON document per project in `data/projects/{id}.json`.
* Each file wraps the project with metadata: { id, name, owner, revision, updatedAt, project }.
* Writes are atomic (temp file + rename) and guarded by an integer revision so that a
* stale client (second tab, old session) cannot silently overwrite newer work.
*
* A store belongs to one signed-in user: it lists, reads and writes only the projects whose
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
* because "this id exists" is already more than a stranger should learn.
*
* An administrator's store (`$seesAll`, #179) reads and writes every project as if it were their
* own, and a save keeps the owner it found. Deleting stays with the owner (#272). The list stays
* their own: the others are reached through the user management, one account at a time.
*/
final class Store
{
public function __construct(
private readonly string $dir,
private readonly string $owner,
private readonly bool $seesAll = false,
) {
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create data directory {$this->dir}");
}
}
public static function isValidId(string $id): bool
{
return (bool) preg_match('/^[a-z0-9]{6,32}$/', $id);
}
public static function newId(): string
{
return substr(bin2hex(random_bytes(8)), 0, 12);
}
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
public function list(): array
{
return $this->listOf($this->owner);
}
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
public function listOf(string $owner): array
{
$items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$doc = $this->readFile($file);
if ($doc === null || $doc['owner'] !== $owner) {
continue;
}
$items[] = [
'id' => $doc['id'],
'name' => $doc['name'],
'revision' => $doc['revision'],
'updatedAt' => $doc['updatedAt'],
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array}|null */
public function load(string $id): ?array
{
return $this->own($this->readFile($this->path($id)));
}
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array} */
public function create(string $name): array
{
do {
$id = self::newId();
} while (file_exists($this->path($id)));
$project = [
'formatVersion' => 1,
'id' => $id,
'name' => $name,
'assets' => [],
'activeSceneId' => '',
];
$doc = $this->document($id, $name, $this->owner, 1, $project);
$this->writeFile($this->path($id), $doc);
return $doc;
}
/**
* Saves a project when `$expectedRevision` matches the stored one.
* Returns the new document, null when the project does not exist, or throws
* StaleRevisionException (carrying the current revision) on a conflict.
*/
public function save(string $id, int $expectedRevision, array $project): ?array
{
$path = $this->path($id);
return $this->locked($path, function () use ($path, $id, $expectedRevision, $project): ?array {
$current = $this->own($this->readFile($path));
if ($current === null) {
return null;
}
if ($current['revision'] !== $expectedRevision) {
throw new StaleRevisionException($current['revision']);
}
$name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name'];
$project['id'] = $id;
$doc = $this->document($id, $name, $current['owner'], $expectedRevision + 1, $project);
$this->writeFile($path, $doc);
return $doc;
});
}
/**
* Deletes a project of this store's user. False when there is none to reach; throws
* NotOwnerException when an administrator reaches one of somebody else's.
*/
public function delete(string $id): bool
{
$path = $this->path($id);
// Under the save lock, or a save running at the same time writes the project back.
$deleted = $this->locked($path, function () use ($id, $path): bool {
$doc = $this->load($id);
if ($doc === null) {
return false;
}
if ($doc['owner'] !== $this->owner) {
throw new NotOwnerException();
}
return unlink($path);
});
if ($deleted) {
// Whoever still waits for the lock finds no project and writes nothing.
@unlink($path . '.lock');
}
return $deleted;
}
/**
* Hands every project of `$from` to `$to` – what renaming an account does, since the name is
* the owner. Revision and date stay: nothing in the project changed, and a tab that has one of
* them open must not be told it was modified elsewhere. Returns how many moved.
*/
public function transfer(string $from, string $to): int
{
$moved = 0;
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
// Under the save lock, or a save running at the same time writes the old owner back.
$moved += $this->locked($file, function () use ($file, $from, $to): int {
$doc = $this->readFile($file);
if ($doc === null || $doc['owner'] !== $from) {
return 0;
}
$doc['owner'] = $to;
$this->writeFile($file, $doc);
return 1;
});
}
return $moved;
}
/**
* The lock file stays when the work is done. Removing it would let two saves in at once: one
* still waiting holds the removed file, while the next one creates a new file and locks that.
*
* @template T
* @param callable():T $work
* @return T
*/
private function locked(string $path, callable $work): mixed
{
$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Cannot acquire project lock');
}
try {
return $work();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** The document, or null when it belongs to someone else and this store does not see everything. */
private function own(?array $doc): ?array
{
return $doc !== null && ($this->seesAll || $doc['owner'] === $this->owner) ? $doc : null;
}
private function path(string $id): string
{
return $this->dir . '/' . $id . '.json';
}
private function document(string $id, string $name, string $owner, int $revision, array $project): array
{
return [
'id' => $id,
'name' => $name,
'owner' => $owner,
'revision' => $revision,
'updatedAt' => gmdate('c'),
'project' => $project,
];
}
private function readFile(string $path): ?array
{
if (!is_file($path)) {
return null;
}
$raw = file_get_contents($path);
$doc = $raw === false ? null : json_decode($raw, true);
if (!is_array($doc) || !isset($doc['id'], $doc['revision'], $doc['project'])) {
return null;
}
$doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled';
// A file without an owner belongs to nobody and is therefore invisible to everybody –
// the safe way round, and the one that shows up straight away if a migration is missed.
$doc['owner'] = is_string($doc['owner'] ?? null) ? $doc['owner'] : '';
$doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : '';
$doc['revision'] = (int) $doc['revision'];
return $doc;
}
private function writeFile(string $path, array $doc): void
{
$json = json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('Cannot encode project');
}
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'project file');
}
}