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:
+91
-34
@@ -7,6 +7,11 @@ bytes. Folders are rows (`downloads.folders`), so a rename or a move never touch
|
||||
In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above
|
||||
the routes are shared with that module — serialization, the ownership locks, the upload spool —
|
||||
because a download and a library file are one row at two moments.
|
||||
|
||||
A machine with an API token (`app.apitokens`) may use the routes that take `files_caller`: list the
|
||||
folders and files, fetch a file and its hash, upload, delete — each only in a folder its token was
|
||||
granted that right in, and never the files shared with the user. A folder or file out of its reach
|
||||
answers like a missing one. Everything else here is the signed-in user's alone (`current_user`).
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -29,6 +34,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, sysconfig
|
||||
from app.apitokens import FilesCaller, Right, files_caller
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.collation import collated
|
||||
@@ -208,7 +214,7 @@ UPLOAD_FOLDER_HEADER = "x-upload-folder"
|
||||
@dataclass(frozen=True)
|
||||
class SpooledUpload:
|
||||
"""What the body dependency hands the handler: the bytes already on disk and their hash, plus
|
||||
the names and the folder that came alongside them."""
|
||||
the names and the folder that came alongside them, and who sent them."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
@@ -217,6 +223,7 @@ class SpooledUpload:
|
||||
filename: str
|
||||
display_name: str | None
|
||||
folder_id: int | None
|
||||
via: str | None = None
|
||||
|
||||
|
||||
def _header_text(request: Request, name: str) -> str:
|
||||
@@ -233,15 +240,21 @@ def _header_text(request: Request, name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _upload_folder(db: Session, user_id: int, raw: str) -> int | None:
|
||||
def _upload_folder(db: Session, caller: FilesCaller, raw: str) -> int | None:
|
||||
"""The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for
|
||||
none."""
|
||||
none — which a token may not: it writes only where it was granted to."""
|
||||
if not raw.strip():
|
||||
if caller.grants is not None:
|
||||
raise refused(400, "api_token_folder_required")
|
||||
return None
|
||||
try:
|
||||
return folders.upload_target(db, user_id, int(raw), hold=False).id
|
||||
folder_id = int(raw)
|
||||
except ValueError as e:
|
||||
raise refused(409, "file_folder_gone") from e
|
||||
if not caller.may(folder_id, Right.WRITE):
|
||||
raise not_found("folder")
|
||||
try:
|
||||
return folders.upload_target(db, caller.user.id, folder_id, hold=False).id
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
|
||||
@@ -260,24 +273,26 @@ class _HashingSink:
|
||||
|
||||
async def spool_upload(
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AsyncIterator[SpooledUpload]:
|
||||
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
||||
|
||||
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
||||
naming `current_user` here makes the auth check a precondition of reading the body at all — a
|
||||
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
|
||||
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
|
||||
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
|
||||
`caller` comes first on purpose. Dependencies resolve in declaration order, so naming
|
||||
`files_caller` here makes the auth check a precondition of reading the body at all — a stranger,
|
||||
or a token without the right to write in that folder, is refused before a byte moves. The
|
||||
`yield` shape owns the temp file's life: ingest MOVES it into place on success (the unlink is
|
||||
then a no-op), and on any other exit — cap hit, empty, quota, probe or move error, a client that
|
||||
vanished mid-upload — it is removed."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
user_id = user.id
|
||||
user_id = caller.user.id
|
||||
via = caller.via
|
||||
# A folder the file may not go in is refused before the transfer, not after it.
|
||||
folder_id = _upload_folder(db, user_id, request.headers.get(UPLOAD_FOLDER_HEADER, ""))
|
||||
folder_id = _upload_folder(db, caller, request.headers.get(UPLOAD_FOLDER_HEADER, ""))
|
||||
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
|
||||
# for why, and for the precondition it enforces. Everything this dependency still needs is
|
||||
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
|
||||
# check a connection straight back out.
|
||||
# already an int, a string or a header in hand: touching an ORM attribute below (`caller.user`)
|
||||
# would reload it and check a connection straight back out.
|
||||
release_read_connection(db)
|
||||
staging = Path(settings.files_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
@@ -300,6 +315,7 @@ async def spool_upload(
|
||||
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
||||
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
||||
folder_id=folder_id,
|
||||
via=via,
|
||||
)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
@@ -322,6 +338,17 @@ def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||
return job
|
||||
|
||||
|
||||
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob:
|
||||
"""A file `caller` may read: as `accessible_job` for the user; for a token, one of the user's
|
||||
own in a folder it may read."""
|
||||
if caller.grants is None:
|
||||
return accessible_job(db, caller.user, job_id)
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None or job.user_id != caller.user.id or not caller.may(job.folder_id, Right.READ):
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
# --- folders -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -340,19 +367,24 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def list_folders(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
"""The caller's folders with how many files each holds and their size. A source folder its
|
||||
files have all left stays (count 0): the client decides whether to show it."""
|
||||
def list_folders(
|
||||
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""The caller's folders with how many files each holds and their size (a token: the ones it may
|
||||
list). A source folder its files have all left stays (count 0): the client decides whether to
|
||||
show it."""
|
||||
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
|
||||
stmt = select(
|
||||
FileFolder,
|
||||
func.count(DownloadJob.id),
|
||||
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
||||
).where(FileFolder.user_id == caller.user.id)
|
||||
listable = caller.folders_with(Right.LIST)
|
||||
if listable is not None:
|
||||
stmt = stmt.where(FileFolder.id.in_(listable))
|
||||
rows = db.execute(
|
||||
select(
|
||||
FileFolder,
|
||||
func.count(DownloadJob.id),
|
||||
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
||||
)
|
||||
.outerjoin(DownloadJob, done)
|
||||
stmt.outerjoin(DownloadJob, done)
|
||||
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(FileFolder.user_id == user.id)
|
||||
.group_by(FileFolder.id)
|
||||
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
||||
).all()
|
||||
@@ -520,12 +552,20 @@ def list_files(
|
||||
sort: Literal["added", "name", "size"] = "added",
|
||||
order: Literal["asc", "desc"] = "desc",
|
||||
scope: Literal["mine", "shared"] = "mine",
|
||||
user: User = Depends(current_user),
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""The caller's done files (`scope=mine`, optionally one folder's), or the ones shared with
|
||||
them (`scope=shared`); searched by name and channel, sorted by when they were added, by name
|
||||
(in the reader's alphabet) or by size."""
|
||||
(in the reader's alphabet) or by size. A token lists only in the folders it may list, and never
|
||||
the shared ones."""
|
||||
user = caller.user
|
||||
listable = caller.folders_with(Right.LIST)
|
||||
if listable is not None:
|
||||
if scope == "shared":
|
||||
raise refused(403, "api_token_scope")
|
||||
if folder_id is not None and folder_id not in listable:
|
||||
raise not_found("folder")
|
||||
stmt = (
|
||||
select(DownloadJob)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
@@ -540,6 +580,8 @@ def list_files(
|
||||
stmt = stmt.where(DownloadJob.user_id == user.id)
|
||||
if folder_id is not None:
|
||||
stmt = stmt.where(DownloadJob.folder_id == folder_id)
|
||||
elif listable is not None:
|
||||
stmt = stmt.where(DownloadJob.folder_id.in_(listable))
|
||||
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
|
||||
term = q.strip()
|
||||
if term:
|
||||
@@ -693,11 +735,23 @@ def update_file(
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
def delete_file(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Delete a file from the caller's library; the bytes go with their last holder."""
|
||||
job = own_job_for_update(db, user, job_id)
|
||||
audit.record(db, user.id, AuditAction.FILE_DELETE, target_type="file", target_id=job_id)
|
||||
"""Delete a file from the caller's library; the bytes go with their last holder. A token
|
||||
deletes only in a folder it may delete in — judged on the row as locked, so a file moved out of
|
||||
that folder meanwhile is out of its reach."""
|
||||
job = own_job_for_update(db, caller.user, job_id)
|
||||
if not caller.may(job.folder_id, Right.DELETE):
|
||||
db.rollback() # lets go of the locks; nothing was written
|
||||
raise not_found("download")
|
||||
audit.record(
|
||||
db,
|
||||
caller.user.id,
|
||||
AuditAction.FILE_DELETE,
|
||||
target_type="file",
|
||||
target_id=job_id,
|
||||
summary=caller.via,
|
||||
)
|
||||
service.delete(db, job) # commits, the audit row with it
|
||||
return {"ok": True, "deleted": job_id}
|
||||
|
||||
@@ -732,6 +786,7 @@ def ingest_spooled(db: Session, upload: SpooledUpload) -> DownloadJob:
|
||||
display_name=upload.display_name,
|
||||
sha256=upload.sha256,
|
||||
folder_id=upload.folder_id,
|
||||
via=upload.via,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||
@@ -782,8 +837,10 @@ def serve_file(db: Session, job: DownloadJob) -> FileResponse:
|
||||
|
||||
|
||||
@router.get("/{job_id}/file")
|
||||
def get_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
response = serve_file(db, accessible_job(db, user, job_id))
|
||||
def get_file(
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
):
|
||||
response = serve_file(db, readable_job(db, caller, job_id))
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
@@ -840,13 +897,13 @@ def preview_file(
|
||||
|
||||
@router.get("/{job_id}/sha256")
|
||||
def get_sha256(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""The file's SHA-256 (hex), hashed now if it never was and kept on the asset for every holder.
|
||||
A sync handler: hashing a large file is blocking disk work, so it runs in the threadpool — and
|
||||
every other sync route shares that pool, so only `_HASHERS` files are hashed at a time (a caller
|
||||
past that is told to come back), and none of them holds a database connection while it reads."""
|
||||
asset, path = _ready_file(db, accessible_job(db, user, job_id))
|
||||
asset, path = _ready_file(db, readable_job(db, caller, job_id))
|
||||
if asset.sha256:
|
||||
return {"sha256": asset.sha256}
|
||||
asset_id = asset.id
|
||||
|
||||
Reference in New Issue
Block a user