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)
|
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
|
||||||
GET /tests/{test_id}/evidence — list evidences (filterable by team)
|
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)
|
DELETE /evidence/{id} — delete evidence (only in editable states)
|
||||||
|
|
||||||
Access Control
|
Access Control
|
||||||
@@ -20,12 +21,14 @@ Access Control
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -46,7 +49,9 @@ from app.services.evidence_service import (
|
|||||||
validate_upload_permission,
|
validate_upload_permission,
|
||||||
)
|
)
|
||||||
from app.limiter import limiter
|
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"])
|
router = APIRouter(tags=["evidence"])
|
||||||
|
|
||||||
@@ -56,7 +61,11 @@ router = APIRouter(tags=["evidence"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
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(
|
return EvidenceOut(
|
||||||
id=evidence.id,
|
id=evidence.id,
|
||||||
test_id=evidence.test_id,
|
test_id=evidence.test_id,
|
||||||
@@ -66,7 +75,7 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
|||||||
uploaded_at=evidence.uploaded_at,
|
uploaded_at=evidence.uploaded_at,
|
||||||
team=evidence.team,
|
team=evidence.team,
|
||||||
notes=evidence.notes,
|
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)
|
issue_key = get_test_jira_key(db, test_id)
|
||||||
if not issue_key:
|
if not issue_key:
|
||||||
return
|
return
|
||||||
|
import io
|
||||||
jira = get_user_jira_client(actor, db)
|
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
|
import logging
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key
|
"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),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
evidence = get_evidence_or_raise(db, evidence_id)
|
||||||
return _evidence_to_out(evidence)
|
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)
|
# DELETE /evidence/{id} — delete evidence (editable states only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ def upload_file(content: bytes, key: str) -> str:
|
|||||||
return key
|
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:
|
def get_presigned_url(key: str, expiration: int = 3600) -> str:
|
||||||
"""Return a presigned GET URL for *key* valid for *expiration* seconds.
|
"""Return a presigned GET URL for *key* valid for *expiration* seconds.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user