deer-flow/backend/app/gateway/routers/project_documents.py
Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash

Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md
(issue #5160, tracker #5129) in the slice order of the spec's §16.

Slices:
- A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY
  admission pinning (both server-owned sets + worker hoist); latest-only
  request-scoped <project> block via DynamicContextMiddleware
  wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID
  prefix + marker + provenance, never persisted); journal audit
  fingerprints; Instructions tab.
- B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with
  locked check-and-set; hash-qualified immutable shelf storage with
  Paths helpers; upload/list/content/delete-to-trash routes; project
  delete trashes the shelf in-transaction; request-scoped bounded
  <documents> index with honest count/shown + actionable overflow note;
  list_project_documents/read_project_document tools registered only on
  pinned runs; PAT allowlist + drift guards; blocking-IO anchors.
- C: shared thread-upload ingestion service (uploads router refactored to
  parity); POST from-thread with provenance; attach-to-thread with
  lock-staged copy (archived source allowed); read-only thread-files
  view with per-group truncation reporting.
- D: restore (restored/merged/not_found/no_target/content_missing; no
  file moves), purge (continuous row lock across unlink/delete/commit,
  retryable on FS errors), retention sweep (lazy + startup, 24h orphan
  guard, row-side reconciliation never deletes).
- E: Documents tab (shelf + conversation-files browser, provenance,
  archived banner, content-missing rows), /workspace/trash route,
  sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks
  + specs.

Review hardening folded in (10 rounds, all with tests):
- force active shelf content (HTML/XML family) to download; nosniff on
  artifact + content responses; unified unsandboxed-iframe PDF preview
  (fixes the pre-existing Chromium sandbox blank in the artifact viewer)
- scope document trash to the URL project under the document lock
- atomic no-overwrite filename reservation for ALL ingestion (seeded
  claims + os.link commit with suffix retry; same-name re-upload now
  unique-names instead of replacing); hidden staging only, no visible
  placeholders; lease cleanup on setup failure
- serialize conversion under the document lock with post-lock active
  revalidation; drain locked filesystem work on cancellation; preserve
  bytes when an insert's commit state is uncertain (including trashed
  rows)
- original-integrity checks before serving text or cached conversions;
  content_missing surfaced in list responses (UI reads the flag, no
  409-probe); downloads always serve original bytes
- bounded streaming document reads with cached char counts; shelf limits
  declared in middleware release identity
- thread-root confinement for from-thread sources; config fallback
  rejects fractional/infinite values; composer counts staged
  attachments; pending attachments persist until submission or removal;
  in-flight instruction/rename edits survive save refetches; shelf and
  trash pagination; conversation-file and thread-files pages stay
  subscribed to refetches

Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md
contracts, config.example.yaml projects block.

Review follow-ups (head b4807477 → this revision):
- The trash retention sweep is split so repeated lazy triggers stay
  bounded: the indexed expiry purge still runs on every trigger
  (GET /api/trash/documents, POST /api/trash/purge) while the
  O(all rows + all files) reconciliation is throttled to one run per
  user per 15 minutes (process-local, per-user window). The startup
  sweep now runs as a background task instead of blocking gateway
  readiness, and shutdown awaits it (bounded).
- The export scrub (stripInternalMarkers) is fence- and indentation-aware
  like the render path, so a pasted, fenced <project>/<documents> snippet
  survives markdown export while real injected blocks (never fenced) are
  still removed. Fence regexes moved to a dependency-free leaf module to
  avoid the messages↔streamdown import cycle.
- The artifact viewer's PDF iframe no longer carries an added title
  attribute (the upstream e2e contract locates it via :not([title])), and
  the upstream artifact-preview spec now pins the new contract: PDFs
  render unsandboxed, images keep sandbox="".

* fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title

- Shutdown cancelled only the shield around the background startup sweep,
  so an all-users reconciliation that outlived the 5s budget kept walking
  rows and files while the document repo and DB engine were disposed
  underneath it. The wait now lives in `_shutdown_startup_trash_sweep`,
  which cancels the task and drains it before worker exit: the shield
  keeps the wait bounded, the cancel makes it final (CancelledError lands
  at the sweep's next await, and `_run_startup_trash_sweep` only catches
  `Exception`, so nothing swallows it).
