mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
* 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.
568 lines
25 KiB
Python
568 lines
25 KiB
Python
"""Project document shelf service: staging, atomic insert, conversion.
|
|
|
|
Implements the Phase-2 shelf-insert atomicity (spec §6.3): bytes are staged
|
|
under ``.staging/{uuid}``, hashed, then — inside the repository's one
|
|
transaction (active-project row lock → dedup select → insert) — atomically
|
|
renamed into the document's exclusive namespace **before** the row exists
|
|
(file-before-row, §10.3). A dedup hit removes staging and returns the existing
|
|
row (the first writer's name wins, §10.9); a failed insert cleans up only its
|
|
own namespace, best-effort. Every filesystem operation runs off the event
|
|
loop via :func:`deerflow.utils.file_io.run_file_io`.
|
|
|
|
Files are immutable and hash-qualified: ``stored_relpath`` (relative to
|
|
``users/{user_id}/projects/``) embeds the content hash and the row's own
|
|
document ID, so rows never share bytes and a re-upload after trash lands in a
|
|
fresh namespace (§6.2). Conversion is lazy: a convertible original is turned
|
|
into ``derived/converted.md`` on first read, written through a temporary file
|
|
and an atomic rename, and only when ``uploads.auto_convert_documents`` is on
|
|
(§6.4, §7.3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import codecs
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import uuid
|
|
from collections import OrderedDict
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
|
from deerflow.utils.file_io import run_file_io
|
|
from deerflow.utils.text_detection import is_text_file_by_content
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.persistence.projects import ProjectDocumentRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_FILENAME_BYTES = 255
|
|
|
|
|
|
class ShelfUploadTooLargeError(Exception):
|
|
"""Raised when staged bytes exceed ``uploads.max_file_size`` (mapped to 413)."""
|
|
|
|
|
|
def validate_shelf_filename(name: str) -> str:
|
|
"""Normalize and validate a shelf display filename; ``ValueError`` ⇒ 400.
|
|
|
|
The display filename occupies its own path component (no hash prefix or
|
|
suffix), so it must be a bare, non-empty filename within the 255 UTF-8
|
|
byte filesystem limit — path separators are rejected outright rather than
|
|
stripped (§6.5).
|
|
"""
|
|
candidate = name.strip() if name else ""
|
|
if not candidate:
|
|
raise ValueError("Filename is empty")
|
|
if "/" in candidate or "\\" in candidate:
|
|
raise ValueError(f"Filename contains a path separator: {name!r}")
|
|
if candidate in {".", ".."}:
|
|
raise ValueError(f"Filename is unsafe: {name!r}")
|
|
if len(candidate.encode("utf-8")) > _MAX_FILENAME_BYTES:
|
|
raise ValueError(f"Filename exceeds {_MAX_FILENAME_BYTES} UTF-8 bytes")
|
|
return candidate
|
|
|
|
|
|
def shelf_relpath(project_id: str, sha256: str, document_id: str) -> str:
|
|
"""Content-addressed namespace root for one row, relative to ``users/{user_id}/projects/``."""
|
|
return f"{project_id}/documents/{sha256[:2]}/{sha256}/{document_id}"
|
|
|
|
|
|
def original_file_path(paths: Paths, *, user_id: str, row: dict) -> Path:
|
|
"""Absolute host path of a row's immutable original bytes."""
|
|
return resolve_document_paths(paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])[0]
|
|
|
|
|
|
def converted_markdown_path(paths: Paths, *, user_id: str, row: dict) -> Path:
|
|
"""Absolute host path of a row's optional ``derived/converted.md`` companion."""
|
|
return resolve_document_paths(paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])[1]
|
|
|
|
|
|
def resolve_document_paths(paths: Paths, *, user_id: str, relpath: str, name: str) -> tuple[Path, Path]:
|
|
"""Resolve a row's ``(original, derived)`` host paths — worker-thread only on async paths.
|
|
|
|
``Paths.project_document_path`` resolves symlinks against the real
|
|
filesystem, so async callers MUST dispatch this through ``run_file_io``
|
|
(filesystem-offload convention); the helper itself stays sync because it
|
|
is also used from worker contexts already off the loop.
|
|
"""
|
|
namespace = paths.project_document_path(user_id, relpath)
|
|
return namespace / "original" / name, namespace / "derived" / "converted.md"
|
|
|
|
|
|
def _content_intact(paths: Paths, *, user_id: str, row: dict) -> bool:
|
|
"""Worker-thread: resolve the original and verify it exists and matches the row's recorded size."""
|
|
original = original_file_path(paths, user_id=user_id, row=row)
|
|
try:
|
|
return original.stat().st_size == int(row.get("size_bytes") or -1)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
async def check_document_content(paths: Paths, *, user_id: str, row: dict) -> bool:
|
|
"""True when the row's immutable original exists and matches ``size_bytes``.
|
|
|
|
The one content check shared by the text serving path, restore (under
|
|
the document lock), the content endpoint, and the retention sweep's row
|
|
reconciliation (§8.3/§11): missing or size-mismatched bytes mean external
|
|
interference — reported as ``content_missing``, never served, repaired,
|
|
or treated as an empty document.
|
|
"""
|
|
return await run_file_io(_content_intact, paths, user_id=user_id, row=row)
|
|
|
|
|
|
def _content_intact_batch(paths: Paths, *, user_id: str, rows: list[dict]) -> list[bool]:
|
|
"""Worker-thread: existence+size check for a whole page of rows in one offload pass."""
|
|
return [_content_intact(paths, user_id=user_id, row=row) for row in rows]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class StagedDocument:
|
|
"""Bytes staged under ``.staging/`` with their content address."""
|
|
|
|
staging_path: Path
|
|
sha256: str
|
|
size_bytes: int
|
|
|
|
|
|
def _open_staging(staging_dir: Path, staging_path: Path):
|
|
"""Worker-thread: create the staging directory and open the staging file."""
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
return open(staging_path, "wb")
|
|
|
|
|
|
def _close_and_unlink(handle: Any, staging_path: Path) -> None:
|
|
try:
|
|
handle.close()
|
|
finally:
|
|
staging_path.unlink(missing_ok=True)
|
|
|
|
|
|
async def stage_document_bytes(paths: Paths, *, user_id: str, project_id: str, chunks: AsyncIterator[bytes] | list[bytes], max_bytes: int) -> StagedDocument:
|
|
"""Stage upload/promote bytes off-loop, enforcing the single-file size cap.
|
|
|
|
Chunks are streamed to ``.staging/{uuid}`` (writes offloaded one by one,
|
|
the uploads router's discipline) while the content hash accumulates.
|
|
``max_bytes`` is the thread-upload limit (``uploads.max_file_size``) —
|
|
the shelf deliberately reuses it instead of growing a second knob with
|
|
the same meaning (§6.4). Overflow raises :class:`ShelfUploadTooLargeError`
|
|
(the route maps it to 413, mirroring the uploads router) and leaves no
|
|
staging file behind.
|
|
"""
|
|
staging_dir = paths.project_documents_dir(user_id, project_id) / ".staging"
|
|
staging_path = staging_dir / uuid.uuid4().hex
|
|
handle = await run_file_io(_open_staging, staging_dir, staging_path)
|
|
digest = hashlib.sha256()
|
|
size = 0
|
|
|
|
async def _aiter() -> AsyncIterator[bytes]:
|
|
if isinstance(chunks, list):
|
|
for chunk in chunks:
|
|
yield chunk
|
|
else:
|
|
async for chunk in chunks:
|
|
yield chunk
|
|
|
|
try:
|
|
async for chunk in _aiter():
|
|
size += len(chunk)
|
|
if size > max_bytes:
|
|
raise ShelfUploadTooLargeError(f"File exceeds the {max_bytes}-byte limit")
|
|
digest.update(chunk)
|
|
await run_file_io(handle.write, chunk)
|
|
await run_file_io(handle.close)
|
|
except Exception:
|
|
await run_file_io(_close_and_unlink, handle, staging_path)
|
|
raise
|
|
return StagedDocument(staging_path=staging_path, sha256=digest.hexdigest(), size_bytes=size)
|
|
|
|
|
|
def _place_staging(staging_path: Path, final_path: Path) -> None:
|
|
"""Atomically move staged bytes into the document's exclusive namespace."""
|
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
os.replace(staging_path, final_path)
|
|
except Exception:
|
|
staging_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _remove_staging(staging_path: Path) -> None:
|
|
staging_path.unlink(missing_ok=True)
|
|
|
|
|
|
def _remove_namespace(paths: Paths, *, user_id: str, relpath: str) -> None:
|
|
"""Best-effort removal of one document's exclusive namespace (never another row's)."""
|
|
shutil.rmtree(paths.project_document_path(user_id, relpath), ignore_errors=True)
|
|
|
|
|
|
async def add_staged_document(
|
|
repo: ProjectDocumentRepository,
|
|
paths: Paths,
|
|
*,
|
|
user_id: str,
|
|
project_id: str,
|
|
name: str,
|
|
staged: StagedDocument,
|
|
source_thread_id: str | None = None,
|
|
source_kind: str | None = None,
|
|
source_name: str | None = None,
|
|
) -> tuple[dict, bool] | None:
|
|
"""Publish staged bytes as a shelf row, or fold into the dedup hit.
|
|
|
|
Returns ``(row, created)`` — ``created`` is ``False`` for a dedup hit, in
|
|
which case the returned row is the pre-existing one (its name and
|
|
provenance win, §10.9) and the staged bytes are discarded. ``None`` means
|
|
the project is missing, foreign, or archived (the route maps it to 404).
|
|
A failure removes this document's namespace only when the row PROVABLY
|
|
does not exist: a post-commit failure (e.g. the insert's trailing
|
|
refresh) leaves the live row serving its placed bytes and is reported as
|
|
success, and an indeterminate liveness check leaves the namespace for
|
|
the reference-aware sweep (§8.3).
|
|
"""
|
|
document_id = uuid.uuid4().hex
|
|
relpath = shelf_relpath(project_id, staged.sha256, document_id)
|
|
# Symlink-resolving lookup — offloaded like every filesystem touch here.
|
|
final_path = (await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=relpath, name=name))[0]
|
|
|
|
async def _place() -> None:
|
|
await run_file_io(_place_staging, staged.staging_path, final_path)
|
|
|
|
try:
|
|
row = await repo.insert_active(
|
|
project_id,
|
|
document_id=document_id,
|
|
name=name,
|
|
relpath=relpath,
|
|
sha256=staged.sha256,
|
|
size_bytes=staged.size_bytes,
|
|
source_thread_id=source_thread_id,
|
|
source_kind=source_kind,
|
|
source_name=source_name,
|
|
place_file=_place,
|
|
user_id=user_id,
|
|
)
|
|
except Exception:
|
|
# ``insert_active`` owns its commit: a failure may surface AFTER the
|
|
# row committed (e.g. its trailing refresh), so the placed namespace
|
|
# is removed only when the row PROVABLY does not exist. When the row
|
|
# is live the insert effectively succeeded — the shelf already serves
|
|
# these bytes — so return it instead of raising; when liveness cannot
|
|
# be determined, leave the namespace for the reference-aware
|
|
# retention sweep (§8.3), which only collects unreferenced files and
|
|
# protects any row's namespace. The probe includes TRASHED rows: a
|
|
# row raced into trash (or a trashing project deletion) before the
|
|
# re-check still owns the placed bytes — trash is recoverable, so
|
|
# deleting the namespace would destroy a recoverable document.
|
|
await run_file_io(_remove_staging, staged.staging_path)
|
|
committed: dict | None
|
|
try:
|
|
committed = await repo.get(document_id, include_trashed=True, user_id=user_id)
|
|
except Exception:
|
|
committed = None
|
|
row_provably_absent = False
|
|
logger.warning(
|
|
"Shelf insert of %s failed and the liveness re-check failed too; leaving the namespace for the retention sweep",
|
|
document_id,
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
row_provably_absent = committed is None
|
|
if committed is not None:
|
|
if committed.get("trashed_at") is None:
|
|
logger.warning("Shelf insert of %s raised after commit; the row is live — keeping its namespace and reporting success", document_id)
|
|
return committed, True
|
|
# Committed but raced into trash before the re-check: keep the
|
|
# bytes recoverable in trash and surface the failure rather than
|
|
# presenting a trashed row as created.
|
|
logger.warning("Shelf insert of %s raised after commit and the row was trashed meanwhile; keeping its namespace in trash", document_id)
|
|
elif row_provably_absent:
|
|
# Confirmed rollback: the bytes are unreferenced — clean up only
|
|
# this document's own namespace (§6.3).
|
|
await run_file_io(_remove_namespace, paths, user_id=user_id, relpath=relpath)
|
|
raise
|
|
if row is None:
|
|
# Missing/foreign/archived project: nothing was placed or inserted.
|
|
await run_file_io(_remove_staging, staged.staging_path)
|
|
return None
|
|
if row["id"] != document_id:
|
|
# Dedup hit: the existing row wins; discard the staged duplicate.
|
|
await run_file_io(_remove_staging, staged.staging_path)
|
|
return row, False
|
|
return row, True
|
|
|
|
|
|
def _convert_in_thread(original: Path, output_path: Path) -> Path | None:
|
|
"""Run the async conversion engine on a private loop inside the worker.
|
|
|
|
``convert_file_to_markdown`` offloads its own heavy work (>1 MiB) but
|
|
stats and writes on its caller's loop; running the whole coroutine here
|
|
keeps every byte of the conversion path off the serving event loop.
|
|
"""
|
|
return asyncio.run(convert_file_to_markdown(original, output_path=output_path))
|
|
|
|
|
|
def _convert_and_publish(original: Path, derived: Path) -> bool:
|
|
"""Worker-thread body: convert to a temp file, then atomically publish.
|
|
|
|
The temp file lives beside the final path (same filesystem) so the
|
|
rename is atomic: readers only ever see a complete ``converted.md`` or
|
|
none at all. A conversion failure publishes nothing and removes the temp.
|
|
"""
|
|
derived.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_path = derived.parent / f".{derived.name}.{uuid.uuid4().hex}.tmp"
|
|
try:
|
|
produced = _convert_in_thread(original, temp_path)
|
|
if produced is None:
|
|
return False
|
|
os.replace(produced, derived)
|
|
return True
|
|
except Exception:
|
|
logger.warning("Failed to convert shelf document %s", original, exc_info=True)
|
|
temp_path.unlink(missing_ok=True)
|
|
return False
|
|
finally:
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
|
|
async def ensure_converted_markdown(repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, row: dict, auto_convert: bool) -> tuple[Path | None, str | None]:
|
|
"""Return ``(converted_path, None)`` or ``(None, reason)``, converting on first read.
|
|
|
|
Conversion runs — and ``derived/converted.md`` is published — while the
|
|
repository holds the document row lock, with ownership, shelf membership
|
|
and active state revalidated AFTER locking (§6.3): a trash/purge that
|
|
committed first makes this decline as ``content_missing`` without
|
|
converting or publishing anything, and one arriving during conversion
|
|
blocks on the row lock and then proceeds. The temp-file + atomic
|
|
``os.replace`` publish stays inside the lock, so no partial converted
|
|
text is ever exposed. The published companion is valid for the lifetime
|
|
of the row's immutable original and never needs invalidation (§6.2,
|
|
§10.9). ``reason`` is one of ``"conversion_disabled"`` (auto-convert
|
|
off, §7.3), ``"content_missing"`` (row no longer live), or ``"binary"``
|
|
(not convertible, or the conversion itself failed).
|
|
"""
|
|
if not auto_convert:
|
|
return None, "conversion_disabled"
|
|
original, derived = await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])
|
|
if original.suffix.lower() not in CONVERTIBLE_EXTENSIONS:
|
|
return None, "binary"
|
|
if await run_file_io(derived.is_file):
|
|
return derived, None
|
|
|
|
async def _convert(_locked_row: dict) -> bool:
|
|
# Re-check under the lock: a conversion that blocked on this row's
|
|
# lock may already have been published by the lock's previous holder.
|
|
if await run_file_io(derived.is_file):
|
|
return True
|
|
return await run_file_io(_convert_and_publish, original, derived)
|
|
|
|
locked = await repo.convert_under_live_lock(row["id"], project_id=row["project_id"], convert=_convert, user_id=user_id)
|
|
if locked is None:
|
|
# Purged/trashed (or foreign) between the caller's read and the
|
|
# locked revalidation: decline without publishing anything (§6.3).
|
|
return None, "content_missing"
|
|
_locked_row, published = locked
|
|
return (derived, None) if published else (None, "binary")
|
|
|
|
|
|
async def read_text_serving_path(repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, row: dict, auto_convert: bool) -> tuple[Path | None, str | None]:
|
|
"""Resolve which file serves a row's text reads, or why none can.
|
|
|
|
Returns ``(path, None)`` for a servable text source — the cached
|
|
``derived/converted.md``, the original when it samples as text, or a
|
|
freshly converted companion — and ``(None, reason)`` otherwise, where
|
|
``reason`` is one of ``"content_missing"``, ``"conversion_disabled"`` or
|
|
``"binary"`` so the caller can produce the §11 error without re-deriving
|
|
the classification. The immutable ORIGINAL is validated (existence AND
|
|
recorded size, the shared §8.3 check) before either serving source is
|
|
selected: a truncated or zero-byte original is ``content_missing``, never
|
|
served as complete, and the derived companion is only servable while its
|
|
owning original validates (§6.2). A convertible extension
|
|
(``CONVERTIBLE_EXTENSIONS``) takes the conversion path — or the
|
|
``conversion_disabled`` decline — BEFORE the text heuristic: a
|
|
null-free head (e.g. an ASCII85 PDF) must never be raw-served as text.
|
|
Genuine text extensions keep the sampled-head heuristic (no
|
|
``mime``/``is_text`` columns exist by design, §6.1).
|
|
"""
|
|
if not await check_document_content(paths, user_id=user_id, row=row):
|
|
return None, "content_missing"
|
|
original, derived = await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])
|
|
if await run_file_io(derived.is_file):
|
|
return derived, None
|
|
if original.suffix.lower() in CONVERTIBLE_EXTENSIONS:
|
|
return await ensure_converted_markdown(repo, paths, user_id=user_id, row=row, auto_convert=auto_convert)
|
|
if await run_file_io(is_text_file_by_content, original):
|
|
return original, None
|
|
return None, "binary"
|
|
|
|
|
|
#: Bounded decode window: reads stop once the requested character page is full.
|
|
_TEXT_READ_CHUNK = 65536
|
|
|
|
#: Bounded process-local LRU of decoded character counts, keyed by content
|
|
#: identity ``(document_id, sha256)``. Documents are immutable within a row's
|
|
#: lifetime (§6.2/§10.9) and ids are never reused, so a purge simply orphans
|
|
#: an entry until LRU eviction — no invalidation hook needed.
|
|
_CHAR_COUNT_CACHE: OrderedDict[tuple[str, str], int] = OrderedDict()
|
|
_CHAR_COUNT_CACHE_MAX = 256
|
|
|
|
|
|
def _read_text_window(path: Path, *, offset: int, limit: int) -> str:
|
|
"""Worker-thread: decode only enough of *path* to fill ``[offset, offset+limit)``.
|
|
|
|
Incremental UTF-8 decode (replacement-tolerant, split-sequence safe) over
|
|
bounded chunks: the full decoded string is never materialized and reading
|
|
stops as soon as the window is filled, so an early page of a multi-MiB
|
|
document never reads the whole file.
|
|
"""
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
parts: list[str] = []
|
|
position = 0 # decoded characters seen so far
|
|
end = offset + limit
|
|
with open(path, "rb") as handle:
|
|
while position < end:
|
|
chunk = handle.read(_TEXT_READ_CHUNK)
|
|
final = not chunk
|
|
text = decoder.decode(chunk, final)
|
|
if not text:
|
|
if final:
|
|
break
|
|
continue
|
|
if position + len(text) > offset:
|
|
parts.append(text[max(offset - position, 0) :])
|
|
position += len(text)
|
|
if final:
|
|
break
|
|
return "".join(parts)[:limit]
|
|
|
|
|
|
def _count_text_chars(path: Path) -> int:
|
|
"""Worker-thread: full scan-decode COUNT without materializing the string."""
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
total = 0
|
|
with open(path, "rb") as handle:
|
|
while chunk := handle.read(_TEXT_READ_CHUNK):
|
|
total += len(decoder.decode(chunk))
|
|
return total + len(decoder.decode(b"", True))
|
|
|
|
|
|
def _cached_char_count(document_id: str, sha256: str, path: Path) -> int:
|
|
"""Worker-thread: character count of an immutable document, cached by content identity."""
|
|
key = (document_id, sha256)
|
|
cached = _CHAR_COUNT_CACHE.get(key)
|
|
if cached is not None:
|
|
_CHAR_COUNT_CACHE.move_to_end(key)
|
|
return cached
|
|
total = _count_text_chars(path)
|
|
_CHAR_COUNT_CACHE[key] = total
|
|
while len(_CHAR_COUNT_CACHE) > _CHAR_COUNT_CACHE_MAX:
|
|
_CHAR_COUNT_CACHE.popitem(last=False)
|
|
return total
|
|
|
|
|
|
async def read_document_text_window(path: Path, *, offset: int, limit: int) -> str:
|
|
"""Decode the ``[offset, offset+limit)`` character window of a serving file, off-loop."""
|
|
return await run_file_io(_read_text_window, path, offset=offset, limit=limit)
|
|
|
|
|
|
async def document_char_count(*, document_id: str, sha256: str, path: Path) -> int:
|
|
"""Total decoded characters of a serving file, off-loop and content-identity cached."""
|
|
return await run_file_io(_cached_char_count, document_id, sha256, path)
|
|
|
|
|
|
def auto_convert_documents_enabled(app_config: Any) -> bool:
|
|
"""Return whether host-side document conversion is enabled (secure default off).
|
|
|
|
Mirrors the uploads router's reader: a malformed value declines rather
|
|
than crashing, and stringly-typed YAML booleans are honored.
|
|
"""
|
|
try:
|
|
uploads_cfg = getattr(app_config, "uploads", None)
|
|
raw = uploads_cfg.get("auto_convert_documents", False) if isinstance(uploads_cfg, dict) else getattr(uploads_cfg, "auto_convert_documents", False)
|
|
if isinstance(raw, str):
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(raw)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class ShelfContentMissingError(Exception):
|
|
"""A live row's original bytes are absent or size-mismatched (§11 ``content_missing``)."""
|
|
|
|
|
|
async def read_file_chunks(path: Path, *, chunk_size: int = 65536) -> AsyncIterator[bytes]:
|
|
"""Yield one file's bytes with every read off the event loop.
|
|
|
|
Shared by the from-thread promote path (thread file → shelf staging) and
|
|
the attach path (staged shelf copy → thread-upload ingestion).
|
|
"""
|
|
|
|
def _open() -> Any:
|
|
return open(path, "rb")
|
|
|
|
handle = await run_file_io(_open)
|
|
try:
|
|
while chunk := await run_file_io(handle.read, chunk_size):
|
|
yield chunk
|
|
finally:
|
|
await run_file_io(handle.close)
|
|
|
|
|
|
def _copy_original_under_lock(paths: Paths, staging_path: Path, *, user_id: str, row: dict) -> None:
|
|
"""Worker-thread: copy a live document's original to a stable staging file.
|
|
|
|
Runs inside the document row lock (``stage_live_copy``): the bytes are
|
|
immutable, so an absent file or a size disagreement means external
|
|
interference — reported as ``content_missing``, never copied (§8.3).
|
|
Resolving the original's path happens here too: ``project_document_path``
|
|
resolves symlinks against the real filesystem, so it stays off the
|
|
caller's event loop.
|
|
"""
|
|
source = original_file_path(paths, user_id=user_id, row=row)
|
|
expected_size = row.get("size_bytes")
|
|
if not source.is_file():
|
|
raise ShelfContentMissingError("original bytes are missing")
|
|
if expected_size is not None and source.stat().st_size != expected_size:
|
|
raise ShelfContentMissingError("original size disagrees with the row")
|
|
staging_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(source, staging_path)
|
|
|
|
|
|
async def stage_document_copy_for_attach(
|
|
repo: ProjectDocumentRepository,
|
|
paths: Paths,
|
|
*,
|
|
user_id: str,
|
|
project_id: str,
|
|
document_id: str,
|
|
) -> tuple[dict, Path] | None:
|
|
"""Stage a stable copy of a live document's original under its row lock.
|
|
|
|
§7.3 item 3: attach is serialized against purge/trash — the copy runs
|
|
while the repository holds the document row lock, then the lock is
|
|
released BEFORE the caller performs sandbox allocation/network sync.
|
|
The copy lands in the project shelf's ``.staging/`` (a crash leftover is
|
|
collected by the retention sweep's 24-hour orphan guard, §8.3) and is
|
|
the caller's responsibility to unlink. ``None`` means the document is
|
|
missing, foreign, trashed, or not on this project's shelf (the route
|
|
maps it to 404); :class:`ShelfContentMissingError` maps to 409.
|
|
"""
|
|
staging_path = paths.project_documents_dir(user_id, project_id) / ".staging" / f"attach-{uuid.uuid4().hex}"
|
|
|
|
async def _copy(row: dict) -> Path:
|
|
await run_file_io(_copy_original_under_lock, paths, staging_path, user_id=user_id, row=row)
|
|
return staging_path
|
|
|
|
result = await repo.stage_live_copy(document_id, project_id=project_id, stage=_copy, user_id=user_id)
|
|
if result is None:
|
|
return None
|
|
row, staged_path = result
|
|
return row, staged_path
|