/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.
692 lines
28 KiB
Python
692 lines
28 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 locks, the upload spool —
|
|
because a download and a library file are one row at two moments.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
import tempfile
|
|
from collections.abc import AsyncIterator, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import BinaryIO, Literal
|
|
from urllib.parse import unquote
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit, sysconfig
|
|
from app.audit import AuditAction
|
|
from app.auth import current_user
|
|
from app.collation import collated
|
|
from app.config import settings
|
|
from app.db import get_db, release_read_connection
|
|
from app.downloads import folders, 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,
|
|
DownloadJob,
|
|
DownloadShare,
|
|
FileFolder,
|
|
LibraryState,
|
|
MediaAsset,
|
|
User,
|
|
display_text,
|
|
)
|
|
from app.refusal import refused
|
|
from app.routes._common import capped_stream_to, not_found, owned_or_404
|
|
from app.utils import iso, like_escape
|
|
|
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
|
|
|
# Cap the listing so a heavy library can't return an unbounded result set (the old Download Center's
|
|
# own cap; far beyond any real library).
|
|
MAX_LIST = 500
|
|
# 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
|
|
|
|
|
|
# --- 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),
|
|
"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,
|
|
"expired": job.asset_id is None 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,
|
|
},
|
|
}
|
|
|
|
|
|
def assets_for(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, MediaAsset]:
|
|
ids = {j.asset_id for j in jobs if j.asset_id}
|
|
if not ids:
|
|
return {}
|
|
rows = db.execute(select(MediaAsset).where(MediaAsset.id.in_(ids))).scalars()
|
|
return {a.id: a for a in rows}
|
|
|
|
|
|
def positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
|
|
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
|
|
ids = [j.id for j in jobs]
|
|
if not ids:
|
|
return {}
|
|
rows = db.execute(
|
|
select(LibraryState.job_id, LibraryState.position_seconds).where(
|
|
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
|
|
)
|
|
).all()
|
|
return {job_id: pos for job_id, pos in rows}
|
|
|
|
|
|
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."""
|
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
return serialize(job, asset)
|
|
|
|
|
|
def own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
|
or writes the status those decide on (pause). The job is re-read under a row lock, so two
|
|
overlapping requests for one job run one after the other and the second decides on the first's
|
|
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
|
just released back into a holder; either can free a file another job still holds.
|
|
|
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
|
while True:
|
|
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").asset_id
|
|
if asset_id is not None:
|
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
|
job = db.execute(
|
|
select(DownloadJob)
|
|
.where(DownloadJob.id == job_id)
|
|
.with_for_update()
|
|
.execution_options(populate_existing=True)
|
|
).scalar_one_or_none()
|
|
if job is None:
|
|
raise not_found("download")
|
|
if job.asset_id == asset_id:
|
|
return job
|
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
|
|
|
|
|
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."""
|
|
|
|
path: Path
|
|
size: int
|
|
sha256: str
|
|
user_id: int
|
|
filename: str
|
|
display_name: str | None
|
|
folder_id: int | 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, user_id: int, raw: str) -> int | None:
|
|
"""The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for
|
|
none."""
|
|
if not raw.strip():
|
|
return None
|
|
try:
|
|
return folders.upload_target(db, user_id, int(raw)).id
|
|
except ValueError as e:
|
|
raise refused(409, "file_folder_gone") from e
|
|
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,
|
|
user: User = Depends(current_user),
|
|
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."""
|
|
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
|
user_id = user.id
|
|
# 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, ""))
|
|
# 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.
|
|
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,
|
|
)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
"""A file the caller may read: their own, or one shared with them."""
|
|
job = db.get(DownloadJob, job_id)
|
|
if job is None:
|
|
raise not_found("download")
|
|
if job.user_id == user.id:
|
|
return job
|
|
shared = db.execute(
|
|
select(DownloadShare.id).where(
|
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
|
)
|
|
).first()
|
|
if shared is None:
|
|
raise not_found("download")
|
|
return job
|
|
|
|
|
|
# --- folders -------------------------------------------------------------------------------
|
|
|
|
|
|
def _serialize_folder(folder: FileFolder, count: int, size: int) -> 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),
|
|
}
|
|
|
|
|
|
def _own_folder_or_404(db: Session, user: User, folder_id: int) -> FileFolder:
|
|
return owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
|
|
|
|
|
@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."""
|
|
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
|
|
rows = db.execute(
|
|
select(
|
|
FileFolder,
|
|
func.count(DownloadJob.id),
|
|
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
|
)
|
|
.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()
|
|
return [_serialize_folder(f, count, int(size)) 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:
|
|
folder = FileFolder(user_id=user.id, kind=folders.FolderKind.OWN, name=_folder_name(body.name))
|
|
try:
|
|
with db.begin_nested():
|
|
db.add(folder)
|
|
db.flush()
|
|
except IntegrityError as e: # the unique (user, lower(name)): one folder per name
|
|
raise refused(409, "file_folder_name_taken") 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_or_404(db, user, folder_id)
|
|
sent = body.model_fields_set
|
|
if "name" in sent:
|
|
if folder.kind != folders.FolderKind.OWN:
|
|
raise refused(409, "file_folder_not_own")
|
|
folder.name = _folder_name(body.name or "")
|
|
try:
|
|
with db.begin_nested():
|
|
db.flush()
|
|
except IntegrityError as e:
|
|
raise refused(409, "file_folder_name_taken") from e
|
|
audit.record(
|
|
db,
|
|
user.id,
|
|
AuditAction.FILE_FOLDER_RENAME,
|
|
target_type="file_folder",
|
|
target_id=folder.id,
|
|
)
|
|
if "retention_days" in sent and body.retention_days != folder.retention_days:
|
|
before = folder.retention_days
|
|
folders.set_retention(db, folder, 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 = _folder_totals(db, folder.id)
|
|
return _serialize_folder(folder, count, size)
|
|
|
|
|
|
def _folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
|
|
count, size = db.execute(
|
|
select(func.count(DownloadJob.id), func.coalesce(func.sum(MediaAsset.size_bytes), 0))
|
|
.select_from(DownloadJob)
|
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
|
.where(DownloadJob.folder_id == folder_id, DownloadJob.status == JobStatus.DONE)
|
|
).one()
|
|
return count, int(size)
|
|
|
|
|
|
@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_or_404(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}
|
|
|
|
|
|
# --- 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",
|
|
user: User = Depends(current_user),
|
|
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."""
|
|
stmt = (
|
|
select(DownloadJob)
|
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
|
.where(DownloadJob.status == JobStatus.DONE)
|
|
)
|
|
if scope == "shared":
|
|
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
|
DownloadShare.to_user_id == user.id
|
|
)
|
|
else:
|
|
stmt = stmt.where(DownloadJob.user_id == user.id)
|
|
if folder_id is not None:
|
|
stmt = stmt.where(DownloadJob.folder_id == folder_id)
|
|
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
|
|
term = q.strip()
|
|
if term:
|
|
like = f"%{like_escape(term)}%"
|
|
stmt = stmt.where(
|
|
or_(
|
|
name.ilike(like),
|
|
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
|
)
|
|
)
|
|
key = {
|
|
"added": DownloadJob.created_at,
|
|
"name": collated(name, lang_for_user(user)),
|
|
"size": MediaAsset.size_bytes,
|
|
}[sort]
|
|
first = key.asc() if order == "asc" else key.desc()
|
|
tie = DownloadJob.id.asc() if order == "asc" else DownloadJob.id.desc()
|
|
jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all()
|
|
assets = assets_for(db, jobs)
|
|
positions = positions_for(db, user, jobs)
|
|
shared = scope == "shared"
|
|
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,
|
|
}
|
|
for j in jobs
|
|
]
|
|
|
|
|
|
class UpdateFileIn(BaseModel):
|
|
# Presence (model_fields_set) decides what is touched; a null/"" name falls back to the title.
|
|
name: str | 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 (its display name; the file on disk keeps its own), move it to another folder
|
|
(`folders.move`), or keep it until deleted / hand it back to its folder's retention."""
|
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
sent = body.model_fields_set
|
|
if "name" in sent:
|
|
job.display_name = display_text(body.name)
|
|
if "folder_id" in sent:
|
|
if body.folder_id is None:
|
|
raise refused(400, "file_folder_gone")
|
|
folder = db.get(FileFolder, body.folder_id)
|
|
if folder is None or folder.user_id != user.id:
|
|
raise refused(409, "file_folder_gone")
|
|
before = job.folder_id
|
|
try:
|
|
folders.move(db, job, folder)
|
|
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, user: User = Depends(current_user), 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)
|
|
service.delete(db, job) # commits, 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,
|
|
)
|
|
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 = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
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}:"
|
|
asset.last_access_at = datetime.now(timezone.utc) # 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, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
response = serve_file(db, accessible_job(db, user, job_id))
|
|
db.commit()
|
|
return response
|
|
|
|
|
|
@router.get("/{job_id}/sha256")
|
|
def get_sha256(
|
|
job_id: int, user: User = Depends(current_user), 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."""
|
|
asset, path = _ready_file(db, accessible_job(db, user, job_id))
|
|
if not asset.sha256:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
while chunk := f.read(_HASH_CHUNK):
|
|
h.update(chunk)
|
|
asset.sha256 = h.hexdigest()
|
|
db.commit()
|
|
return {"sha256": asset.sha256}
|