- Add ia_dev submodule (projects/smart_ide on forge 4nk) - Document APIs, orchestrator, gateway, local-office, rollout - Add systemd/scripts layout; relocate setup scripts - Remove obsolete nginx/enso-only docs from this repo scope
157 lines
5.1 KiB
Python
157 lines
5.1 KiB
Python
"""Document routes: upload, get file, list, delete, commands. Auth and rate limit on each."""
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.api.schemas import CommandsRequest, commands_to_dicts
|
|
from app.auth import require_api_key
|
|
from app.config import get_max_upload_bytes
|
|
from app.engine.commands import apply_commands
|
|
from app.limiter import limiter, rate_limit_string
|
|
from app.storage import file_storage
|
|
from app.storage.file_storage import delete_document_file, read_document, write_document
|
|
from app.storage.metadata import (
|
|
delete_document_metadata,
|
|
generate_document_id,
|
|
get_document,
|
|
insert_document,
|
|
list_documents,
|
|
update_document_size,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
rate = rate_limit_string()
|
|
|
|
ALLOWED_MIME = {
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
}
|
|
|
|
|
|
def _check_owner(meta: dict | None, api_key_id: str, document_id: str) -> None:
|
|
if meta is None:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
if meta.get("api_key_id") != api_key_id:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
|
|
@router.post("", status_code=201, response_model=dict)
|
|
@limiter.limit(rate)
|
|
async def upload_document(
|
|
request: Request,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
file: Annotated[UploadFile, File()],
|
|
) -> dict:
|
|
"""Upload an Office file. Returns document_id."""
|
|
content = await file.read()
|
|
if len(content) > get_max_upload_bytes():
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail="File too large",
|
|
)
|
|
mime = (file.content_type or "").strip().split(";")[0]
|
|
if mime not in ALLOWED_MIME:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unsupported file type. Allowed: {sorted(ALLOWED_MIME)}",
|
|
)
|
|
name = file.filename or "document"
|
|
document_id = generate_document_id()
|
|
write_document(document_id, content)
|
|
insert_document(
|
|
document_id=document_id,
|
|
api_key_id=api_key_id,
|
|
name=name,
|
|
mime_type=mime,
|
|
size=len(content),
|
|
)
|
|
logger.info("Uploaded document %s for key %s", document_id, api_key_id)
|
|
return {"document_id": document_id, "name": name, "mime_type": mime, "size": len(content)}
|
|
|
|
|
|
@router.get("")
|
|
@limiter.limit(rate)
|
|
async def list_docs(
|
|
request: Request,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
) -> list:
|
|
"""List documents for the authenticated API key."""
|
|
return list_documents(api_key_id)
|
|
|
|
|
|
@router.get("/{document_id}")
|
|
@limiter.limit(rate)
|
|
async def get_metadata(
|
|
request: Request,
|
|
document_id: str,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
) -> dict:
|
|
"""Get document metadata."""
|
|
meta = get_document(document_id)
|
|
_check_owner(meta, api_key_id, document_id)
|
|
return meta
|
|
|
|
|
|
@router.get("/{document_id}/file")
|
|
@limiter.limit(rate)
|
|
async def download_file(
|
|
request: Request,
|
|
document_id: str,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
):
|
|
"""Download document file."""
|
|
meta = get_document(document_id)
|
|
_check_owner(meta, api_key_id, document_id)
|
|
path = file_storage.get_document_path(document_id)
|
|
if not path.is_file():
|
|
raise HTTPException(status_code=404, detail="Document file not found")
|
|
return FileResponse(
|
|
path,
|
|
media_type=meta.get("mime_type", "application/octet-stream"),
|
|
filename=meta.get("name", "document"),
|
|
)
|
|
|
|
|
|
@router.post("/{document_id}/commands")
|
|
@limiter.limit(rate)
|
|
async def apply_document_commands(
|
|
request: Request,
|
|
document_id: str,
|
|
body: CommandsRequest,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
) -> dict:
|
|
"""Apply commands to document. Supported for docx: replaceText, insertParagraph."""
|
|
meta = get_document(document_id)
|
|
_check_owner(meta, api_key_id, document_id)
|
|
content = read_document(document_id)
|
|
mime = meta.get("mime_type", "")
|
|
try:
|
|
new_content = apply_commands(content, mime, commands_to_dicts(body.commands))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
write_document(document_id, new_content)
|
|
update_document_size(document_id, len(new_content))
|
|
logger.info("Applied %d commands to document %s", len(body.commands), document_id)
|
|
return {"document_id": document_id, "size": len(new_content)}
|
|
|
|
|
|
@router.delete("/{document_id}", status_code=204)
|
|
@limiter.limit(rate)
|
|
async def delete_doc(
|
|
request: Request,
|
|
document_id: str,
|
|
api_key_id: Annotated[str, Depends(require_api_key)],
|
|
) -> None:
|
|
"""Delete document and its file."""
|
|
meta = get_document(document_id)
|
|
_check_owner(meta, api_key_id, document_id)
|
|
deleted = delete_document_metadata(document_id)
|
|
delete_document_file(document_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|