Give each account its own folder of uploaded files to delete (#344)
Uploaded images, fonts and sounds belonged to nobody. Every account saw every file in the pickers, and only an administrator could delete one, through the API alone. Now every account uploads into a folder of its own, sees its own files and nobody's, and can delete a file of its own that no project uses. - Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the folder is part of asset.file. A name without a folder belongs to nobody. - The folder is a random 12-character name kept in users.json and given with the first upload. Renaming an account moves no file, and a released game's URLs carry no account name. - The list shows the caller's own files and nobody's. An administrator sees all, each with owner and deletable. - DELETE takes the name with its folder. It answers 403 unless the file is the caller's or the caller is an administrator, and 409 while a project, snapshot or release of any account names it (FileUsage). The 409 names the caller's own projects only. - The pickers have a Delete button. The first press asks, the second deletes, and a refusal shows the server's reason. - A deleted account's folder stays and only administrators see it. - tools/migrate-file-owners.php moved every used file into the folder of the account using it (a copy each for two accounts) and rewrote projects, snapshots and releases. A project's revision goes up, so an open editor gets a conflict instead of saving old names back. - Font families are named after the hash alone, so a folder never ends up in a CSS family name. Run on stein2 on 2026-10-07, backup in /root/config-backup-20261007/data-before-344: 25 files used, 27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30 unused files stay nobody's. Afterwards all 600 references resolve to a file in their owner's folder. Found while building: the Delete button cut its new label to "Del…" because a button does not measure itself again on a new text. The picker now lays its button row out again. Tests: - PHP files.test.php (new): 46 checks, counter-check 3 red - Unit core + editor: 220 files, 3403 tests - e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2d0a02645
commit
6b49a0bea7
34 files changed
+910
-111
No files matched your search
+56
-23
@@ -51,17 +51,23 @@ declare(strict_types=1);
|
||||
*
|
||||
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
|
||||
*
|
||||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
||||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt,owner,deletable}]
|
||||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204 (administrators only, 403 for everybody else)
|
||||
* DELETE ?r=textures/{file} → 204 | 403 | 404 | 409 (still used)
|
||||
*
|
||||
* GET ?r=fonts → [{file,name,bytes,updatedAt}]
|
||||
* GET ?r=fonts → [{file,name,bytes,updatedAt,owner,deletable}]
|
||||
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||||
* DELETE ?r=fonts/{file} → 204 (administrators only)
|
||||
* DELETE ?r=fonts/{file} → 204 | 403 | 404 | 409
|
||||
*
|
||||
* GET ?r=sounds → [{file,name,bytes,updatedAt}]
|
||||
* GET ?r=sounds → [{file,name,bytes,updatedAt,owner,deletable}]
|
||||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||||
* DELETE ?r=sounds/{file} → 204 (administrators only)
|
||||
* DELETE ?r=sounds/{file} → 204 | 403 | 404 | 409
|
||||
*
|
||||
* Uploads land in the uploader's own folder, and `file` carries it: `{folder}/{sha256}.{ext}`
|
||||
* (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and
|
||||
* nobody's, an administrator's every file; `owner` is the account a file belongs to (null for
|
||||
* nobody, '' for a deleted account). A file goes only at its owner's or an administrator's hand,
|
||||
* and only while no project, snapshot or release uses it.
|
||||
*
|
||||
* POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130)
|
||||
*
|
||||
@@ -69,7 +75,8 @@ declare(strict_types=1);
|
||||
* GET ?r=plugins/{file} → the module source as text/javascript
|
||||
*
|
||||
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`
|
||||
* in a folder.
|
||||
*
|
||||
* Stored images, fonts, sounds and cels are served straight from `data/textures/`, `data/fonts/`,
|
||||
* `data/sounds/` and `data/pixels/` as static files; the API only writes them.
|
||||
@@ -78,6 +85,7 @@ declare(strict_types=1);
|
||||
require __DIR__ . '/lib/AccountRules.php';
|
||||
require __DIR__ . '/lib/AtomicFile.php';
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/FileUsage.php';
|
||||
require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
@@ -482,9 +490,10 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
}
|
||||
|
||||
/**
|
||||
* Images, fonts and sounds: shared between the accounts and stored under the hash of their bytes.
|
||||
* Everybody signed in uploads and lists them. Deleting one is for administrators only, because a
|
||||
* project of another account may be wearing it – the editor itself never deletes a file.
|
||||
* Images, fonts and sounds, stored under the hash of their bytes in a folder per account (#344).
|
||||
* Everybody signed in uploads into their own folder and lists their own files and nobody's; an
|
||||
* administrator lists every file. Deleting one is for its owner or an administrator, and refused
|
||||
* while a project of any account still uses it.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
|
||||
@@ -492,7 +501,9 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
function routeFiles(
|
||||
array $parts,
|
||||
string $method,
|
||||
Auth $auth,
|
||||
string $user,
|
||||
bool $isAdmin,
|
||||
UserStore $users,
|
||||
HashedFileStore $store,
|
||||
int $maxBytes,
|
||||
string $noun,
|
||||
@@ -500,7 +511,7 @@ function routeFiles(
|
||||
): never {
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($store->list());
|
||||
Response::json($store->list($users->folderOf($user), $isAdmin, $users->folderOwners()));
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$bytes = Response::rawBody();
|
||||
@@ -508,7 +519,7 @@ function routeFiles(
|
||||
Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? ''), $users->folderOf($user, true)), 201);
|
||||
} catch (RuntimeException $e) {
|
||||
if (!$e instanceof $invalid) {
|
||||
throw $e;
|
||||
@@ -520,10 +531,19 @@ function routeFiles(
|
||||
}
|
||||
|
||||
if ($method === 'DELETE') {
|
||||
$auth->requireAdmin();
|
||||
if (!$store->delete($parts[1])) {
|
||||
$file = implode('/', array_slice($parts, 1));
|
||||
if (!$store->exists($file)) {
|
||||
Response::error("{$noun} not found", 404);
|
||||
}
|
||||
$folder = HashedFileStore::folderOf($file);
|
||||
if (!$isAdmin && ($folder === '' || $folder !== $users->folderOf($user))) {
|
||||
Response::error('Only its owner may delete this file', 403);
|
||||
}
|
||||
$uses = (new FileUsage(dirname(__DIR__) . '/data'))->of($file);
|
||||
if ($uses !== []) {
|
||||
Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409);
|
||||
}
|
||||
$store->delete($file);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -587,8 +607,14 @@ try {
|
||||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||||
$parts = $route === '' ? [] : explode('/', $route);
|
||||
|
||||
// Only a project reaches deeper than two parts: `projects/{id}/snapshots/{snapshotId}/restore`.
|
||||
if (count($parts) < 1 || count($parts) > ($parts[0] === 'projects' ? 5 : 2)) {
|
||||
// A project reaches deepest, `projects/{id}/snapshots/{snapshotId}/restore`; a stored file is
|
||||
// `textures/{folder}/{file}`; everything else stops at two parts.
|
||||
$depth = match ($parts[0] ?? '') {
|
||||
'projects' => 5,
|
||||
'textures', 'fonts', 'sounds' => 3,
|
||||
default => 2,
|
||||
};
|
||||
if (count($parts) < 1 || count($parts) > $depth) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
@@ -603,17 +629,20 @@ try {
|
||||
if ($parts[0] === 'releases') {
|
||||
routeReleases($parts, $method);
|
||||
}
|
||||
// Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the
|
||||
// price of admission, so an uploaded file cannot be dropped in by a stranger.
|
||||
// Being signed in is the price of admission for everything below, so an uploaded file cannot be
|
||||
// dropped in by a stranger.
|
||||
$user = $auth->require();
|
||||
$isAdmin = $auth->role($user) === 'admin';
|
||||
$data = dirname(__DIR__) . '/data';
|
||||
|
||||
match ($parts[0]) {
|
||||
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
|
||||
'projects' => routeProjects($parts, $method, $user, $isAdmin),
|
||||
'textures' => routeFiles(
|
||||
$parts,
|
||||
$method,
|
||||
$auth,
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new TextureStore("{$data}/textures"),
|
||||
MAX_TEXTURE_BYTES,
|
||||
'Image',
|
||||
@@ -622,7 +651,9 @@ try {
|
||||
'fonts' => routeFiles(
|
||||
$parts,
|
||||
$method,
|
||||
$auth,
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new FontStore("{$data}/fonts"),
|
||||
MAX_FONT_BYTES,
|
||||
'Font',
|
||||
@@ -631,7 +662,9 @@ try {
|
||||
'sounds' => routeFiles(
|
||||
$parts,
|
||||
$method,
|
||||
$auth,
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new SoundStore("{$data}/sounds"),
|
||||
MAX_SOUND_BYTES,
|
||||
'Sound',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?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 or in its
|
||||
* release: restoring a snapshot or playing a released game 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;
|
||||
}
|
||||
}
|
||||
foreach (glob("{$this->data}/releases/*.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 and releases";
|
||||
}
|
||||
|
||||
/** 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Content-addressed font store: `data/fonts/{sha256}.{ext}` (see HashedFileStore).
|
||||
* Content-addressed font store: `data/fonts/{folder}/{sha256}.{ext}` (see HashedFileStore).
|
||||
*
|
||||
* A font is stored byte for byte. There is no re-encoding step as there is for images, so what
|
||||
* the browser loads is exactly what was picked. The picker shows each face by rendering with it,
|
||||
@@ -23,9 +23,9 @@ final class FontStore extends HashedFileStore
|
||||
'wOF2' => 'woff2',
|
||||
];
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
protected static function filePattern(): string
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name);
|
||||
return '[a-f0-9]{64}\.(ttf|otf|woff|woff2)';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ final class FontStore extends HashedFileStore
|
||||
* @return array{file:string,name:string,bytes:int}
|
||||
* @throws InvalidFontException when the bytes are not a supported font
|
||||
*/
|
||||
public function store(string $bytes, string $originalName = ''): array
|
||||
public function store(string $bytes, string $originalName, string $folder): array
|
||||
{
|
||||
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
|
||||
if ($extension === '') {
|
||||
@@ -53,7 +53,7 @@ final class FontStore extends HashedFileStore
|
||||
throw new InvalidFontException('Font file is truncated');
|
||||
}
|
||||
|
||||
return $this->keep($bytes, $extension, $originalName);
|
||||
return $this->keep($bytes, $extension, $originalName, $folder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+95
-36
@@ -2,13 +2,17 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The shared half of the image, font and sound stores: `{dir}/{sha256}.{ext}`.
|
||||
* The shared half of the image, font and sound stores: `{dir}/{folder}/{sha256}.{ext}`.
|
||||
*
|
||||
* The name of a file is the hash of its bytes, which buys four things at once: uploading the same
|
||||
* The name of a file is the hash of its bytes, which buys three things at once: uploading the same
|
||||
* file twice costs one file, a stored file can be cached forever (its URL cannot ever mean
|
||||
* different bytes), projects reference a short string instead of embedding megabytes of base64,
|
||||
* and deleting an asset is undoable for free because nothing here is removed when it stops being
|
||||
* referenced. What a store adds is which bytes it takes, and what its records say beyond the name.
|
||||
* different bytes), and projects reference a short string instead of embedding megabytes of base64.
|
||||
* What a store adds is which bytes it takes, and what its records say beyond the name.
|
||||
*
|
||||
* Every account uploads into a folder of its own (#344, see `UserStore::folderOf`), and the folder
|
||||
* is part of the name a project stores: `{folder}/{sha256}.{ext}`. A name without a folder is a
|
||||
* file that belongs to nobody – what everything uploaded before #344 and used by no project is.
|
||||
* The picker shows an account its own files and nobody's; an administrator sees them all.
|
||||
*/
|
||||
abstract class HashedFileStore
|
||||
{
|
||||
@@ -19,30 +23,45 @@ abstract class HashedFileStore
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored file name, as it appears in a project document. */
|
||||
abstract public static function isValidName(string $name): bool;
|
||||
/** The hash-and-extension half of a stored name, as a regular expression without delimiters. */
|
||||
abstract protected static function filePattern(): string;
|
||||
|
||||
/** A stored file name as it appears in a project document, with or without its folder. */
|
||||
public static function isValidName(string $name): bool
|
||||
{
|
||||
return (bool) preg_match('#^(?:' . UserStore::FOLDER_PATTERN . '/)?' . static::filePattern() . '$#', $name);
|
||||
}
|
||||
|
||||
/** The folder a stored name lies in, '' for a file that belongs to nobody. */
|
||||
public static function folderOf(string $name): string
|
||||
{
|
||||
$slash = strpos($name, '/');
|
||||
return $slash === false ? '' : substr($name, 0, $slash);
|
||||
}
|
||||
|
||||
public function exists(string $file): bool
|
||||
{
|
||||
return static::isValidName($file) && is_file($this->dir . '/' . $file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored file, newest first – what the picker of its kind offers.
|
||||
* What the picker of its kind offers, newest first: the files of `$folder` and the ones that
|
||||
* belong to nobody, or with `$seesAll` every file there is. `owner` is the account a file
|
||||
* belongs to, null for nobody, '' for an account that was deleted; `deletable` says whether
|
||||
* the one asking may try (whether a project still uses it is only known when they do).
|
||||
*
|
||||
* @return list<array<string,string|int>>
|
||||
* @param array<string,string> $owners folder => account, see `UserStore::folderOwners`
|
||||
* @return list<array<string,string|int|bool|null>>
|
||||
*/
|
||||
public function list(): array
|
||||
public function list(string $folder, bool $seesAll, array $owners): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||
$file = basename($path);
|
||||
// Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name.
|
||||
if (!static::isValidName($file)) {
|
||||
$items = $this->listIn('', null, $seesAll);
|
||||
foreach (glob($this->dir . '/*', GLOB_ONLYDIR) ?: [] as $path) {
|
||||
$name = basename($path);
|
||||
if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $name) || (!$seesAll && $name !== $folder)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'file' => $file,
|
||||
'name' => OriginalName::read($this->dir, $file),
|
||||
...$this->details($path),
|
||||
'bytes' => (int) filesize($path),
|
||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||
];
|
||||
array_push($items, ...$this->listIn($name, $owners[$name] ?? '', true));
|
||||
}
|
||||
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
|
||||
return $items;
|
||||
@@ -50,35 +69,41 @@ abstract class HashedFileStore
|
||||
|
||||
public function delete(string $file): bool
|
||||
{
|
||||
if (!static::isValidName($file)) {
|
||||
if (!$this->exists($file)) {
|
||||
return false;
|
||||
}
|
||||
$path = $this->dir . '/' . $file;
|
||||
if (!is_file($path) || !unlink($path)) {
|
||||
[$dir, $base] = $this->split($file);
|
||||
if (!unlink($dir . '/' . $base)) {
|
||||
return false;
|
||||
}
|
||||
OriginalName::delete($this->dir, $file);
|
||||
OriginalName::delete($dir, $base);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps bytes that passed the store's checks under their hash, with `$originalName` beside them
|
||||
* as a caption (see OriginalName), and returns the record. Bytes stored before are not written
|
||||
* again; a new name renames them.
|
||||
* Keeps bytes that passed the store's checks under their hash in `$folder`, with `$originalName`
|
||||
* beside them as a caption (see OriginalName), and returns the record. Bytes stored there before
|
||||
* are not written again; a new name renames them.
|
||||
*
|
||||
* @param array<string,int> $details what the record says between the name and the size
|
||||
* @return array<string,string|int>
|
||||
*/
|
||||
protected function keep(string $bytes, string $extension, string $originalName, array $details = []): array
|
||||
protected function keep(string $bytes, string $extension, string $originalName, string $folder, array $details = []): array
|
||||
{
|
||||
$file = hash('sha256', $bytes) . '.' . $extension;
|
||||
$path = $this->dir . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'stored file');
|
||||
if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) {
|
||||
throw new InvalidArgumentException("Invalid folder: {$folder}");
|
||||
}
|
||||
OriginalName::write($this->dir, $file, $originalName);
|
||||
$dir = $this->dir . '/' . $folder;
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create directory {$dir}");
|
||||
}
|
||||
$base = hash('sha256', $bytes) . '.' . $extension;
|
||||
if (!is_file($dir . '/' . $base)) {
|
||||
AtomicFile::write($dir . '/' . $base, $bytes, AtomicFile::SERVED, 'stored file');
|
||||
}
|
||||
OriginalName::write($dir, $base, $originalName);
|
||||
|
||||
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), ...$details, 'bytes' => strlen($bytes)];
|
||||
return ['file' => "{$folder}/{$base}", 'name' => OriginalName::read($dir, $base), ...$details, 'bytes' => strlen($bytes)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,4 +116,38 @@ abstract class HashedFileStore
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @return list<array<string,string|int|bool|null>> */
|
||||
private function listIn(string $folder, ?string $owner, bool $deletable): array
|
||||
{
|
||||
$dir = $folder === '' ? $this->dir : $this->dir . '/' . $folder;
|
||||
$prefix = $folder === '' ? '' : $folder . '/';
|
||||
$items = [];
|
||||
foreach (glob($dir . '/*.*') ?: [] as $path) {
|
||||
$base = basename($path);
|
||||
// Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name.
|
||||
if (!static::isValidName($base)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'file' => $prefix . $base,
|
||||
'name' => OriginalName::read($dir, $base),
|
||||
...$this->details($path),
|
||||
'bytes' => (int) filesize($path),
|
||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||
'owner' => $owner,
|
||||
'deletable' => $deletable,
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} the directory a stored name lies in, and its last part */
|
||||
private function split(string $file): array
|
||||
{
|
||||
$folder = self::folderOf($file);
|
||||
return $folder === ''
|
||||
? [$this->dir, $file]
|
||||
: [$this->dir . '/' . $folder, substr($file, strlen($folder) + 1)];
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ declare(strict_types=1);
|
||||
* project as it was stored, with { id, name, revision, createdAt, project }.
|
||||
*
|
||||
* A copy of the project document is all a snapshot needs: images, fonts, sounds and cels are
|
||||
* content-addressed and never removed, so whatever the copy names is still there to load.
|
||||
* content-addressed, and a file a snapshot names cannot be deleted (#344, see FileUsage), so
|
||||
* whatever the copy names is still there to load.
|
||||
*
|
||||
* The store checks nothing about who asks. The router reaches it only after `Store::load` found
|
||||
* the project for that user, so the snapshots of a project are seen by exactly those who see it.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Content-addressed sound store: `data/sounds/{sha256}.{ext}` (see HashedFileStore).
|
||||
* Content-addressed sound store: `data/sounds/{folder}/{sha256}.{ext}` (see HashedFileStore).
|
||||
*
|
||||
* A sound is stored byte for byte. There is no re-encoding step as there is for images: the
|
||||
* browser decodes what was picked, and nothing here could encode audio anyway. The picker plays a
|
||||
@@ -20,9 +20,9 @@ final class SoundStore extends HashedFileStore
|
||||
'ID3' => 'mp3',
|
||||
];
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
protected static function filePattern(): string
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name);
|
||||
return '[a-f0-9]{64}\.(mp3|ogg|wav|m4a)';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,14 +38,14 @@ final class SoundStore extends HashedFileStore
|
||||
* @return array{file:string,name:string,bytes:int}
|
||||
* @throws InvalidSoundException when the bytes are not a supported sound
|
||||
*/
|
||||
public function store(string $bytes, string $originalName = ''): array
|
||||
public function store(string $bytes, string $originalName, string $folder): array
|
||||
{
|
||||
$extension = self::extensionOf($bytes);
|
||||
if ($extension === '') {
|
||||
throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A');
|
||||
}
|
||||
|
||||
return $this->keep($bytes, $extension, $originalName);
|
||||
return $this->keep($bytes, $extension, $originalName, $folder);
|
||||
}
|
||||
|
||||
/** The format these bytes are, `''` when they are none we take. */
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Content-addressed image store: `data/textures/{sha256}.{ext}` (see HashedFileStore).
|
||||
* Content-addressed image store: `data/textures/{folder}/{sha256}.{ext}` (see HashedFileStore).
|
||||
*
|
||||
* The flip side of hashed names is that files outlive the projects that used them: nothing
|
||||
* collects the unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the
|
||||
* editor's image picker offers, so a picture one project dropped is still there for the next.
|
||||
* collects the unreferenced ones by itself. They stay pickable instead – `GET ?r=textures` is what
|
||||
* the editor's image picker offers, so a picture one project dropped is still there for the next –
|
||||
* until their owner deletes them (#344).
|
||||
*/
|
||||
final class TextureStore extends HashedFileStore
|
||||
{
|
||||
@@ -32,9 +33,9 @@ final class TextureStore extends HashedFileStore
|
||||
*/
|
||||
private const MAX_DIMENSION = 8192;
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
protected static function filePattern(): string
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name);
|
||||
return '[a-f0-9]{64}\.(png|jpg|webp|gif)';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ final class TextureStore extends HashedFileStore
|
||||
* @return array{file:string,name:string,width:int,height:int,bytes:int}
|
||||
* @throws InvalidImageException when the bytes are not a supported image
|
||||
*/
|
||||
public function store(string $bytes, string $originalName = ''): array
|
||||
public function store(string $bytes, string $originalName, string $folder): array
|
||||
{
|
||||
$size = @getimagesizefromstring($bytes);
|
||||
if ($size === false) {
|
||||
@@ -73,7 +74,7 @@ final class TextureStore extends HashedFileStore
|
||||
throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side');
|
||||
}
|
||||
|
||||
return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]);
|
||||
return $this->keep($bytes, $extension, $originalName, $folder, ['width' => $width, 'height' => $height]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+56
-6
@@ -2,13 +2,18 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The accounts, as one JSON file: `data/users.json` holding `{username, password, role, createdAt}`
|
||||
* per user, where `password` is a hash from `password_hash` – the plain one is never stored.
|
||||
* The accounts, as one JSON file: `data/users.json` holding `{username, password, role, createdAt,
|
||||
* folder}` per user, where `password` is a hash from `password_hash` – the plain one is never stored.
|
||||
*
|
||||
* An entry without `role` is a `user` (#180): the weaker of the two is what a missing field means,
|
||||
* so a hand-written file never hands out rights by accident. `createdAt` is missing on the accounts
|
||||
* from before #179 and stays empty for them.
|
||||
*
|
||||
* `folder` names the account's own directory under `data/textures/`, `data/fonts/` and
|
||||
* `data/sounds/` (#344). It is random rather than the name, so renaming an account moves no file
|
||||
* and rewrites no project, and the URL of a released game's files says nothing about whose they
|
||||
* are. An account gets one with its first upload.
|
||||
*
|
||||
* Accounts are made from the command line (`tools/make-user.php`) or by an administrator through
|
||||
* the `users` routes (#179); nobody can sign up over the wire.
|
||||
*/
|
||||
@@ -23,6 +28,9 @@ final class UserStore
|
||||
|
||||
public const ROLES = ['admin', 'user'];
|
||||
|
||||
/** What a folder name looks like, for the file stores to check a path against. */
|
||||
public const FOLDER_PATTERN = '[a-f0-9]{12}';
|
||||
|
||||
public function __construct(private readonly string $file) {}
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
@@ -78,6 +86,44 @@ final class UserStore
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's own folder for uploads. With `$create` an account without one is given one;
|
||||
* without it, '' means it has never uploaded anything.
|
||||
*/
|
||||
public function folderOf(string $username, bool $create = false): string
|
||||
{
|
||||
$user = $this->find($username);
|
||||
if ($user === null) {
|
||||
return '';
|
||||
}
|
||||
if ($user['folder'] !== '' || !$create) {
|
||||
return $user['folder'];
|
||||
}
|
||||
$taken = array_column($this->all(), 'folder');
|
||||
do {
|
||||
$folder = bin2hex(random_bytes(6));
|
||||
} while (in_array($folder, $taken, true));
|
||||
$this->change($username, static fn(array $u): array => ['folder' => $folder] + $u);
|
||||
return $folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which account each folder belongs to. A folder missing here belonged to an account that was
|
||||
* deleted; its files stay, for administrators to see (#344).
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function folderOwners(): array
|
||||
{
|
||||
$owners = [];
|
||||
foreach ($this->all() as $user) {
|
||||
if ($user['folder'] !== '') {
|
||||
$owners[$user['folder']] = $user['username'];
|
||||
}
|
||||
}
|
||||
return $owners;
|
||||
}
|
||||
|
||||
public function adminCount(): int
|
||||
{
|
||||
return count(array_filter($this->all(), static fn(array $u): bool => $u['role'] === 'admin'));
|
||||
@@ -103,6 +149,7 @@ final class UserStore
|
||||
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'role' => $role ?? $existing['role'] ?? 'user',
|
||||
'createdAt' => $existing !== null ? $existing['createdAt'] : gmdate('c'),
|
||||
'folder' => $existing['folder'] ?? '',
|
||||
];
|
||||
$this->write($users);
|
||||
}
|
||||
@@ -159,7 +206,7 @@ final class UserStore
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
|
||||
/** @return list<array{username:string,password:string,role:string,createdAt:string,folder:string}> */
|
||||
private function without(string $username): array
|
||||
{
|
||||
return array_values(
|
||||
@@ -167,7 +214,7 @@ final class UserStore
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{username:string,password:string,role:string,createdAt:string}|null */
|
||||
/** @return array{username:string,password:string,role:string,createdAt:string,folder:string}|null */
|
||||
private function find(string $username): ?array
|
||||
{
|
||||
foreach ($this->all() as $user) {
|
||||
@@ -178,7 +225,7 @@ final class UserStore
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
|
||||
/** @return list<array{username:string,password:string,role:string,createdAt:string,folder:string}> */
|
||||
private function all(): array
|
||||
{
|
||||
if (!is_file($this->file)) {
|
||||
@@ -198,13 +245,16 @@ final class UserStore
|
||||
'password' => $user['password'],
|
||||
'role' => is_string($role) && self::isValidRole($role) ? $role : 'user',
|
||||
'createdAt' => is_string($user['createdAt'] ?? null) ? $user['createdAt'] : '',
|
||||
'folder' => is_string($user['folder'] ?? null) && preg_match('/^' . self::FOLDER_PATTERN . '$/', $user['folder'])
|
||||
? $user['folder']
|
||||
: '',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $users;
|
||||
}
|
||||
|
||||
/** @param list<array{username:string,password:string,role:string,createdAt:string}> $users */
|
||||
/** @param list<array{username:string,password:string,role:string,createdAt:string,folder:string}> $users */
|
||||
private function write(array $users): void
|
||||
{
|
||||
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
|
||||
|
||||
Reference in new issue
Block a user