feat(files): API tokens — a machine reaches the library folder by folder

A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.

The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
This commit is contained in:
2026-10-14 18:40:23 +02:00
parent c335d54929
commit b2d34e6e6c
18 changed files with 1080 additions and 48 deletions
+11 -2
View File
@@ -57,12 +57,14 @@ def ingest_upload(
display_name: str | None = None,
sha256: str | None = None,
folder_id: int | None = None,
via: str | None = None,
) -> DownloadJob:
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the
hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for
(`folders.upload_target`), else its Uploads folder.
(`folders.upload_target`), else its Uploads folder; `via` what the audit row names as the doer
when it was not the user themselves (an API token).
Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
@@ -145,7 +147,14 @@ def ingest_upload(
folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id)
db.flush()
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id)
audit.record(
db,
user_id,
AuditAction.FILE_UPLOAD,
target_type="file",
target_id=job.id,
summary=via,
)
db.commit()
except Exception:
db.rollback()