From 1fa1da14be121de9da70d57ebfcc52ef627d4a7e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 08:25:25 +0200 Subject: [PATCH] Describe each account's folder of uploaded files (#344) Uploaded images, fonts and sounds now belong to the account that uploaded them, and the pickers can delete them. - API: a new section Uploaded files belong to an account, with the folder in the file name, who sees what, and DELETE with 403/409. - Textures: the Delete button in the picker and when it refuses. - Fonts, Sounds, Home: file names carry the account's folder. - Development: the PHP test files.test.php and 1850 checks. Co-Authored-By: Claude Opus 5.5 --- API.md | 55 ++++++++++++++++++++++++++++++++++++++------------ Development.md | 3 ++- Fonts.md | 8 +++++--- Home.md | 6 +++--- Sounds.md | 8 +++++--- Textures.md | 12 +++++++++-- 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/API.md b/API.md index e8b6fe8..7288f08 100644 --- a/API.md +++ b/API.md @@ -19,15 +19,15 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT | POST | `projects/{id}/release` | → `{token,revision,publishedAt}`. Copies the project as stored; publishing again replaces the copy and keeps the token | | DELETE | `projects/{id}/release` | 204, the link is dead; **404** when the project was not released | | GET | `releases/{token}` | `{name,publishedAt,project}` — the released copy, **without a session**: the one route besides `auth/*` open to everybody. `project.id` is the token, not the project's id. **404** for an unknown or taken-down token | -| GET | `textures` | `[{file,name,width,height,bytes,updatedAt}]` — what the image picker offers | -| POST | `textures&name=hero.png` | **raw image bytes** → `{file,name,width,height,bytes}` (201); **422** for bytes that are no readable image | -| DELETE | `textures/{file}` | 204. Administrators only: a project of another account may wear the file. The editor never deletes one | -| GET | `fonts` | `[{file,name,bytes,updatedAt}]` — what the font picker offers | +| GET | `textures` | `[{file,name,width,height,bytes,updatedAt,owner,deletable}]` — what the image picker offers: the caller's own files and nobody's, an administrator's every file (see below) | +| POST | `textures&name=hero.png` | **raw image bytes** → `{file,name,width,height,bytes}` (201), stored in the caller's folder; **422** for bytes that are no readable image | +| DELETE | `textures/{file}` | 204. `{file}` is the stored name, folder and all. **403** unless it lies in the caller's folder or the caller is an administrator, **409** while a project, snapshot or release of any account uses it | +| GET | `fonts` | `[{file,name,bytes,updatedAt,owner,deletable}]` — what the font picker offers | | POST | `fonts&name=Serif.ttf` | **raw font bytes** → `{file,name,bytes}` (201); **422** for bytes that are no font | -| DELETE | `fonts/{file}` | 204. Administrators only | -| GET | `sounds` | `[{file,name,bytes,updatedAt}]` — what the sound picker offers | +| DELETE | `fonts/{file}` | 204, **403**, **409** like an image | +| GET | `sounds` | `[{file,name,bytes,updatedAt,owner,deletable}]` — what the sound picker offers | | POST | `sounds&name=jump.wav` | **raw sound bytes** → `{file,name,bytes}` (201); **422** for bytes that are no sound | -| DELETE | `sounds/{file}` | 204. Administrators only | +| DELETE | `sounds/{file}` | 204, **403**, **409** like an image | | POST | `pixels` | **raw cel bytes** → `{file,bytes}` (201) — the pixels of one layer on one frame of [pixel art](Pixel-Art); **422** for anything that is not a cel. No listing and no delete: a cel is found by the name in the project | | GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name | | GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 404 for anything but a bare file name there — see [Plugins](Plugins) | @@ -43,11 +43,40 @@ An **administrator** (#180, #179) reads and saves every project with the same ro keeps the project's `owner`; their `projects` list stays their own. Deleting a project stays with its owner (#272): an administrator gets **403** `{"error":"Only its owner may delete a project"}`, also for a file without an owner. Snapshots and the release of the project are theirs to manage -all the same. The `users` routes and -deleting a stored image, font or sound answer anybody but an administrator **403** -`{"error":"Administrators only"}`. -Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the price -of admission. See [Accounts](Accounts). +all the same. The `users` routes answer anybody but an administrator **403** +`{"error":"Administrators only"}`. Cels and plugins are shared between accounts; being signed in +is still the price of admission. See [Accounts](Accounts). + +## Uploaded files belong to an account (#344) + +Every account uploads images, fonts and sounds into a **folder of its own**, and the folder is part +of the name the project stores: `{folder}/{sha256}.{ext}`, for example +`data/sounds/a590cab0f8b7/f1f2…f558.wav`. The folder is a random 12-character name kept as +`folder` in `users.json`, given with the first upload. It is not the account 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. Two accounts uploading the same bytes have a copy each. + +A name **without** a folder is a file that belongs to nobody — what was uploaded before #344 and is +used by no project. + +- **Listing:** an account gets its own files and nobody's; an administrator every file. `owner` is + the account a file belongs to, `null` for nobody and `""` for an account that was deleted. + `deletable` says whether the caller may try. +- **Deleting:** a file in the caller's folder, or any file for an administrator — and only while no + project, snapshot or release of **any** account names it (`api/lib/FileUsage.php`, any string in + the document equal to the stored name counts). The **409** says who uses it: the caller's own + projects by name, somebody else's only as "a project of another account" (an administrator sees + the name and the owner). +- **A deleted account's folder stays**, and only administrators see it. +- The files themselves are still served without a session — a released game loads them that way. + The owner decides what the picker offers and who deletes, not who may load a file whose URL they + know. + +The files that existed when this came in were handed out once by `tools/migrate-file-owners.php` +(2026-10-07): a file a project uses moved into the folder of the project's owner (a copy each when +two accounts used it), and the projects, snapshots and releases were rewritten to the new names. +A project's revision went up with it, so an editor that had it open was told it changed elsewhere +instead of saving the old names back. Which editor plugins a project switches on is the project's own field `plugins` (#119); the server has no switch of its own and hands out every plugin file it has. @@ -69,7 +98,7 @@ own caps behind nginx's: 20 MB per request, 4 MB per image or font and 16 MB per a single music track is worth more than four typefaces. **nginx's own limit is the one that actually bites** (1 MB today, see `MAX_UPLOAD_BYTES` in `textureImport.ts`): raising the API's cap without raising that one changes nothing. The stored files are served statically from -`data/textures/`, `data/fonts/`, `data/sounds/` and `data/pixels/`, so reading one never runs PHP. An image is +`data/textures/`, `data/fonts/`, `data/sounds/` (each in its owner's folder) and `data/pixels/`, so reading one never runs PHP. An image is downscaled and re-encoded in the browser when it would not fit; a font or a sound cannot be, so an oversized one is refused rather than shrunk. diff --git a/Development.md b/Development.md index 13ce8f9..ad01024 100644 --- a/Development.md +++ b/Development.md @@ -18,7 +18,8 @@ php tests/php/accounts.test.php # the API's accoun # has and the web server's container has not (SKIP there) php tests/php/snapshots.test.php # the snapshot store (#231), the same way php tests/php/releases.test.php # the release store (#266), the same way -node tools/seed-e2e.mjs && node tools/e2e.mjs # 1848 editor checks, screenshots in /tmp/gm-e2e-*.png +php tests/php/files.test.php # uploaded files in a folder per account (#344), the same way +node tools/seed-e2e.mjs && node tools/e2e.mjs # 1850 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/Fonts.md b/Fonts.md index ba5c01b..5c56696 100644 --- a/Fonts.md +++ b/Fonts.md @@ -20,9 +20,11 @@ making a second one; any other row is captioned with the name the file was uploa picking it names the asset after that file — `DejaVuSerif.ttf` becomes `DejaVuSerif`. **Where it goes.** The bytes are uploaded to `POST ?r=fonts` and stored as -`data/fonts/{sha256}.{ext}` — named after their own content, exactly like an image, and with -the same four consequences (one file per typeface however often it is uploaded, cacheable -forever, a short string in the project document, an undoable delete). Unlike an image, a font +`data/fonts/{folder}/{sha256}.{ext}` — in the uploader's folder and named after their own +content, exactly like an image, and with the same four consequences (one file per typeface +however often it is uploaded, cacheable forever, a short string in the project document, an +undoable delete). **Delete** in the picker removes a file of your own nobody uses, as for +[images](Textures). Unlike an image, a font is **never re-encoded on the way**: what the server stores is the file that was picked. The format is read from the file's own signature, so nothing a request claims can decide what is written. Bitmap collections (`.ttc`) and SVG fonts are refused — a collection holds several diff --git a/Home.md b/Home.md index cd5d830..96d8c41 100644 --- a/Home.md +++ b/Home.md @@ -55,6 +55,6 @@ into issues #1-#50, closed ones included, each carrying the commits it was filed A touch-optimised layout. Sound has no mixer, no fade and no `Set volume` on a playing voice (see [Sounds](Sounds)). Animations have no events on the timeline and no preview in the Scene tab. Slicing cuts a regular grid only — no detection of where the sprites actually -sit on the sheet. Unreferenced image files are never collected — deliberately, since the image -picker offers them again; nothing deletes a stored image except `DELETE ?r=textures/{file}`, which -only an administrator may call. +sit on the sheet. Unreferenced image files are never collected by themselves — deliberately, since the image +picker offers them again; a file goes only when its owner (or an administrator) presses +**Delete** in the picker and no project uses it. diff --git a/Sounds.md b/Sounds.md index fd561e0..e6e053d 100644 --- a/Sounds.md +++ b/Sounds.md @@ -16,9 +16,11 @@ captioned with an asset name is one this project already uses, and picking it us rather than making a second one. **Where it goes.** The bytes are uploaded to `POST ?r=sounds` and stored as -`data/sounds/{sha256}.{ext}` — named after their own content, exactly like a typeface, and with -the same four consequences (one file per sound however often it is uploaded, cacheable forever, a -short string in the project document, an undoable delete). A sound is **never re-encoded on the +`data/sounds/{folder}/{sha256}.{ext}` — in the uploader's folder and named after their own +content, exactly like a typeface, and with the same four consequences (one file per sound however +often it is uploaded, cacheable forever, a short string in the project document, an undoable +delete). **Delete** in the picker removes a file of your own nobody uses, as for +[images](Textures). A sound is **never re-encoded on the way**: what the server stores is the file that was picked. The format is read from the file's own signature, so nothing a request claims can decide what is written. diff --git a/Textures.md b/Textures.md index 6865bd3..d8676ae 100644 --- a/Textures.md +++ b/Textures.md @@ -29,12 +29,20 @@ any other shows the name the file was uploaded under, which the server keeps bes file from before names were kept still reads as its pixel size. **Where it goes.** The image is uploaded as raw bytes to `POST ?r=textures` and stored as -`data/textures/{sha256}.{ext}` — the file is named after its own content. That name buys four -things: the same picture uploaded twice is one file, a stored file can be cached forever +`data/textures/{folder}/{sha256}.{ext}` — in the uploading account's own folder (#344, see +[API](API#uploaded-files-belong-to-an-account-344)), and named after its own content. That name buys +four things: the same picture uploaded twice is one file, a stored file can be cached forever (a name cannot come to mean different bytes), the project document holds a short string instead of megabytes of base64, and deleting a texture asset is undoable for free because nothing on disk is removed. The price is that files outlive the projects that referenced them. +**Deleting a file.** The picker shows your own files and the ones that belong to nobody; an +administrator sees every file, and the line under the list says whose a file is. **Delete** in the +picker removes the picked file from the server: the first press only asks (*Delete for good*), the +second deletes. It is offered for your own files (an administrator: for every file), and the +server refuses while a project, a snapshot or a release of any account still uses the file — the +line under the list then says which of your projects it is. Fonts and sounds work the same way. + **Colour tints the picture.** `color` multiplies the texture, so it defaults to **white** (= no tint) and a picture shows its own colours. Tinting is worth having: one white coin picture becomes ten differently coloured coins, per instance, for nothing. Without a texture