- The browser-preview iframe lost `title={getFileName(filepath)}` in the
  previous fix round, leaving the PDF frame without an accessible name
  while its siblings keep theirs. Restore it (WCAG frame titles), assert
  it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]`
  instead of `iframe:not([title])`.

* fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel

`Task.cancel()` returns False when the sweep already finished inside the
window between the deadline firing and the cancel, so the shutdown log
claimed a cancellation that never happened. Branch on that outcome: the
warning stays for a real cancel, a late finish is logged at info, and both
paths still reap the task before worker exit.

* fix(projects): round-4 review — make Empty trash delete what it confirms

`POST /api/trash/purge` only ran the retention sweep, and the sweep's
candidate selection is age-gated, so a freshly trashed document survived
"Empty trash" even though the confirmation promises that every listed
document is permanently deleted. With one trashed row the route answered
`{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps
expired rows before listing, so the visible rows were normally ineligible
for the action by construction.

Empty trash now drives `purge_all_trashed`: the caller's trashed rows
(`list_all_trashed`, no age filter) each go through the same guarded,
row-locked `purge` as the single-document delete — bytes first, then the
row, in one transaction — so a row restored mid-flight is skipped instead of
force-deleted, and an unlink failure rolls that row back and answers 500 with
a retryable message. Retention expiry stays where it was: the sweep's
`purge_candidates` is now the only age-gated selection, and the lazy
retention sweep still runs on the listing and at startup.

Tests: the router suite replaces the retention-gated expectation with the
reviewer's repro (fresh row purged, bytes unlinked, shelf and other users'
trash untouched, a failing unlink stays retryable and 500); a blocking-I/O
anchor drives the new entry point through the offload; the mocked e2e covers
the action end to end; a new real-backend spec performs it against the real
gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE
and the phase-2 design docs (en+zh) state the age-independent contract.
2026-09-16 18:46:18 +08:00

495 lines
22 KiB
Python

