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:
adminandClaude Opus 5.5 committed 2026-10-07 08:25:13 +02:00
1 parent a2d0a02645
commit 6b49a0bea7
34 files changed
+910 -111

No files matched your search

+56 -23
View File
@@ -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',