From 7111debd8fac9602806688589d88320991c634fe Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 11:26:01 +0200 Subject: [PATCH] fix(evidence): proxy download + fix Jira attachment signature Evidence download: - Replace presigned MinIO URLs with backend proxy endpoint GET /api/v1/evidence/{id}/file streams the file through the backend so MinIO never needs to be publicly accessible from browsers - Add download_file() helper to storage.py (internal boto3 get_object) - download_url in EvidenceOut now points to the proxy endpoint Jira attachment: - Fix add_attachment call: use add_attachment_object(issue_key, BytesIO) instead of add_attachment(issue_key, filename=..., content=...) which had wrong keyword args for the installed atlassian-python-api version Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/evidence.py | 63 +++++++++++++++++++++++++++++---- backend/app/storage.py | 6 ++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py index b000092..57ac4c1 100644 --- a/backend/app/routers/evidence.py +++ b/backend/app/routers/evidence.py @@ -4,7 +4,8 @@ Endpoints --------- POST /tests/{test_id}/evidence — upload evidence (with team=red/blue) GET /tests/{test_id}/evidence — list evidences (filterable by team) -GET /evidence/{id} — presigned download URL +GET /evidence/{id} — metadata + download_url +GET /evidence/{id}/file — proxy download (streams file through backend) DELETE /evidence/{id} — delete evidence (only in editable states) Access Control @@ -20,12 +21,14 @@ Access Control """ import hashlib +import logging import os import uuid as _uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from app.database import get_db @@ -46,7 +49,9 @@ from app.services.evidence_service import ( validate_upload_permission, ) from app.limiter import limiter -from app.storage import get_presigned_url, upload_file +from app.storage import download_file, upload_file + +logger = logging.getLogger(__name__) router = APIRouter(tags=["evidence"]) @@ -56,7 +61,11 @@ router = APIRouter(tags=["evidence"]) # --------------------------------------------------------------------------- def _evidence_to_out(evidence: Evidence) -> EvidenceOut: - """Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL.""" + """Convert an ORM ``Evidence`` to the API schema. + + ``download_url`` points to the backend proxy endpoint so the browser + never needs direct access to MinIO. + """ return EvidenceOut( id=evidence.id, test_id=evidence.test_id, @@ -66,7 +75,7 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut: uploaded_at=evidence.uploaded_at, team=evidence.team, notes=evidence.notes, - download_url=get_presigned_url(evidence.file_path), + download_url=f"/api/v1/evidence/{evidence.id}/file", ) @@ -163,8 +172,11 @@ def _attach_evidence_to_jira( issue_key = get_test_jira_key(db, test_id) if not issue_key: return + import io jira = get_user_jira_client(actor, db) - jira.add_attachment(issue_key, filename=file_name, content=content) + buf = io.BytesIO(content) + buf.name = file_name # requests uses .name as the multipart filename + jira.add_attachment_object(issue_key, buf) import logging logging.getLogger(__name__).info( "Attached evidence '%s' to Jira ticket %s", file_name, issue_key @@ -195,7 +207,7 @@ def list_evidence( # --------------------------------------------------------------------------- -# GET /evidence/{id} — presigned download URL +# GET /evidence/{id} — metadata + proxy download URL # --------------------------------------------------------------------------- @@ -205,11 +217,48 @@ def get_evidence( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Return evidence metadata together with a presigned download URL.""" + """Return evidence metadata. ``download_url`` is a backend proxy URL.""" evidence = get_evidence_or_raise(db, evidence_id) return _evidence_to_out(evidence) +# --------------------------------------------------------------------------- +# GET /evidence/{id}/file — proxy download (streams file via backend) +# --------------------------------------------------------------------------- + + +@router.get("/evidence/{evidence_id}/file") +def download_evidence_file( + evidence_id: _uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Stream the evidence file through the backend. + + The browser calls this endpoint (authenticated via JWT cookie/header). + The backend fetches the file from MinIO internally and streams it back, + so MinIO never needs to be publicly accessible. + """ + import mimetypes + + evidence = get_evidence_or_raise(db, evidence_id) + content = download_file(evidence.file_path) + + mime_type, _ = mimetypes.guess_type(evidence.file_name) + if not mime_type: + mime_type = "application/octet-stream" + + safe_name = evidence.file_name.replace('"', '\\"') + return StreamingResponse( + iter([content]), + media_type=mime_type, + headers={ + "Content-Disposition": f'inline; filename="{safe_name}"', + "Content-Length": str(len(content)), + }, + ) + + # --------------------------------------------------------------------------- # DELETE /evidence/{id} — delete evidence (editable states only) # --------------------------------------------------------------------------- diff --git a/backend/app/storage.py b/backend/app/storage.py index 4d085c6..3fa80ae 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -70,6 +70,12 @@ def upload_file(content: bytes, key: str) -> str: return key +def download_file(key: str) -> bytes: + """Download *key* from the evidence bucket and return its raw bytes.""" + response = _client.get_object(Bucket=settings.MINIO_BUCKET, Key=key) + return response["Body"].read() + + def get_presigned_url(key: str, expiration: int = 3600) -> str: """Return a presigned GET URL for *key* valid for *expiration* seconds.