124 lines
3.9 KiB
Python
124 lines
3.9 KiB
Python
|
|
"""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("/<evidence_id>")
|
||
|
|
@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("/<evidence_id>")
|
||
|
|
@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})
|