feat(files): the Files library API — folders, moving and keeping files, upload into a folder, the file's hash

/api/files is the library's home: list a user's done files (by folder, searched, sorted, or the
ones shared with them), rename / move / keep / delete a file, make, rename and delete own folders
(a deleted folder's files go back to their source folders), and set how long any folder keeps a
file — done files shift by the difference, never closer than the GC's warning window.

An upload can name its folder (an own folder or Uploads), checked before the body is read and again
before the file lands; it is hashed while it streams, and any file can be hashed on first ask. The
download says the hash (Repr-Digest) once it is known; a new file at an asset's path forgets it.

Uploads, deletes, keeps, moves and folder changes are audited (ids only). The fileless "expired"
rows of the old retention are deleted by migration 0103: an expired file now leaves the library.

The shared route helpers moved to routes/files.py; the old /api/downloads library routes delegate
to the new ones until the client moves over.
This commit is contained in:
2026-10-11 04:42:55 +02:00
parent bf99444ded
commit 1a1665b8ec
24 changed files with 1483 additions and 399 deletions
@@ -141,7 +141,7 @@ const goneWhats = [
];
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
// `setting_refused` picks from at runtime.
// `setting_refused` picks from at runtime, and every `FolderError` reason.
const appSources = (dir = ""): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
@@ -157,6 +157,11 @@ const refusedReasons = [
...appSources().flatMap((src) =>
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
),
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
// `refused(409, e.reason)`, which the pattern above cannot read.
...appSources().flatMap((src) =>
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
),
...[
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
/:\s*"(\w+)"/g,
+10 -1
View File
@@ -22,5 +22,14 @@
"download_credential_delete": "Download cookies deleted",
"invite_delete": "Whitelist entry deleted",
"invite_send": "Invitation sent",
"issue_create": "Bug report sent"
"issue_create": "Bug report sent",
"file_upload": "File uploaded",
"file_delete": "File deleted",
"file_keep": "File kept",
"file_unkeep": "File no longer kept",
"file_move": "File moved",
"file_folder_create": "Folder created",
"file_folder_rename": "Folder renamed",
"file_folder_retention": "Folder retention changed",
"file_folder_delete": "Folder deleted"
}
+8 -2
View File
@@ -37,7 +37,8 @@
"poster": "There's no poster for this",
"profile": "This download profile doesn't exist any more",
"season": "This season isn't in the library any more",
"show": "This show isn't in the library any more"
"show": "This show isn't in the library any more",
"folder": "That folder no longer exists"
},
"admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
@@ -219,6 +220,11 @@
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire.",
"issue_sending": "The report is being sent right now. Wait for it to finish.",
"issue_rate_limited": "At most {{limit}} send attempts an hour. Try again later.",
"preview_rate_limited": "Too many previews in a row. Wait a minute."
"preview_rate_limited": "Too many previews in a row. Wait a minute.",
"file_folder_name_required": "Give the folder a name",
"file_folder_name_taken": "You already have a folder with that name",
"file_folder_not_own": "Only a folder you made can be renamed or deleted",
"file_folder_gone": "That folder no longer exists",
"file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from"
}
}
+10 -1
View File
@@ -22,5 +22,14 @@
"download_credential_delete": "Letöltési cookie-k törölve",
"invite_delete": "Fehérlista-bejegyzés törölve",
"invite_send": "Meghívó elküldve",
"issue_create": "Hibajegy elküldve"
"issue_create": "Hibajegy elküldve",
"file_upload": "Fájl feltöltve",
"file_delete": "Fájl törölve",
"file_keep": "Fájl megtartva",
"file_unkeep": "Fájl megtartása visszavonva",
"file_move": "Fájl áthelyezve",
"file_folder_create": "Mappa létrehozva",
"file_folder_rename": "Mappa átnevezve",
"file_folder_retention": "Mappa megőrzési ideje módosítva",
"file_folder_delete": "Mappa törölve"
}
+8 -2
View File
@@ -37,7 +37,8 @@
"poster": "Ehhez nincs poszter",
"profile": "Ez a letöltési profil már nem létezik",
"season": "Ez az évad már nincs a könyvtárban",
"show": "Ez a sorozat már nincs a könyvtárban"
"show": "Ez a sorozat már nincs a könyvtárban",
"folder": "Ez a mappa már nem létezik"
},
"admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
@@ -219,6 +220,11 @@
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak.",
"issue_sending": "A hibajegy küldése épp folyamatban van. Várd meg, amíg befejeződik.",
"issue_rate_limited": "Óránként legfeljebb {{limit}} küldési kísérlet. Próbáld újra később.",
"preview_rate_limited": "Túl sok előnézet egymás után. Várj egy percet."
"preview_rate_limited": "Túl sok előnézet egymás után. Várj egy percet.",
"file_folder_name_required": "Adj nevet a mappának",
"file_folder_name_taken": "Már van ilyen nevű mappád",
"file_folder_not_own": "Csak a saját mappádat nevezheted át vagy törölheted",
"file_folder_gone": "Ez a mappa már nem létezik",
"file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet"
}
}