Newer
Older
gnexus-tasks / backend / app / api / attachments.py
"""API вложений: изображения документов (описания задач, заметки проектов),
в том числе вставка из буфера обмена.

URL скачивания файла (`/api/attachments/{id}/file`) неизменен — он зашит в
уже сохранённый markdown-текст документов.
"""

import uuid
from pathlib import Path
from typing import Any, cast

from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select

from app.config import get_settings
from app.dependencies import DbDep, UserDep
from app.models import Attachment, Document
from app.schemas import AttachmentOut

router = APIRouter(prefix="/api", tags=["attachments"])

ALLOWED_PREFIXES = ("image/",)
# SVG не принимаем вовсе: это скриптуемый формат, его inline-отдача в origin
# приложения = stored XSS (кука сессии уходит на скрипт). Растровые форматы
# (png/jpeg/gif/webp) скриптовать нельзя. Лимит размера — без него загрузка
# льёт файлы на диск бесконтрольно.
FORBIDDEN_MIMES = ("image/svg+xml",)
MAX_FILE_BYTES = 10 * 1024 * 1024


def _attachments_dir() -> Path:
    path = Path(get_settings().attachments_path)
    path.mkdir(parents=True, exist_ok=True)
    return path


def _get_document_or_404(db: Any, document_id: int) -> Document:
    doc = cast(Document | None, db.get(Document, document_id))
    if doc is None:
        raise HTTPException(status_code=404, detail="Document not found")
    return doc


def _get_attachment_or_404(db: Any, attachment_id: int) -> Attachment:
    att = cast(Attachment | None, db.get(Attachment, attachment_id))
    if att is None:
        raise HTTPException(status_code=404, detail="Attachment not found")
    return att


@router.post("/documents/{document_id}/attachments", response_model=list[AttachmentOut])
async def upload_attachments(
    document_id: int, db: DbDep, user: UserDep, files: list[UploadFile]
) -> list[Attachment]:
    """Загрузка файлов (в т.ч. Ctrl+V из буфера — приходит как обычный файл)."""
    document = _get_document_or_404(db, document_id)
    saved: list[Attachment] = []
    for file in files:
        mime = file.content_type or "application/octet-stream"
        if not mime.startswith(ALLOWED_PREFIXES) or mime in FORBIDDEN_MIMES:
            raise HTTPException(status_code=415, detail=f"Unsupported type: {mime}")
        ext = Path(file.filename or "image").suffix or ".bin"
        stored_name = f"{uuid.uuid4().hex}{ext}"
        target = _attachments_dir() / stored_name
        size = 0
        too_large = False
        with target.open("wb") as out:
            # copyfileobj не умеет лимит (третий аргумент — размер буфера):
            # копируем кусками и следим за объёмом сами
            while chunk := file.file.read(1024 * 1024):
                size += len(chunk)
                if size > MAX_FILE_BYTES:
                    too_large = True
                    break
                out.write(chunk)
        if too_large:
            target.unlink()
            raise HTTPException(status_code=413, detail="File too large")
        att = Attachment(
            document_id=document.id,
            filename=stored_name,
            original_name=(file.filename or "image")[:255],
            mime=mime,
            size=target.stat().st_size,
        )
        db.add(att)
        saved.append(att)
    db.flush()
    return saved


@router.get("/documents/{document_id}/attachments", response_model=list[AttachmentOut])
async def list_attachments(document_id: int, db: DbDep, user: UserDep) -> list[Attachment]:
    document = _get_document_or_404(db, document_id)
    return list(
        db.scalars(
            select(Attachment).where(Attachment.document_id == document.id).order_by(Attachment.id)
        ).all()
    )


@router.get("/attachments/{attachment_id}/file")
async def get_attachment_file(attachment_id: int, db: DbDep, user: UserDep) -> FileResponse:
    att = _get_attachment_or_404(db, attachment_id)
    path = _attachments_dir() / att.filename
    if not path.is_file():
        raise HTTPException(status_code=404, detail="File missing on disk")
    # nosniff: браузер не должен угадывать тип иначе, чем записанный mime
    return FileResponse(path, media_type=att.mime, headers={"X-Content-Type-Options": "nosniff"})


@router.delete("/attachments/{attachment_id}")
async def delete_attachment(attachment_id: int, db: DbDep, user: UserDep) -> dict[str, bool]:
    att = _get_attachment_or_404(db, attachment_id)
    path = _attachments_dir() / att.filename
    if path.is_file():
        path.unlink()
    db.delete(att)
    return {"ok": True}