"""Uploads, downloads (signed or authenticated), manual Python re-run.""" from __future__ import annotations from fastapi import ( APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status, ) from fastapi.responses import Response from pydantic import BaseModel, Field from app.core.config import Settings, get_settings from app.core.ratelimit import MSG_UPLOAD, limiter from app.core.security import AuthUser, get_current_user, get_optional_user, hash_user_id from app.services import files as svc from app.tools import execute_python from app.tools.registry import ToolContext router = APIRouter(tags=["files"]) @router.post("/files", status_code=201) async def upload(file: UploadFile = File(...), conversation_id: str | None = Form(None), auth: AuthUser = Depends(get_current_user), settings: Settings = Depends(get_settings)) -> dict: limiter.check(f"upload:{auth.id}", settings.RATE_UPLOADS_PER_DAY, 86400, MSG_UPLOAD) data = await file.read() try: rec = await svc.store_upload(auth.id, conversation_id, file.filename or "fichier", data) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return {"file_id": rec.id, "filename": rec.filename, "type": rec.type, "size": rec.size_bytes, "url": f"/api/v1/files/{rec.id}"} @router.get("/files/{file_id}") async def download(file_id: str, request: Request, sig: str | None = Query(None), download: bool = False, auth: AuthUser | None = Depends(get_optional_user)) -> Response: rec = await svc.get_file(file_id) if not rec: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.") allowed = (auth is not None and (auth.id == rec.user_id or auth.is_professor)) or \ (sig is not None and svc.verify_signature(file_id, sig)) if not allowed: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Accès refusé.") try: data = svc.read_bytes(rec) except FileNotFoundError as exc: raise HTTPException(status.HTTP_410_GONE, detail="Fichier expiré.") from exc disp = "attachment" if download or rec.type in {"xlsx", "file", "csv", "docx"} else "inline" from urllib.parse import quote headers = {"Content-Disposition": f"{disp}; filename*=UTF-8''{quote(rec.filename)}", "Cache-Control": "private, max-age=600"} return Response(content=data, media_type=rec.mime, headers=headers) @router.get("/files/{file_id}/link") async def signed_link(file_id: str, auth: AuthUser = Depends(get_current_user)) -> dict: rec = await svc.get_file(file_id) if not rec or (rec.user_id != auth.id and not auth.is_professor): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.") return {"url": f"/api/v1/files/{file_id}?sig={svc.sign(file_id)}&download=1", "expires_in": 600} class PinReq(BaseModel): pinned: bool = True @router.post("/files/{file_id}/pin") async def pin(file_id: str, req: PinReq, auth: AuthUser = Depends(get_current_user)) -> dict: if not await svc.set_pinned(file_id, auth.id, req.pinned): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.") return {"ok": True} @router.get("/conversations/{conv_id}/files") async def conversation_files(conv_id: str, auth: AuthUser = Depends(get_current_user)) -> list[dict]: rows = await svc.list_for_conversation(conv_id) return [{"file_id": r.id, "filename": r.filename, "type": r.type, "kind": r.kind, "size": r.size_bytes, "created_at": r.created_at.isoformat(), "pinned": r.pinned, "url": f"/api/v1/files/{r.id}"} for r in rows if r.user_id == auth.id] class RunReq(BaseModel): code: str = Field(..., max_length=100_000) conversation_id: str | None = None @router.post("/tools/python/run") async def run_python(req: RunReq, auth: AuthUser = Depends(get_current_user), settings: Settings = Depends(get_settings)) -> dict: """Manual re-run of a code block from the UI (same sandbox, same limits).""" file_ids: list[str] = [] if req.conversation_id: recs = await svc.list_for_conversation(req.conversation_id) file_ids = [r.id for r in recs if r.kind == "upload" and r.user_id == auth.id] ctx = ToolContext(user_id=auth.id, user_id_hash=hash_user_id(auth.id), conversation_id=req.conversation_id or "", course_code="", settings=settings, role=auth.role, file_ids=file_ids) result = await execute_python.run({"code": req.code, "description": "Ré-exécution", "heavy": False}, ctx) if result.error and not result.payload: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.content) return result.payload