Serve call audio through c2-core instead of GCS signed URLs
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m34s

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.
This commit is contained in:
Logan Cusano
2026-08-16 16:26:41 -04:00
parent a195563da6
commit a2cd2c57ca
10 changed files with 291 additions and 62 deletions
+36 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
@@ -37,11 +37,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
: call.incident_id ? [call.incident_id] : [];
const isActive = call.status === "active";
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
// the private gs:// object location — never a playable URL. Presence of audio
// is known from the doc; the actual link is minted by the API on expand.
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
// there, so it still signals "this call has a recording".
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
const hasBoth = !!(call.transcript && call.transcript_corrected);
const hasSegments = call.segments && call.segments.length > 1;
// Fetched lazily on expand: playback links are short-lived, so minting one
// for every row up front would waste most of them and expire the rest.
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioError, setAudioError] = useState(false);
useEffect(() => {
if (!expanded || !hasAudio || audioUrl || audioError) return;
let cancelled = false;
c2api.getCall(call.call_id)
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
.catch(() => { if (!cancelled) setAudioError(true); });
return () => { cancelled = true; };
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
function startEdit() {
setEditText(call.transcript_corrected ?? call.transcript ?? "");
setEditing(true);
@@ -89,7 +109,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
)}
</td>
<td className="px-4 py-2 text-xs">
{call.audio_url ? (
{hasAudio ? (
<span className="text-blue-400">▶</span>
) : (
<span className="text-gray-700">—</span>
@@ -104,13 +124,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<tr className="bg-gray-900/60 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */}
{call.audio_url && (
<audio
controls
src={call.audio_url}
className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()}
/>
{hasAudio && (
audioError ? (
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
) : audioUrl ? (
<audio
controls
src={audioUrl}
className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()}
/>
) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
)
)}
{/* Tags */}