import asyncio import functools import hashlib import logging import mimetypes import os import stat import tempfile import zipfile from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from urllib.parse import quote from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request from app.gateway.deps import get_run_manager from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.path_utils import normalize_outputs_virtual_path, resolve_outputs_confined_path, resolve_thread_virtual_path from deerflow.authz.sandbox_authz import safe_app_config from deerflow.config.paths import make_safe_user_id from deerflow.runtime import ConflictError, ThreadOperationKind from deerflow.runtime.user_context import get_effective_user_id from deerflow.sandbox.sandbox_provider import get_sandbox_provider from deerflow.utils.thread_id import ThreadId logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["artifacts"]) # Exact matches only; ``_is_active_content_mime_type`` also treats every # ``+xml`` subtype as active content. ACTIVE_CONTENT_MIME_TYPES = { "text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml", "text/xsl", } MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024 _SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024 MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024 _ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-" class ArtifactUpdateRequest(BaseModel): content: str expected_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") class ArtifactUpdateResponse(BaseModel): path: str sha256: str size: int @asynccontextmanager async def reserve_artifact_write(request: Request, thread_id: str, *, user_id: str) -> AsyncIterator[None]: """Serialize an artifact edit against runs and other thread mutations.""" run_manager = get_run_manager(request) async with run_manager.reserve_thread_operation( thread_id, kind=ThreadOperationKind.artifact_write, user_id=user_id, ): yield def _normalize_editable_artifact_path(path: str) -> str: # The outputs-only rule is shared with channel attachment delivery: # ``normalize_outputs_virtual_path`` collapses ``..`` before its prefix check # and ``resolve_outputs_confined_path`` re-checks the resolved host path, so # neither an encoded ``..`` nor a symlink planted in ``outputs/`` can # redirect the edit to a sibling ``user-data/`` directory. virtual_path = normalize_outputs_virtual_path(path) if ".skill/" in virtual_path or virtual_path.endswith(".skill"): raise HTTPException(status_code=415, detail="Skill archives cannot be edited in the artifacts panel") return virtual_path def _load_editable_artifact(actual_path: Path, path: str, expected_sha256: str) -> tuple[bytes, os.stat_result]: try: file_stat = os.lstat(actual_path) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") from None if stat.S_ISLNK(file_stat.st_mode): raise HTTPException(status_code=415, detail="Symlinked artifacts cannot be edited") if not stat.S_ISREG(file_stat.st_mode): raise HTTPException(status_code=400, detail=f"Path is not a file: {path}") if file_stat.st_size > MAX_EDITABLE_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail="Artifact is too large to edit") current = actual_path.read_bytes() if len(current) > MAX_EDITABLE_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail="Artifact is too large to edit") if b"\x00" in current: raise HTTPException(status_code=415, detail="Binary artifacts cannot be edited") try: current.decode("utf-8") except UnicodeDecodeError: raise HTTPException(status_code=415, detail="Only UTF-8 text artifacts can be edited") from None current_sha256 = hashlib.sha256(current).hexdigest() if current_sha256 != expected_sha256: raise HTTPException(status_code=412, detail="Artifact changed since it was opened") return current, file_stat def _encode_artifact_update(content: str) -> bytes: encoded = content.encode("utf-8") if len(encoded) > MAX_EDITABLE_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail="Artifact is too large to edit") if b"\x00" in encoded: raise HTTPException(status_code=415, detail="Binary content cannot be saved as an artifact") return encoded def _replace_artifact_atomically(actual_path: Path, content: bytes, file_stat: os.stat_result) -> None: temp_fd, temp_path_str = tempfile.mkstemp(prefix=_ARTIFACT_EDIT_TEMP_PREFIX, dir=actual_path.parent) temp_path = Path(temp_path_str) try: # Preserve ownership where possible and keep replacement permissions # scoped to the owner/group. The shared outputs directory allows a # mounted sandbox to reach the file without making it world-writable. if hasattr(os, "fchown"): try: os.fchown(temp_fd, file_stat.st_uid, file_stat.st_gid) except OSError: logger.debug("Could not preserve artifact ownership: %s", actual_path, exc_info=True) # Windows has no fchmod and uses ACLs rather than POSIX mode bits. # Keep the mkstemp permissions there; retain the existing POSIX # behavior on platforms that expose descriptor-based chmod. if hasattr(os, "fchmod"): os.fchmod(temp_fd, stat.S_IMODE(file_stat.st_mode) | 0o660) with os.fdopen(temp_fd, "wb") as handle: temp_fd = -1 handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temp_path, actual_path) # Invalidate the SHA-256 cache after a successful edit so the next # preview request computes the new digest. Edits are rare, so # clearing the whole 256-entry LRU costs nothing (see PR review). _sha256_of_file_cached.cache_clear() finally: if temp_fd >= 0: os.close(temp_fd) try: temp_path.unlink() except FileNotFoundError: pass def _sync_artifact_to_sandbox(sandbox, virtual_path: str, content: bytes) -> None: sandbox.update_file(virtual_path, content) def _build_content_disposition(disposition_type: str, filename: str) -> str: """Build an RFC 5987 encoded Content-Disposition header value.""" return f"{disposition_type}; filename*=UTF-8''{quote(filename)}" def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | None = None) -> dict[str, str]: headers = {"Content-Disposition": _build_content_disposition("attachment", filename)} if extra_headers: headers.update(extra_headers) return headers def _slice_byte_range(content: bytes, range_header: str | None) -> tuple[bytes, int, dict[str, str]]: """Apply one RFC 9110 byte range to an in-memory archive member.""" size = len(content) headers = {"Accept-Ranges": "bytes"} if range_header is None: return content, 200, headers def unsatisfied() -> HTTPException: return HTTPException( status_code=416, detail="Requested range is not satisfiable", headers={"Accept-Ranges": "bytes", "Content-Range": f"bytes */{size}"}, ) if not range_header.startswith("bytes=") or "," in range_header: raise unsatisfied() range_spec = range_header.removeprefix("bytes=") if "-" not in range_spec: raise unsatisfied() start_text, end_text = range_spec.split("-", 1) try: if start_text: start = int(start_text) end = size - 1 if not end_text else min(int(end_text), size - 1) else: suffix_length = int(end_text) if suffix_length <= 0: raise unsatisfied() start = max(size - suffix_length, 0) end = size - 1 except ValueError as exc: raise unsatisfied() from exc if size == 0 or start < 0 or start >= size or end < start: raise unsatisfied() ranged_content = content[start : end + 1] headers.update( { "Content-Range": f"bytes {start}-{end}/{size}", "Content-Length": str(len(ranged_content)), } ) return ranged_content, 206, headers def _is_active_content_mime_type(mime_type: str | None) -> bool: """Return whether a browser can run script when rendering *mime_type* inline. Beyond HTML, this covers every WHATWG XML MIME type (``text/xml``, ``application/xml``, or a ``+xml`` subtype) plus ``text/xsl``, which Blink also renders as XML: any XML document can carry an XHTML-namespaced ``