"""Top-level evidence routes (download + soft-delete by id). Upload is collocated under `/missions/{id}/tests/{test_id}/evidence` because that path encodes the parent context. Once an evidence row exists, callers can address it by id directly — these routes own that side. Membership/visibility is enforced through the service (`EvidenceNotFound` is returned for both "missing" and "not visible" outcomes — no existence leak). """ from __future__ import annotations import logging import uuid from typing import Any from flask import Blueprint, abort, g, jsonify, request, send_file from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm from app.services import evidence as svc bp = Blueprint("evidence", __name__, url_prefix="/evidence") log = logging.getLogger("metamorph.api.evidence") def _serialize(ev: svc.EvidenceView) -> dict[str, Any]: return { "id": str(ev.id), "mission_test_id": str(ev.mission_test_id), "sha256": ev.sha256, "mime": ev.mime, "size_bytes": ev.size_bytes, "original_filename": ev.original_filename, "uploaded_by_user_id": ( str(ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None ), "uploaded_by_email": ev.uploaded_by_email, "uploaded_by_display_name": ev.uploaded_by_display_name, "uploaded_at": ev.uploaded_at.isoformat(), "created_at": ev.created_at.isoformat(), } def _current_user() -> AuthenticatedUser: user: AuthenticatedUser | None = getattr(g, "current_user", None) if user is None: abort(401, description="not authenticated") assert user is not None # for Pyright; abort raises HTTPException return user def _parse_uuid_or_400(raw: str) -> uuid.UUID | None: try: return uuid.UUID(raw) except ValueError: return None @bp.get("/") @require_auth @require_perm("mission.read") def get_evidence(evidence_id: str): """Metadata read. Use `?download=true` to receive the bytes inline. The download mode streams the on-disk file via `send_file` with the original filename in `Content-Disposition`. Browsers handle the Content-Type guess from the stored mime. """ eid = _parse_uuid_or_400(evidence_id) if eid is None: return jsonify({"error": "invalid_id"}), 400 user = _current_user() want_download = request.args.get("download", "false").lower() == "true" if want_download: try: view, path = svc.get_evidence_for_download( eid, viewer_id=user.id, viewer_is_admin=user.is_admin ) except svc.EvidenceNotFound: return jsonify({"error": "not_found"}), 404 log.info( "metamorph.evidence.download", extra={ "evidence_id": str(eid), "user_id": str(user.id), "size_bytes": view.size_bytes, }, ) return send_file( str(path), mimetype=view.mime, as_attachment=True, download_name=view.original_filename, etag=view.sha256, conditional=True, max_age=0, ) try: view = svc.get_evidence( eid, viewer_id=user.id, viewer_is_admin=user.is_admin ) except svc.EvidenceNotFound: return jsonify({"error": "not_found"}), 404 return jsonify(_serialize(view)) @bp.delete("/") @require_auth @require_perm("mission.write_blue_fields") def soft_delete_evidence(evidence_id: str): eid = _parse_uuid_or_400(evidence_id) if eid is None: return jsonify({"error": "invalid_id"}), 400 user = _current_user() try: svc.soft_delete_evidence( eid, viewer_id=user.id, viewer_is_admin=user.is_admin ) except svc.EvidenceNotFound: return jsonify({"error": "not_found"}), 404 return jsonify({"ok": True})