"""Project document shelf API (Projects Phase 2 Slice B).
Routes under ``/api/projects/{project_id}/documents``: upload one file,
list the shelf, read/download a document's content, and move a document to
trash. Trashed rows are invisible to every endpoint. Fail closed everywhere:
a missing or foreign project/document is a 404 (never a 403 that leaks
existence), an archived project rejects uploads and individual trash with the
same 404 (§8.4), and a memory-backend deployment answers 503 ``"Projects"``
through the shared ``_require`` accessor convention (§11).
"""
from __future__ import annotations
import logging
import mimetypes
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.gateway.authz import require_permission
from app.gateway.deps import get_config, get_project_document_repo, get_project_repo, get_thread_store
from app.gateway.routers.uploads import DEFAULT_MAX_FILE_SIZE, UPLOAD_CHUNK_SIZE, _get_upload_limit
from app.gateway.upload_ingestion import ThreadUploadIngestionService, UnsafeFilenameError, UnsafeUploadDestinationError
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths, get_paths
from deerflow.projects.documents import (
ShelfContentMissingError,
ShelfUploadTooLargeError,
_content_intact_batch,
add_staged_document,
check_document_content,
read_file_chunks,
resolve_document_paths,
stage_document_bytes,
stage_document_copy_for_attach,
validate_shelf_filename,
)
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.uploads.manager import normalize_filename
from deerflow.utils.file_io import run_file_io
from deerflow.utils.text_detection import _is_active_content_mime_type, is_text_file_by_content
from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects/{project_id}/documents", tags=["project-documents"])
class ProjectDocumentResponse(BaseModel):
"""One active shelf row. Internal columns (``user_id``, ``stored_relpath``,
trash fields) never leave the server; trashed rows never reach it."""
id: str
name: str
size_bytes: int
sha256: str
source_thread_id: str | None = None
source_kind: str | None = None
source_name: str | None = None
created_at: str
updated_at: str
#: Read-time truth (never a persisted column): the immutable original is
#: missing or size-mismatched — external interference, §8.3/§11. The
#: derived companion is not the integrity anchor; the original is.
content_missing: bool = False
class ProjectDocumentListResponse(BaseModel):
documents: list[ProjectDocumentResponse]
total: int
limit: int
offset: int
class ProjectDocumentUploadResponse(BaseModel):
"""``deduplicated`` distinguishes the 200 hit (existing row, first name
wins, §10.9) from the 201 created row."""
document: ProjectDocumentResponse
deduplicated: bool
def _to_response(row: dict, *, content_missing: bool = False) -> ProjectDocumentResponse:
return ProjectDocumentResponse(
id=row["id"],
name=row.get("name", ""),
size_bytes=row.get("size_bytes", 0),
sha256=row.get("sha256", ""),
source_thread_id=row.get("source_thread_id"),
source_kind=row.get("source_kind"),
source_name=row.get("source_name"),
created_at=row.get("created_at", ""),
updated_at=row.get("updated_at", ""),
content_missing=content_missing,
)
def _not_found() -> HTTPException:
# Fail closed: foreign projects/documents are indistinguishable from missing.
return HTTPException(status_code=404, detail="Project document not found")
#: Non-text MIME families a browser renders inline WITHOUT executing script
#: on the application origin (the sandboxed preview iframe navigates to the
#: content endpoint). Active content (HTML/XML family) is never viewable.
_INLINE_VIEWABLE_MIME_PREFIXES = ("image/", "audio/", "video/")
def _is_inline_viewable_mime_type(media_type: str) -> bool:
"""Whether *media_type* may be served ``inline`` when ``download=false``.
Passive text and browser-viewable binaries (PDF, image, audio, video)
are inline-able; active content — HTML or any XML family type, including
``+xml`` subtypes such as SVG — never is (same rule as the artifacts
router), regardless of the ``download`` flag.
"""
if _is_active_content_mime_type(media_type):
return False
return media_type.startswith("text/") or media_type == "application/pdf" or media_type.startswith(_INLINE_VIEWABLE_MIME_PREFIXES)
async def _require_project(request: Request, project_id: str) -> dict:
"""Owned project row in any status (archived keeps read access, §8.4), else 404."""
row = await get_project_repo(request).get(project_id)
if row is None:
raise _not_found()
return row
async def _stream_upload(file: UploadFile) -> AsyncIterator[bytes]:
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
yield chunk
@router.get("", response_model=ProjectDocumentListResponse)
@require_permission("projects", "read")
async def list_project_documents(
project_id: str,
request: Request,
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> ProjectDocumentListResponse:
await _require_project(request, project_id)
repo = get_project_document_repo(request)
rows = await repo.list_active(project_id, limit=limit, offset=offset)
total = await repo.count_active(project_id)
# Read-time integrity truth for the whole page in ONE offloaded pass:
# files can only change via external interference, so the list surfaces
# content_missing without a schema column (§8.3/§11).
intact = await run_file_io(_content_intact_batch, get_paths(), user_id=get_effective_user_id(), rows=rows)
return ProjectDocumentListResponse(
documents=[_to_response(row, content_missing=not ok) for row, ok in zip(rows, intact, strict=True)],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=ProjectDocumentUploadResponse, status_code=201)
@require_permission("projects", "write")
async def upload_project_document(
project_id: str,
request: Request,
response: Response,
file: UploadFile = File(...),
name: str | None = Form(default=None),
config: AppConfig = Depends(get_config),
) -> ProjectDocumentUploadResponse:
"""Shelf exactly one file (§17.2): 201 created, or 200 on a content dedup hit.
Active projects only — the shelf insert locks the project row with
``status='active'`` inside its transaction, so an archived/foreign/missing
project is the same 404 (§8.4, §11). The display name defaults to the
multipart filename; both go through the same validation (400 when empty,
separator-bearing, or over 255 UTF-8 bytes). Size reuses
``uploads.max_file_size`` (413, mirroring the uploads router).
"""
try:
display_name = validate_shelf_filename(name) if name is not None else validate_shelf_filename(normalize_filename(file.filename or ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
user_id = get_effective_user_id()
paths = get_paths()
max_file_size = _get_upload_limit(config, "max_file_size", DEFAULT_MAX_FILE_SIZE, legacy_key="max_single_file_size")
try:
staged = await stage_document_bytes(paths, user_id=user_id, project_id=project_id, chunks=_stream_upload(file), max_bytes=max_file_size)
except ShelfUploadTooLargeError:
raise HTTPException(status_code=413, detail=f"File too large: {display_name}")
except ValueError:
# Unsafe project_id charset — fail closed, indistinguishable from missing.
raise _not_found()
if staged.size_bytes == 0:
await run_file_io(staged.staging_path.unlink, True)
raise HTTPException(status_code=400, detail="Empty file")
result = await add_staged_document(
get_project_document_repo(request),
paths,
user_id=user_id,
project_id=project_id,
name=display_name,
staged=staged,
source_kind="upload",
)
if result is None:
raise _not_found()
row, created = result
if not created:
# Dedup hit: same body, 200 instead of 201 — the first writer's name
# and provenance win (§10.9).
response.status_code = 200
return ProjectDocumentUploadResponse(document=_to_response(row), deduplicated=not created)
class PromoteThreadFileRequest(BaseModel):
"""Save-to-shelf body (§7.4): locate one thread file, optional shelf rename."""
thread_id: str
kind: Literal["upload", "output"]
name: str
shelf_name: str | None = None
class AttachDocumentResponse(BaseModel):
"""The completed thread attachment — returned only after ingestion succeeds (§7.3 item 3)."""
filename: str
size_bytes: int
virtual_path: str
artifact_url: str
def _resolve_thread_source(paths: Paths, *, user_id: str, thread_id: str, kind: str, name: str) -> Path | None:
"""Resolve the source file inside the thread's own uploads/outputs dir.
Confinement failure is indistinguishable from absence (§11): a separator-
bearing or dot name, an escape of the thread directory (a symlink is
followed and re-checked), a missing file, or an unsafe thread id all
return ``None`` → 404. The boundary is the TRUSTED thread storage root —
established unresolved from the server-owned base (resolving it would let
a planted symlink turn an external directory into the trusted base and
break user-bucket isolation): the resolved kind directory must live under
that root, and the resolved candidate under the kind directory, so a
symlinked uploads/outputs dir escaping thread storage is ``None`` while
an in-root symlink keeps working (same behavior as virtual-path
resolution). Runs in the file-IO worker (called via ``run_file_io``).
"""
if not name or name in (".", "..") or "/" in name or "\\" in name:
return None
try:
thread_root = paths.thread_dir(thread_id, user_id=user_id)
kind_dir = (paths.sandbox_uploads_dir(thread_id, user_id=user_id) if kind == "upload" else paths.sandbox_outputs_dir(thread_id, user_id=user_id)).resolve()
kind_dir.relative_to(thread_root)
candidate = (kind_dir / name).resolve()
candidate.relative_to(kind_dir)
except (ValueError, OSError):
return None
return candidate if candidate.is_file() else None
@router.post("/from-thread", response_model=ProjectDocumentUploadResponse, status_code=201)
@require_permission("projects", "write")
@require_permission("threads", "read")
async def promote_thread_file_to_shelf(
project_id: str,
body: PromoteThreadFileRequest,
request: Request,
response: Response,
config: AppConfig = Depends(get_config),
) -> ProjectDocumentUploadResponse:
"""Copy one thread file (upload or output) into the shelf (§7.4).
The source is never moved — the shelf gets a project-owned snapshot with
provenance (``source_thread_id``/``source_kind``/``source_name``), so
deleting the thread cannot affect it. ``shelf_name`` defaults to the
source name and follows upload-name validation. The shelf insert locks
the project row with ``status='active'``, so an archived/foreign/missing
project is the same 404 as a source that fails confinement (§8.4, §11).
Response mirrors upload: 201 created, 200 on a content dedup hit (the
existing row keeps its own name and provenance, §10.9).
"""
if await get_thread_store(request).get(body.thread_id) is None:
raise _not_found()
repo = get_project_document_repo(request)
try:
display_name = validate_shelf_filename(body.shelf_name) if body.shelf_name is not None else validate_shelf_filename(normalize_filename(body.name))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
user_id = get_effective_user_id()
paths = get_paths()
source = await run_file_io(_resolve_thread_source, paths, user_id=user_id, thread_id=body.thread_id, kind=body.kind, name=body.name)
if source is None:
raise _not_found()
max_file_size = _get_upload_limit(config, "max_file_size", DEFAULT_MAX_FILE_SIZE, legacy_key="max_single_file_size")
try:
staged = await stage_document_bytes(paths, user_id=user_id, project_id=project_id, chunks=read_file_chunks(source, chunk_size=UPLOAD_CHUNK_SIZE), max_bytes=max_file_size)
except ShelfUploadTooLargeError:
raise HTTPException(status_code=413, detail=f"File too large: {display_name}")
except ValueError:
# Unsafe project_id charset — fail closed, indistinguishable from missing.
raise _not_found()
if staged.size_bytes == 0:
await run_file_io(staged.staging_path.unlink, True)
raise HTTPException(status_code=400, detail="Empty file")
result = await add_staged_document(
repo,
paths,
user_id=user_id,
project_id=project_id,
name=display_name,
staged=staged,
source_thread_id=body.thread_id,
source_kind=body.kind,
source_name=body.name,
)
if result is None:
raise _not_found()
row, created = result
if not created:
# Dedup hit: same body, 200 instead of 201 — the first writer's name
# and provenance win (§10.9).
response.status_code = 200
return ProjectDocumentUploadResponse(document=_to_response(row), deduplicated=not created)
@router.post("/{document_id}/attach-to-thread/{thread_id}", response_model=AttachDocumentResponse)
@require_permission("projects", "write")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def attach_project_document_to_thread(
project_id: str,
document_id: str,
thread_id: ThreadId,
request: Request,
config: AppConfig = Depends(get_config),
) -> AttachDocumentResponse:
"""Materialize an independent copy of a shelf document into a thread (§7.3 item 3).
The owned LIVE source document (an archived source project stays
readable, §8.4 — its shelf is not mutated) is staged to a stable copy
under its row lock, serialized against purge; the lock is then released
and the shared thread-upload ingestion service takes over with ordinary
upload lifecycle guarantees — size checks, optional conversion,
sandbox-readable permissions, and non-mounted provider sync. Filename
claiming is seeded from the thread's EXISTING uploads first, so the
shelf copy never silently replaces a same-named thread file: it lands
under a claimed unique name (``report_1.txt``), which the response
reflects.
The document transaction is never held across sandbox allocation or
network sync. A target thread the caller cannot write is a 404 (the
owner check above, ``require_existing``); the response is returned only
after ingestion succeeds.
"""
await _require_project(request, project_id)
repo = get_project_document_repo(request)
user_id = get_effective_user_id()
paths = get_paths()
try:
staged = await stage_document_copy_for_attach(repo, paths, user_id=user_id, project_id=project_id, document_id=document_id)
except ShelfContentMissingError:
raise HTTPException(status_code=409, detail="content_missing")
except ValueError:
# Unsafe project_id charset — fail closed, indistinguishable from missing.
raise _not_found()
if staged is None:
raise _not_found()
row, staged_path = staged
service = ThreadUploadIngestionService(request=request, thread_id=thread_id, user_id=user_id, app_config=config)
try:
try:
await service.open()
file_info = await service.ingest_chunks(read_file_chunks(staged_path, chunk_size=UPLOAD_CHUNK_SIZE), display_name=row["name"])
except UnsafeFilenameError as exc:
# Shelf names always normalize, so this cannot fire — fail closed.
await service.cleanup_written()
raise HTTPException(status_code=500, detail=f"Failed to upload {row['name']}: {exc}")
except UnsafeUploadDestinationError as exc:
await service.cleanup_written()
raise HTTPException(status_code=500, detail=f"Failed to upload {row['name']}: {exc}")
except HTTPException:
await service.cleanup_written()
raise
except Exception as exc:
logger.error(f"Failed to attach {row['name']} to thread {thread_id}: {exc}")
await service.cleanup_written()
raise HTTPException(status_code=500, detail=f"Failed to upload {row['name']}: {exc}")
# Sync-phase failures (permissions/provider sync) follow ordinary
# uploads: they propagate and the host files stay in place.
await service.finalize()
finally:
await run_file_io(staged_path.unlink, True)
await service.aclose()
return AttachDocumentResponse(
filename=file_info["filename"],
size_bytes=file_info["size"],
virtual_path=file_info["virtual_path"],
artifact_url=file_info["artifact_url"],
)
@router.get("/{document_id}/content")
@require_permission("projects", "read")
async def get_project_document_content(
project_id: str,
document_id: str,
request: Request,
download: bool = Query(default=False),
) -> FileResponse:
"""Inline text or attachment for one active shelf document (§6.5).
``download=false`` serves text inline (the converted-markdown companion
when present, else the original when it samples as text) plus
browser-viewable binaries — PDF, image, audio, video — for the sandboxed
preview iframe, and streams other binaries as attachments.
``download=true`` always attaches the immutable ORIGINAL bytes under the
original filename — the converted-markdown companion is preview-only, so
a prior agent read never changes the download's format. Active content
(HTML and the XML family, including ``+xml`` subtypes) is always forced to
an attachment regardless of ``download`` so it never executes script on
the application origin. A row whose original bytes are missing or
size-mismatched answers with an explicit ``content_missing`` error (§11) —
never an empty or substituted body.
"""
await _require_project(request, project_id)
row = await get_project_document_repo(request).get(document_id)
if row is None or row.get("project_id") != project_id:
raise _not_found()
paths = get_paths()
user_id = get_effective_user_id()
# Validate the immutable ORIGINAL (existence AND recorded size, the one
# shared §8.3 check) before selecting EITHER serving path: the derived
# companion is only servable while its owning original validates (§6.2) —
# a deleted or truncated original is ``content_missing``, never a healthy
# preview.
if not await check_document_content(paths, user_id=user_id, row=row):
raise HTTPException(status_code=409, detail="content_missing")
original, derived = await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])
serving_path = original
media_type: str | None = None
filename = row["name"]
# The converted-markdown companion is a PREVIEW aid only: an explicit
# download always serves the immutable original bytes under the original
# filename — never a format that changed because an agent once read it.
if not download and await run_file_io(derived.is_file):
serving_path = derived
media_type = "text/markdown"
filename = f"{row['name']}.md"
else:
guessed, _ = await run_file_io(mimetypes.guess_type, str(original))
media_type = guessed
if media_type is None and not download and await run_file_io(is_text_file_by_content, serving_path):
media_type = "text/plain"
# Inline only what a browser can display WITHOUT running script on the
# application origin: passive text, the converted-markdown companion, and
# browser-viewable binaries (PDF, image, audio, video — the sandboxed
# preview iframe navigates here). Active content (HTML or any XML family
# type, including ``+xml`` subtypes such as SVG) is ALWAYS an attachment
# regardless of ``download``, exactly like the artifacts router, and
# ``download=true`` attaches everything.
inline = not download and media_type is not None and _is_inline_viewable_mime_type(media_type)
return FileResponse(
serving_path,
media_type=media_type or "application/octet-stream",
filename=filename,
# nosniff: a declared PDF/image/etc. must never be reinterpreted as
# HTML — the transport-level guarantee behind unsandboxed PDF preview.
headers={"X-Content-Type-Options": "nosniff"},
content_disposition_type="inline" if inline else "attachment",
)
@router.delete("/{document_id}", status_code=204)
@require_permission("projects", "delete")
async def delete_project_document(project_id: str, document_id: str, request: Request) -> None:
"""Move one document to trash (204). Restore/purge are the Slice-D trash tier.
Archived shelves reject the write inside the repository's locked
transaction, so they surface as the same fail-closed 404 (§8.4).
"""
await _require_project(request, project_id)
if not await get_project_document_repo(request).trash(document_id, project_id=project_id):
raise _not_found()