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 <noreply@anthropic.com>
adminandClaude Opus 5.5 committed 2026-10-07 08:25:25 +02:00
1 parent fbe7d68b22
commit 1fa1da14be
6 files changed
+67 -25

No files matched your search

+42 -13
@@ -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.
+2 -1
@@ -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
+5 -3
@@ -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
+3 -3
@@ -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.
+5 -3
@@ -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.
+10 -2
@@ -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