Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call the session or set a model's fields: reads are downloads/queries, writes the unit's service (service, folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes through userscope (owned_by, owned_row), which owned_or_404 now uses too. The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes no other unit's, filters owners through userscope and commits only in its jobs and the upload ingest. A test per service use case, among them that the tray lists only the caller's files.
793 lines
32 KiB
Python
793 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:
|
|
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}
|