Files
OpenOPC/opc/core/attachment_store.py
T
2026-07-01 17:56:31 +08:00

258 lines
8.3 KiB
Python

"""Attachment storage layer — disk-based file storage with lightweight references.
Files are stored on disk under `{opc_home}/projects/{project_id}/attachments/{id}/`.
Only lightweight AttachmentRef objects (no binary content) flow through the engine
pipeline, databases, and WebSocket messages. Base64 encoding is performed lazily
at the two endpoints: ingestion (decode → disk) and LLM call (disk → encode).
"""
from __future__ import annotations
import base64
import mimetypes
import os
import shutil
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
ALLOWED_MIME_PREFIXES = (
"image/",
"text/",
"application/pdf",
"application/json",
"application/x-yaml",
"application/yaml",
)
ALLOWED_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff",
".txt", ".md", ".pdf", ".csv", ".json", ".yaml", ".yml",
".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css",
".java", ".c", ".cpp", ".h", ".go", ".rs", ".rb", ".sh",
".xml", ".toml", ".ini", ".cfg", ".log",
".docx", ".xlsx", ".pptx",
".mp4", ".mpeg", ".mpg", ".mov", ".webm",
}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB per file
MAX_TOTAL_SIZE = 20 * 1024 * 1024 # 20 MB per message
ALLOWED_MIME_TYPES = {
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/webm",
}
_MIME_EXTENSION_OVERRIDES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/markdown": ".md",
"application/json": ".json",
"application/x-yaml": ".yaml",
"application/yaml": ".yaml",
"video/mp4": ".mp4",
"video/mpeg": ".mpeg",
"video/quicktime": ".mov",
"video/webm": ".webm",
}
@dataclass
class AttachmentRef:
"""Lightweight reference to a stored attachment — safe for JSON / metadata."""
attachment_id: str
filename: str
mime_type: str
size_bytes: int
disk_path: str # relative to opc_home
@property
def is_image(self) -> bool:
return self.mime_type.startswith("image/")
def to_dict(self) -> dict[str, Any]:
return {
"attachment_id": self.attachment_id,
"filename": self.filename,
"mime_type": self.mime_type,
"size_bytes": self.size_bytes,
"disk_path": self.disk_path,
}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> AttachmentRef:
return cls(
attachment_id=d["attachment_id"],
filename=d["filename"],
mime_type=d.get("mime_type", "application/octet-stream"),
size_bytes=d.get("size_bytes", 0),
disk_path=d.get("disk_path", ""),
)
def _sanitize_filename(name: str) -> str:
"""Strip path components and dangerous characters from a filename."""
name = os.path.basename(name)
name = name.replace("..", "").replace("/", "").replace("\\", "")
return name or "upload"
def _check_mime(filename: str, mime: str) -> bool:
ext = os.path.splitext(filename)[1].lower()
if ext in ALLOWED_EXTENSIONS:
return True
if mime in ALLOWED_MIME_TYPES:
return True
for prefix in ALLOWED_MIME_PREFIXES:
if mime.startswith(prefix):
return True
return False
def _normalize_mime(mime_type: str | None) -> str:
return str(mime_type or "").strip().lower()
def _split_data_url(payload: str) -> tuple[str, str | None]:
raw = str(payload or "").strip()
if not raw.startswith("data:"):
return raw, None
header, sep, encoded = raw.partition(",")
if not sep:
return raw, None
mime = header[5:].split(";", 1)[0].strip().lower()
return encoded, mime or None
def _extension_for_mime(mime_type: str) -> str:
if not mime_type:
return ""
override = _MIME_EXTENSION_OVERRIDES.get(mime_type)
if override:
return override
guessed = mimetypes.guess_extension(mime_type, strict=False) or ""
return guessed.lower()
def _ensure_filename_extension(filename: str, mime_type: str) -> str:
if os.path.splitext(filename)[1]:
return filename
extension = _extension_for_mime(mime_type)
if extension:
return f"{filename}{extension}"
return filename
class AttachmentStore:
"""Manages attachment lifecycle on disk."""
def __init__(self, opc_home: Path, project_id: str) -> None:
self.opc_home = opc_home
self.project_id = project_id
self.base_dir = opc_home / "projects" / project_id / "attachments"
def _ensure_dir(self, attachment_id: str) -> Path:
d = self.base_dir / attachment_id
d.mkdir(parents=True, exist_ok=True)
return d
async def save_from_base64(
self,
filename: str,
b64_data: str,
mime_type: str | None = None,
) -> AttachmentRef:
"""Decode base64 data and persist to disk. Returns a lightweight ref."""
filename = _sanitize_filename(filename)
b64_payload, inferred_mime = _split_data_url(b64_data)
mime = _normalize_mime(mime_type) or inferred_mime or ""
if not mime:
guessed_mime, _ = mimetypes.guess_type(filename)
mime = guessed_mime or "application/octet-stream"
filename = _ensure_filename_extension(filename, mime)
if mime == "application/octet-stream":
guessed_mime, _ = mimetypes.guess_type(filename)
mime = guessed_mime or mime
try:
raw = base64.b64decode(b64_payload)
except Exception as exc:
raise ValueError(f"Invalid base64 data for {filename}: {exc}") from exc
size = len(raw)
if size > MAX_FILE_SIZE:
raise ValueError(f"File too large: {size} bytes (limit {MAX_FILE_SIZE})")
if not _check_mime(filename, mime):
raise ValueError(f"Unsupported file type: {filename} ({mime})")
aid = uuid.uuid4().hex[:16]
dest_dir = self._ensure_dir(aid)
dest = dest_dir / filename
dest.write_bytes(raw)
rel = dest.relative_to(self.opc_home)
return AttachmentRef(
attachment_id=aid,
filename=filename,
mime_type=mime,
size_bytes=size,
disk_path=str(rel),
)
async def save_from_path(self, file_path: Path) -> AttachmentRef:
"""Copy a local file into the attachment store. Used by CLI."""
file_path = file_path.expanduser().resolve()
if not file_path.is_file():
raise FileNotFoundError(f"File not found: {file_path}")
size = file_path.stat().st_size
if size > MAX_FILE_SIZE:
raise ValueError(f"File too large: {size} bytes (limit {MAX_FILE_SIZE})")
filename = _sanitize_filename(file_path.name)
mime, _ = mimetypes.guess_type(filename)
mime = mime or "application/octet-stream"
if not _check_mime(filename, mime):
raise ValueError(f"Unsupported file type: {filename} ({mime})")
aid = uuid.uuid4().hex[:16]
dest_dir = self._ensure_dir(aid)
dest = dest_dir / filename
shutil.copy2(str(file_path), str(dest))
rel = dest.relative_to(self.opc_home)
return AttachmentRef(
attachment_id=aid,
filename=filename,
mime_type=mime,
size_bytes=size,
disk_path=str(rel),
)
def resolve_abs_path(self, ref: AttachmentRef) -> Path:
"""Return the absolute path on disk for reading."""
resolved = (self.opc_home / ref.disk_path).resolve()
if not str(resolved).startswith(str(self.base_dir.resolve())):
raise ValueError(f"Path traversal detected: {ref.disk_path}")
return resolved
def read_bytes(self, ref: AttachmentRef) -> bytes:
"""Read raw file content from disk."""
return self.resolve_abs_path(ref).read_bytes()
def read_base64(self, ref: AttachmentRef) -> str:
"""Read file and return as base64 string — called only at LLM call time."""
return base64.b64encode(self.read_bytes(ref)).decode("ascii")
def resolve_http_path(self, ref: AttachmentRef) -> str:
"""Return the HTTP-accessible path for frontend display."""
return f"/api/attachments/{ref.attachment_id}/{ref.filename}"