From 1dbf542104a2ee259d548cddbf0fbc0c8c19b4be Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Fri, 24 Apr 2026 15:38:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(internal):=20download=20endpoint=20per=20P?= =?UTF-8?q?DF=20allegati=20=E2=80=94=20istruttore=20+=20response=20benef?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Risposta a richiesta Rinaldo (team BE bflows-bandi-be) per integrazione S3 folder pratica unico. Il BE scaricherà i PDF binary via nostri endpoint e li archivierà su S3 nel folder {practice_id}/amendments/{id}/. ==ENDPOINT NUOVI (2, simmetrici)== GET /internal/remission-amendments/{id}/document → PDF istruttore (amendment_document_path) binary stream GET /internal/remission-amendments/{id}/response-document → PDF benef risposta (response_document_path) binary stream Auth: X-Internal-Secret (riusa _check_internal_auth come gli altri /internal). Risposta: application/pdf con Content-Disposition attachment + filename originale (estratto dal pattern {sha256}-{nome} del path fisico). ==IMPLEMENTAZIONE== - Nuovo helper _resolve_amendment_file(amendment_id, db, kind) che: 1. Carica l'amendment (404 se non esiste) 2. Seleziona il path in base al kind ('instructor' | 'response') 3. 404 se il campo è NULL (es. benef non ha ancora risposto) 4. Hardening path traversal: abs_path.resolve().relative_to(BASE_PATH) 5. 404 se file non presente su filesystem 6. Estrae safe_name dal pattern {sha}-{nome.ext} - FileResponse streaming, media_type da amendment.*_document_type - Import BASE_PATH + StorageError da ..storage ==TEST (8 step, /tmp/td2.py, tutti verdi)== 1. crea amendment DRAFT 2. upload PDF istruttore → HTTP 200, 526 bytes 3. GET /document → 200, byte-exact, ct=application/pdf, filename preservato 4. GET senza X-Internal-Secret → 401 5. GET amendment inesistente → 404 6. GET /response-document prima che benef allegi → 404 7. benef upload response_document 8. GET /response-document → 200, byte-exact, filename preservato ==RISPOSTA RINALDO== - amendment_document_path NON è S3 → path FS relativo a /var/uploads - 1 solo file per ruolo (istruttore + benef): due campi distinti in DB - Download via questi 2 endpoint simmetrici con shared secret - Pull-on-upload dal poller BE: dopo mark-pec-sent scarica PDF e lo archivia S3 folder pratica; simmetrico per response_document quando il benef risponde ==NOTA MIGRAZIONE FUTURA== app/storage.py aveva già nota 'Migrazione futura a S3/MinIO: cambiare solo questa classe'. Questi endpoint download restano validi anche dopo migrazione S3 lato microservizio (cambia solo impl interna di _resolve_amendment_file). Breakdown effort a Rinaldo si aggiorna: opzione A = volume condiviso, o opzione B = questi endpoint (ora implementati, disponibili subito). --- app/routers/internal.py | 80 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/app/routers/internal.py b/app/routers/internal.py index baf5757..76f54e2 100644 --- a/app/routers/internal.py +++ b/app/routers/internal.py @@ -16,9 +16,12 @@ from uuid import UUID from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Header, Query +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_, text +from ..storage import BASE_PATH, StorageError + from ..db import get_db from ..config import get_settings, Settings from ..models import RemissionAmendmentRequest, RemissionPractice @@ -185,3 +188,80 @@ def mark_pec_failed( db.commit() return ApiResponse(message="PEC marcata come fallita", data={"id": str(amendment_id), "reason": body.reason[:200]}) + + +def _resolve_amendment_file(amendment_id: UUID, db: Session, + kind: str) -> tuple: + """Risolve il path fisico di un allegato amendment. + + kind: 'instructor' (amendment_document_path) | 'response' (response_document_path). + Ritorna tuple (abs_path, mime, safe_filename_hint). + Solleva HTTPException(404) se amendment non esiste o non ha il file. + Hardening: path deve restare dentro BASE_PATH (no traversal). + """ + ar = db.query(RemissionAmendmentRequest).filter( + RemissionAmendmentRequest.id == amendment_id + ).first() + if not ar: + raise HTTPException(status_code=404, detail="Amendment non trovata") + + if kind == "instructor": + rel_path, mime = ar.amendment_document_path, ar.amendment_document_type + elif kind == "response": + rel_path, mime = ar.response_document_path, ar.response_document_type + else: + raise HTTPException(status_code=422, detail=f"kind invalido: {kind}") + + if not rel_path: + raise HTTPException(status_code=404, + detail=f"Nessun documento {kind} per amendment {amendment_id}") + + abs_path = BASE_PATH / rel_path + try: + abs_path.resolve().relative_to(BASE_PATH.resolve()) + except ValueError: + raise HTTPException(status_code=500, detail="Path non valido (hardening)") + + if not abs_path.is_file(): + raise HTTPException(status_code=404, + detail=f"File non presente su storage: {rel_path}") + + # filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext}) + base = abs_path.name + if "-" in base: + safe_name = base.split("-", 1)[1] + else: + safe_name = base + return abs_path, (mime or "application/pdf"), safe_name + + +@router.get("/{amendment_id}/document") +def get_amendment_document( + amendment_id: UUID, + db: Session = Depends(get_db), + _: bool = Depends(_check_internal_auth), +): + """Scarica il PDF allegato dall'istruttore al soccorso (binary stream). + Usato dal poller BE per archiviare su S3 nel folder pratica. + """ + abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor") + return FileResponse( + path=str(abs_path), media_type=mime, + headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, + ) + + +@router.get("/{amendment_id}/response-document") +def get_response_document( + amendment_id: UUID, + db: Session = Depends(get_db), + _: bool = Depends(_check_internal_auth), +): + """Scarica il PDF allegato dal beneficiario come risposta (binary stream). + Usato dal poller BE per archiviare su S3 nel folder pratica. + """ + abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response") + return FileResponse( + path=str(abs_path), media_type=mime, + headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, + )