upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a service-account key file. The deployed VM runs on Application Default Credentials with no key file, so every upload silently took the fallback branch and returned a bare gs:// URI. That broke two things at once: * Browsers cannot fetch a gs:// URI, so no recording was ever playable. * _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and returned None for it, so `if gcs_uri:` in the upload path was always false and transcription never ran. Nothing was logged, which is why this looked like an OpenAI credits problem rather than a storage one. The fallback also interpolated the client-supplied filename instead of the call_id-derived safe name, so the URI did not even name the object written. Calls now store only the canonical gs:// location. A short-lived playback link is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and audio is served from the private bucket by the new /media route. An <audio src> cannot carry an Authorization header, so the link has to be the credential; that router is therefore public with the check done inline, as enrollment.py already does. Signing GCS URLs from the VM would have needed a serviceAccountTokenCreator grant on its own service account — this avoids the IAM change entirely and keeps the bucket private. gcs_uri_for_call() reconstructs the object name from call_id, so recordings made before this fix are reachable again without a data migration. Frontend rows come straight from Firestore via onSnapshot and never see a server-minted field, so CallRow fetches the link lazily on expand. Also removes the last long-lived (1 year) signed URL and the log line that printed it.
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""
|
|
Call-audio playback.
|
|
|
|
Public router by necessity: a browser's <audio src="..."> cannot attach an
|
|
Authorization header, so the link itself carries the credential — a short-lived
|
|
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
|
|
router is included in main.py WITHOUT a router-level auth dependency; the check
|
|
happens inline below, in the same spirit as routers/enrollment.py.
|
|
|
|
The bucket stays fully private and c2-core reads the object server-side with
|
|
Application Default Credentials, so no GCS signed URL — and therefore no
|
|
service-account private key on the VM — is involved anywhere in this path.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Query, Response
|
|
from app.internal import firestore as fstore
|
|
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
|
|
|
|
router = APIRouter(prefix="/media", tags=["media"])
|
|
|
|
|
|
@router.get("/calls/{call_id}/audio")
|
|
async def get_call_audio(
|
|
call_id: str,
|
|
exp: int = Query(..., description="Link expiry, unix seconds."),
|
|
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
|
):
|
|
# Verify before touching Firestore so an invalid link costs nothing.
|
|
if not verify_audio_link(call_id, exp, sig):
|
|
raise HTTPException(403, "Invalid or expired audio link")
|
|
|
|
call = await fstore.doc_get("calls", call_id)
|
|
if not call:
|
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
|
|
|
gcs_uri = gcs_uri_for_call(call)
|
|
if not gcs_uri:
|
|
raise HTTPException(404, "No audio for this call.")
|
|
|
|
data = await download_audio(gcs_uri)
|
|
if not data:
|
|
raise HTTPException(404, "Audio object missing from storage.")
|
|
|
|
return Response(
|
|
content=data,
|
|
media_type="audio/mpeg",
|
|
headers={
|
|
"Content-Length": str(len(data)),
|
|
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
|
# whole body is sent at once and the browser seeks within its own
|
|
# buffer. Range support would only matter for long files.
|
|
"Accept-Ranges": "none",
|
|
# Immutable content, but the URL expires — cache privately only.
|
|
"Cache-Control": "private, max-age=3600",
|
|
},
|
|
)
|