Files
feuerwerk/api/lib/Store.php
T

185 lines
6.5 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})");
}
}
/**
* 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.
*/
final class Store
{
public function __construct(
private readonly string $dir,
private readonly string $owner,
) {
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
{
$items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$doc = $this->readFile($file);
if ($doc === null || $doc['owner'] !== $this->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, 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);
$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Cannot acquire project lock');
}
try {
$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, $expectedRevision + 1, $project);
$this->writeFile($path, $doc);
return $doc;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
@unlink($path . '.lock');
}
}
public function delete(string $id): bool
{
if ($this->load($id) === null) {
return false;
}
$path = $this->path($id);
return file_exists($path) && unlink($path);
}
/** The document, or null when it belongs to someone else. */
private function own(?array $doc): ?array
{
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
}
private function path(string $id): string
{
return $this->dir . '/' . $id . '.json';
}
private function document(string $id, string $name, int $revision, array $project): array
{
return [
'id' => $id,
'name' => $name,
'owner' => $this->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');
}
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write project file');
}
}
}