Files
siftlode/backend/app/routes/files.py
T
peter b376373af5 fix(files): a folder's kind is answered before its new name, as before
A blank name on a source folder answered 400 name_required; it answers 409
not_own again.
2026-10-20 00:45:22 +02:00

796 lines
32 KiB
Python

"""The Files library HTTP API — a user's files, the folders they sit in, and the file itself.
A file in the library is a user's DONE job (`DownloadJob`) over its shared `MediaAsset`: the job is
the per-user layer (name, folder, kept, expiry, shares and links all hang off it), the asset the
bytes. Folders are rows (`downloads.folders`), so a rename or a move never touches the disk.
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 checks, the upload spool —
because a download and a library file are one row at two moments. What is read comes from
`downloads.queries`, what is written goes through Capture's service (`service`, `folders`); this
module validates, answers in HTTP and commits.
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
import hashlib
import os
import tempfile
import threading
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, BinaryIO, Literal
from urllib.parse import unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
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.config import settings
from app.db import get_db, release_read_connection
from app.downloads import folders, preview, queries, quota, service, storage
from app.downloads import upload as uploadmod
from app.downloads.status import JobStatus
from app.mailtext import lang_for_user
from app.models import DISPLAY_TEXT_MAX, User, display_text
from app.refusal import refused
from app.routes._common import capped_stream_to, not_found
from app.utils import iso
if TYPE_CHECKING:
from app.downloads.models import DownloadJob, FileFolder, MediaAsset
router = APIRouter(prefix="/api/files", tags=["files"])
# The longest a folder may keep a file, in days — ten years is "forever" in all but name.
_MAX_RETENTION_DAYS = 3650
_HASH_CHUNK = 1 << 20
# How many files the hash route reads at once: each holds a threadpool thread for the whole read.
_HASHERS = 2
_HASHING = threading.BoundedSemaphore(_HASHERS)
# --- shared with routes.downloads -------------------------------------------------------
def serialize(job: "DownloadJob", asset: "MediaAsset | None") -> dict:
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
return {
"id": job.id,
"status": job.status,
"progress": job.progress,
"speed_bps": job.speed_bps,
"eta_s": job.eta_s,
"phase": job.phase,
"error": job.error,
"error_code": job.error_code,
"error_detail": job.error_detail,
"queue_pos": job.queue_pos,
"created_at": iso(job.created_at),
"ready_at": iso(job.ready_at),
"source_kind": job.source_kind,
"source_ref": job.source_ref,
"source_url": service.reference_url(job, asset),
# A locally-generated poster to fall back to when the source had no thumbnail.
"poster_url": f"/api/downloads/{job.id}/poster.jpg"
if (asset is not None and asset.poster_path)
else None,
"profile_id": job.profile_id,
"spec": job.profile_snapshot,
"display_name": job.display_name,
"display_uploader": job.display_uploader,
"display_uploader_url": job.display_uploader_url,
"extra_links": job.extra_links or [],
"job_kind": job.job_kind,
"source_job_id": job.source_job_id,
"edit_spec": job.edit_spec,
"can_download": ready and job.status == JobStatus.DONE,
# The Files library: its folder, whether the user keeps it, and when it goes (NULL = never).
"folder_id": job.folder_id,
"kept": job.kept,
"expires_at": iso(job.expires_at),
"asset": None
if asset is None
else {
"id": asset.id,
"status": asset.status,
"title": asset.title,
"uploader": asset.uploader,
"upload_date": iso(asset.upload_date),
"duration_s": asset.duration_s,
"size_bytes": asset.size_bytes,
"width": asset.width,
"height": asset.height,
"container": asset.container,
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
# generic uploaded file is download-only. Null while still in flight (set at finalize).
"media_kind": asset.media_kind,
"uploader_url": asset.uploader_url,
"thumbnail_url": asset.thumbnail_url,
# "image" | "pdf" | "text" | null — what `/preview` shows (`downloads/preview.py`).
"preview": preview.kind(asset.media_kind, asset.container),
},
}
def serialize_job(db: Session, job: "DownloadJob") -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
return serialize(job, queries.asset_of(db, job))
def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
"""One of the user's jobs under its row lock (`service.lock_own_job`), or a 404."""
job = service.lock_own_job(db, user, job_id)
if job is None:
raise not_found("download")
return job
def quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
# FastAPI *before the first dependency runs*, so the auth dependency could not gate it and an anonymous
# caller could make the process write the whole per-file allowance to temp, per request (SEC-20
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
# declared AFTER the auth one, is the only shape that closes it — the same one
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
# every route with a raised ceiling rather than leaving it to a comment like this one.
#
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
# Hungarian filename would otherwise be unsendable.
UPLOAD_FILENAME_HEADER = "x-upload-filename"
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
# The folder the file goes in (its id); without it, the user's Uploads folder.
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, and who sent them."""
path: Path
size: int
sha256: str
user_id: int
filename: str
display_name: str | None
folder_id: int | None
via: str | None = None
def _header_text(request: Request, name: str) -> str:
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
own fallback names the file."""
raw = request.headers.get(name, "")
try:
return unquote(raw, errors="strict").strip()
except (UnicodeDecodeError, ValueError):
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
# a label; logging it would hand any client a free log line per request.
return ""
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 — 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:
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
class _HashingSink:
"""The upload's file, hashing the bytes on their way in: the hash costs no second read."""
def __init__(self, out: BinaryIO) -> None:
self.out = out
self.hash = hashlib.sha256()
def write(self, data: bytes, /) -> int:
self.hash.update(data)
return self.out.write(data)
async def spool_upload(
request: Request,
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.
`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 = 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, 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, 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)
fd, tmp = tempfile.mkstemp(dir=str(staging))
tmp_path = Path(tmp)
try:
with os.fdopen(fd, "wb") as out:
sink = _HashingSink(out)
size = await capped_stream_to(
request,
sink,
cap,
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
)
yield SpooledUpload(
path=tmp_path,
size=size,
sha256=sink.hash.hexdigest(),
user_id=user_id,
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)
def accessible_job(db: Session, user: User, job_id: int) -> "DownloadJob":
"""A file the caller may read (`queries.accessible_job`: their own, or shared with them)."""
job = queries.accessible_job(db, user, job_id)
if job is None:
raise not_found("download")
return job
def own_job(db: Session, user: User, job_id: int) -> "DownloadJob":
job = queries.own_job(db, user, job_id)
if job is None:
raise not_found("download")
return job
def own_folder(db: Session, user: User, folder_id: int) -> "FileFolder":
folder = queries.own_folder(db, user, folder_id)
if folder is None:
raise not_found("folder")
return folder
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> "DownloadJob":
"""A file `caller` may read (`queries.readable_job`), or a 404."""
job = queries.readable_job(db, caller, job_id)
if job is None:
raise not_found("download")
return job
# --- folders -------------------------------------------------------------------------------
def _serialize_folder(folder: "FileFolder", count: int, size: int, linked: bool = False) -> dict:
return {
"id": folder.id,
"kind": folder.kind,
# A source folder's name is the client's to render from its key, in the reader's language.
"source_key": folder.source_key,
"name": folder.name,
"retention_days": folder.retention_days,
"count": count,
"size_bytes": size,
"created_at": iso(folder.created_at),
# A live public link opens it: whatever is moved in is shown to whoever holds the link.
"has_link": linked,
}
@router.get("/folders")
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."""
rows = queries.folders_with_totals(db, caller.user, caller.folders_with(Right.LIST))
linked = queries.linked_folders(db, [f.id for f, _, _ in rows])
return [_serialize_folder(f, count, size, f.id in linked) for f, count, size in rows]
class FolderIn(BaseModel):
name: str = Field(min_length=1, max_length=DISPLAY_TEXT_MAX)
def _folder_name(raw: str) -> str:
name = display_text(raw)
if not name:
raise refused(400, "file_folder_name_required")
return name
@router.post("/folders")
def create_folder(
body: FolderIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
try:
folder = folders.create_own(db, user.id, _folder_name(body.name))
except folders.FolderError as e:
raise refused(409, e.reason) from e
audit.record(
db, user.id, AuditAction.FILE_FOLDER_CREATE, target_type="file_folder", target_id=folder.id
)
db.commit()
return _serialize_folder(folder, 0, 0)
class UpdateFolderIn(BaseModel):
# Presence (model_fields_set) decides what is touched; retention_days null = keep forever.
name: str | None = Field(default=None, min_length=1, max_length=DISPLAY_TEXT_MAX)
retention_days: int | None = Field(default=None, ge=1, le=_MAX_RETENTION_DAYS)
@router.patch("/folders/{folder_id}")
def update_folder(
folder_id: int,
body: UpdateFolderIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Rename one of the caller's own folders (a source folder's name is its origin's), and/or set
how long any of their folders keeps a file (`folders.set_retention`)."""
folder = own_folder(db, user, folder_id)
sent = body.model_fields_set
if "name" in sent:
# Which folder may be renamed is answered before what the name is, as it always was.
if not folders.renamable(folder):
raise refused(409, "file_folder_not_own")
try:
folders.rename(db, folder, _folder_name(body.name or ""))
except folders.FolderError as e:
raise refused(409, e.reason) from e
audit.record(
db,
user.id,
AuditAction.FILE_FOLDER_RENAME,
target_type="file_folder",
target_id=folder.id,
)
if "retention_days" in sent:
# The value replaced is the one read under the folder's lock, not the one read above it.
try:
before = folders.set_retention(db, folder, body.retention_days)
except folders.FolderError as e:
raise refused(409, e.reason) from e
if before != body.retention_days:
audit.record(
db,
user.id,
AuditAction.FILE_FOLDER_RETENTION,
target_type="file_folder",
target_id=folder.id,
before={"retention_days": before},
after={"retention_days": body.retention_days},
)
db.commit()
count, size = queries.folder_totals(db, folder.id)
linked = folder.id in queries.linked_folders(db, [folder.id])
return _serialize_folder(folder, count, size, linked)
@router.delete("/folders/{folder_id}")
def delete_folder(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Delete one of the caller's own folders; its files go back to their source folders."""
folder = own_folder(db, user, folder_id)
try:
moved = folders.delete_own(db, folder)
except folders.FolderError as e:
raise refused(409, e.reason) from e
audit.record(
db,
user.id,
AuditAction.FILE_FOLDER_DELETE,
target_type="file_folder",
target_id=folder_id,
after={"files_moved": moved},
)
db.commit()
return {"ok": True, "deleted": folder_id, "files_moved": moved}
@router.get("/usage")
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The caller's storage quota, plus the per-file upload cap.
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
boundary: the caps stay where they are and still refuse a client that ignores this.
"""
return {
**quota.usage(db, user.id),
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
}
@router.delete("/shared/{job_id}")
def remove_shared_with_me(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Remove a file that was shared WITH this user from their 'Shared with me' list. Deletes
only the recipient's share grant — the owner's job and the physical file are untouched (this is
a per-user dismissal, not a delete)."""
if service.unshare(db, job_id, user):
db.commit()
return {"ok": True, "removed": job_id}
# --- files ---------------------------------------------------------------------------------
@router.get("")
def list_files(
folder_id: int | None = None,
q: str = Query("", max_length=200),
sort: Literal["added", "name", "size"] = "added",
order: Literal["asc", "desc"] = "desc",
scope: Literal["mine", "shared"] = "mine",
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. 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")
shared = scope == "shared"
jobs = queries.library(
db,
user,
folder_id=folder_id,
listable=listable,
shared=shared,
term=q.strip(),
sort=sort,
order=order,
lang=lang_for_user(user),
)
assets = queries.assets_for(db, jobs)
positions = queries.positions_for(db, user, jobs)
shared_at = queries.shared_at(db, user, jobs) if shared else {}
sources = queries.source_keys(db, jobs)
return [
{
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0),
"shared": shared,
"shared_at": iso(shared_at.get(j.id)),
"source_key": sources[j.id],
}
for j in jobs
]
_MAX_EXTRA_LINKS = 8
def _clean_url(raw) -> str | None:
"""Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a
public watch page); anything else is dropped rather than trusted."""
if not isinstance(raw, str):
return None
url = raw.strip()
if not url:
return None
if not url.startswith(("http://", "https://")):
return None
# `urlparse` raises on a malformed authority; this function already has a "not a plausible URL"
# answer, so the typo belongs in it.
try:
parsed = urlparse(url)
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
# answer, the caller already has one, and any line here would carry the URL itself.
except ValueError:
return None
if not parsed.netloc:
return None
return url[:2048]
class UpdateFileIn(BaseModel):
# Presence (model_fields_set) decides what is touched; a null/"" text falls back to the system
# value (the title, the channel). All display-only: the file on disk keeps its own name.
name: str | None = None
uploader: str | None = None
uploader_url: str | None = None
extra_links: list | None = None
folder_id: int | None = None
kept: bool | None = None
@router.patch("/{job_id}")
def update_file(
job_id: int,
body: UpdateFileIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Rename a file and its channel, set its reference links (all display-only: the file on disk
keeps its own name), move it to another folder (`folders.move`), or keep it until deleted /
hand it back to its folder's retention."""
job = own_job(db, user, job_id)
sent = body.model_fields_set
display: dict[str, object] = {}
if "name" in sent:
display["display_name"] = display_text(body.name)
if "uploader" in sent:
display["display_uploader"] = display_text(body.uploader)
if "uploader_url" in sent:
display["display_uploader_url"] = _clean_url(body.uploader_url)
if "extra_links" in sent:
raw = body.extra_links or []
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
display["extra_links"] = cleaned or None
service.set_display(job, **display)
if "folder_id" in sent:
if body.folder_id is None:
raise refused(400, "file_folder_gone")
before = job.folder_id
try:
folders.move(db, job, folders.own_folder(db, user.id, body.folder_id))
except folders.FolderError as e:
raise refused(409, e.reason) from e
if job.folder_id != before:
audit.record(
db,
user.id,
AuditAction.FILE_MOVE,
target_type="file",
target_id=job.id,
before={"folder_id": before},
after={"folder_id": job.folder_id},
)
if "kept" in sent and body.kept is not None and body.kept != job.kept:
folders.set_kept(db, job, body.kept)
action = AuditAction.FILE_KEEP if body.kept else AuditAction.FILE_UNKEEP
audit.record(db, user.id, action, target_type="file", target_id=job.id)
db.commit()
return serialize_job(db, job)
@router.delete("/{job_id}")
def delete_file(
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. 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)
db.commit() # the audit row with it
return {"ok": True, "deleted": job_id}
@router.post("/upload")
def upload_file(
upload: SpooledUpload = Depends(spool_upload),
db: Session = Depends(get_db),
) -> dict:
"""Put a local file in the caller's library — in the folder `UPLOAD_FOLDER_HEADER` names, else
their Uploads folder. Any file type; media gets the player, a generic file is download-only.
The body IS the file (see the header note above `UPLOAD_FILENAME_HEADER`); `spool_upload` has
authenticated the caller, checked the folder and streamed it to disk already.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
plus sync DB writes — so it must NOT run on the event loop."""
return serialize_job(db, ingest_spooled(db, upload))
def ingest_spooled(db: Session, upload: SpooledUpload) -> "DownloadJob":
"""The spooled upload into the library (`upload.ingest_upload`, which commits its own rows),
its refusals answered as HTTP."""
if upload.size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
upload.user_id,
filename=upload.filename,
src_path=upload.path,
size=upload.size,
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
except folders.FolderError as e: # the folder went while the file was on its way
raise refused(409, e.reason) from e
return job
def _ready_file(db: Session, job: "DownloadJob") -> tuple["MediaAsset", Path]:
asset = queries.asset_of(db, job)
if asset is None or asset.status != "ready" or not asset.rel_path:
raise refused(409, "download_not_ready")
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise not_found("file")
return asset, path
def serve_file(db: Session, job: "DownloadJob") -> FileResponse:
"""The file of `job` (the caller may read it), range-aware, named by the user's display name.
The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
(`storage.served_media_type`), NEVER guessed from the file's own extension. A generic 'file' (and
any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
this origin. Recognised media is served with its real type so the in-app player streams it. Both
are range-aware (FileResponse → 206). A file whose hash is known says so (`Repr-Digest`, RFC
9530: the digest of the whole file, whichever range is asked for)."""
asset, path = _ready_file(db, job)
filename = storage.download_filename(
job.display_name or asset.title or asset.source_ref, asset.container, path
)
headers = {}
if asset.sha256:
digest = base64.b64encode(bytes.fromhex(asset.sha256)).decode()
headers["Repr-Digest"] = f"sha-256=:{digest}:"
service.touch(asset) # the handler commits it
media_type = storage.served_media_type(asset.media_kind, asset.container)
if media_type is None:
return FileResponse(
path,
filename=filename,
media_type="application/octet-stream",
content_disposition_type="attachment",
headers=headers,
)
return FileResponse(path, filename=filename, media_type=media_type, headers=headers)
@router.get("/{job_id}/file")
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
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines.
PREVIEW_HEADERS = {
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
}
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
TRUNCATED_HEADER = "X-Preview-Truncated"
@router.get("/{job_id}/preview")
def preview_file(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> Response:
"""The file shown in the library's viewer: an image or a PDF inline, a text format as plain text
(its first `TEXT_PREVIEW_BYTES`). The type comes from `downloads/preview.py`'s allow-list, so
an HTML or SVG upload arrives as text, never as a page. Anything else: 415 `file_no_preview`."""
job = accessible_job(db, user, job_id)
asset, path = _ready_file(db, job)
kind = preview.kind(asset.media_kind, asset.container)
if kind is None:
raise refused(415, "file_no_preview")
headers = dict(PREVIEW_HEADERS)
if kind == "text":
with path.open("rb") as f:
head = f.read(preview.TEXT_PREVIEW_BYTES + 1)
if len(head) > preview.TEXT_PREVIEW_BYTES:
head = head[: preview.TEXT_PREVIEW_BYTES]
headers[TRUNCATED_HEADER] = "1"
text = preview.text_utf8(head)
if text is None:
raise refused(415, "file_no_preview")
service.touch(asset)
db.commit()
return Response(text, media_type=preview.media_type(kind, asset.container), headers=headers)
filename = storage.download_filename(
job.display_name or asset.title or asset.source_ref, asset.container, path
)
service.touch(asset)
db.commit()
return FileResponse(
path,
filename=filename,
media_type=preview.media_type(kind, asset.container),
content_disposition_type="inline",
headers=headers,
)
@router.get("/{job_id}/sha256")
def get_sha256(
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, readable_job(db, caller, job_id))
if asset.sha256:
return {"sha256": asset.sha256}
asset_id = asset.id
if not _HASHING.acquire(blocking=False):
raise refused(429, "file_hash_busy")
try:
release_read_connection(db)
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(_HASH_CHUNK):
h.update(chunk)
except OSError as e:
raise not_found("file") from e
finally:
_HASHING.release()
digest = h.hexdigest()
service.store_sha256(db, asset_id, digest)
db.commit()
return {"sha256": digest}