fix(evidence): proxy download + fix Jira attachment signature
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user