Serve call audio through c2-core instead of GCS signed URLs
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:
@@ -68,6 +68,16 @@ class Settings(BaseSettings):
|
||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||
upload_max_bytes: int = 100 * 1024 * 1024
|
||||
|
||||
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
|
||||
# Only used to build absolute call-audio playback links: an <audio src> is
|
||||
# fetched by the browser directly, so a relative path would resolve against
|
||||
# the frontend origin, not this one.
|
||||
public_api_url: Optional[str] = None
|
||||
|
||||
# How long a minted call-audio playback link stays valid. Long enough for a
|
||||
# browsing session, short enough that a copied link isn't durable access.
|
||||
audio_link_ttl_seconds: int = 6 * 60 * 60
|
||||
|
||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||
# Defaults to "*" for local development only.
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
"""
|
||||
Call-audio storage and playback links.
|
||||
|
||||
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
|
||||
|
||||
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
|
||||
service-account private key, and the deployed VM runs on Application Default
|
||||
Credentials with no key file (see ansible c2-core.env.j2). The old code
|
||||
silently fell back to returning a bare ``gs://`` URI, which broke two things
|
||||
at once: browsers can't fetch a gs:// URI, so no recording was ever
|
||||
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
|
||||
so the transcription step was skipped without logging anything at all.
|
||||
|
||||
2. It does not store a long-lived URL on the call document. What gets persisted
|
||||
is the canonical ``gs://`` object location; a short-lived playback link is
|
||||
minted on read instead. Nothing durable and nothing loggable is a credential.
|
||||
|
||||
Playback goes through c2-core's own /media route rather than GCS directly,
|
||||
because an <audio src> cannot carry an Authorization header — so the link
|
||||
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
|
||||
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
|
||||
fully private, and needs no IAM change on the VM's service account.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
|
||||
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
|
||||
# a service-key bearer token against the rest of the API.
|
||||
_KEY_CONTEXT = b"drb-audio-link-v1"
|
||||
|
||||
|
||||
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
"""Return a safe GCS object name derived from the call_id.
|
||||
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||
The original extension is preserved only if it's a known audio type.
|
||||
"""
|
||||
import os
|
||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||
ext = ".mp3"
|
||||
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
|
||||
|
||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
||||
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
||||
if not settings.gcs_bucket:
|
||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||
return None
|
||||
|
||||
def _upload() -> str:
|
||||
safe_name = _safe_audio_filename(filename, call_id)
|
||||
blob_path = f"calls/{safe_name}"
|
||||
|
||||
def _upload() -> None:
|
||||
from google.cloud import storage
|
||||
from google.oauth2 import service_account as sa
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
signing_creds = None
|
||||
bucket = client.bucket(settings.gcs_bucket)
|
||||
safe_name = _safe_audio_filename(filename, call_id)
|
||||
blob = bucket.blob(f"calls/{safe_name}")
|
||||
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||
if signing_creds:
|
||||
return blob.generate_signed_url(
|
||||
version="v2",
|
||||
expiration=datetime.timedelta(days=365),
|
||||
method="GET",
|
||||
credentials=signing_creds,
|
||||
)
|
||||
# Fallback: return the gs:// URI (no public access)
|
||||
return f"gs://{settings.gcs_bucket}/calls/{filename}"
|
||||
|
||||
try:
|
||||
url = await asyncio.to_thread(_upload)
|
||||
logger.info(f"Audio uploaded: {url}")
|
||||
return url
|
||||
await asyncio.to_thread(_upload)
|
||||
except Exception as e:
|
||||
logger.error(f"GCS upload failed: {e}")
|
||||
return None
|
||||
|
||||
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
|
||||
logger.info(f"Audio uploaded: {gcs_uri}")
|
||||
return gcs_uri
|
||||
|
||||
|
||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _download() -> bytes:
|
||||
from google.cloud import storage
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_download)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
return None, None
|
||||
without_scheme = gcs_uri[len("gs://"):]
|
||||
if "/" not in without_scheme:
|
||||
return None, None
|
||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||
return bucket_name, blob_path
|
||||
|
||||
|
||||
def gcs_uri_for_call(call: dict) -> Optional[str]:
|
||||
"""Resolve the audio object for a call document.
|
||||
|
||||
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
|
||||
reconstructing the object name from the call_id for documents written
|
||||
before this module was fixed: those stored a gs:// URI built from the
|
||||
*client-supplied* filename, which never matched the object actually
|
||||
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
|
||||
trusting the stored value is what makes every pre-existing recording
|
||||
playable again without a data migration.
|
||||
"""
|
||||
uri = call.get("audio_gcs_uri")
|
||||
if uri:
|
||||
return uri
|
||||
call_id = call.get("call_id")
|
||||
if call.get("audio_url") and call_id and settings.gcs_bucket:
|
||||
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
|
||||
return None
|
||||
|
||||
|
||||
def _link_key() -> Optional[bytes]:
|
||||
if not settings.service_key:
|
||||
return None
|
||||
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
|
||||
key = _link_key()
|
||||
if not key:
|
||||
return None
|
||||
msg = f"{call_id}:{expires_at}".encode("utf-8")
|
||||
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
|
||||
if expires_at < int(time.time()):
|
||||
return False
|
||||
expected = sign_audio_link(call_id, expires_at)
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
_warned_no_service_key = False
|
||||
_warned_no_public_url = False
|
||||
|
||||
|
||||
def playback_url(call: dict) -> Optional[str]:
|
||||
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
|
||||
global _warned_no_service_key, _warned_no_public_url
|
||||
|
||||
call_id = call.get("call_id")
|
||||
if not call_id or not gcs_uri_for_call(call):
|
||||
return None
|
||||
|
||||
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
|
||||
signature = sign_audio_link(call_id, expires_at)
|
||||
if not signature:
|
||||
if not _warned_no_service_key:
|
||||
logger.error("SERVICE_KEY not set — call audio cannot be served.")
|
||||
_warned_no_service_key = True
|
||||
return None
|
||||
|
||||
# Loud rather than silent: a relative link here would 404 against the
|
||||
# frontend origin, which is the exact failure mode this module exists to
|
||||
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
|
||||
if not settings.public_api_url and not _warned_no_public_url:
|
||||
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
|
||||
_warned_no_public_url = True
|
||||
|
||||
base = (settings.public_api_url or "").rstrip("/")
|
||||
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
|
||||
|
||||
|
||||
def with_playback_url(call: dict) -> dict:
|
||||
"""Return the call dict with a freshly minted ``audio_url``."""
|
||||
return {**call, "audio_url": playback_url(call)}
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.internal.auth import (
|
||||
require_node_service_or_firebase_token,
|
||||
)
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.routers import enrollment
|
||||
from app.routers import enrollment, media
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -101,6 +101,9 @@ app.include_router(admin.router) # auth is per-endpoint (read: firebase, wri
|
||||
app.include_router(users.router) # auth: admin only
|
||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||
# public by necessity — an <audio src> can't send a bearer token, so the
|
||||
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||
app.include_router(media.router)
|
||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
||||
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||
|
||||
@@ -62,7 +62,8 @@ class CallRecord(BaseModel):
|
||||
srcaddr: Optional[str] = None
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
audio_url: Optional[str] = None
|
||||
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
||||
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
||||
transcript: Optional[str] = None # populated later by STT
|
||||
incident_ids: List[str] = [] # one per scene detected in the recording
|
||||
location: Optional[Dict[str, float]] = None # {lat, lng}
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.storage import gcs_uri_for_call, with_playback_url
|
||||
|
||||
|
||||
class TranscriptUpdate(BaseModel):
|
||||
@@ -25,7 +26,9 @@ async def list_calls(
|
||||
filters["status"] = status
|
||||
if system_id:
|
||||
filters["system_id"] = system_id
|
||||
return await fstore.collection_list("calls", **filters)
|
||||
calls = await fstore.collection_list("calls", **filters)
|
||||
# audio_url is not stored — it's a short-lived signed link minted per read.
|
||||
return [with_playback_url(c) for c in calls]
|
||||
|
||||
|
||||
@router.get("/{call_id}")
|
||||
@@ -33,7 +36,7 @@ async def get_call(call_id: str):
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
return call
|
||||
return with_playback_url(call)
|
||||
|
||||
|
||||
@router.post("/{call_id}/reprocess")
|
||||
@@ -43,10 +46,9 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
|
||||
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri
|
||||
from app.routers.upload import _run_intelligence_pipeline
|
||||
|
||||
audio_url = call.get("audio_url")
|
||||
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
|
||||
gcs_uri = gcs_uri_for_call(call)
|
||||
|
||||
background_tasks.add_task(
|
||||
_run_intelligence_pipeline,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
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",
|
||||
},
|
||||
)
|
||||
@@ -47,16 +47,15 @@ async def upload_call_audio(
|
||||
if len(data) > settings.upload_max_bytes:
|
||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||
|
||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
|
||||
if audio_url:
|
||||
if gcs_uri:
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"audio_url": audio_url})
|
||||
# Canonical object location only. The playback link is minted per
|
||||
# read in storage.playback_url() — nothing durable is stored here.
|
||||
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
||||
|
||||
# Convert public GCS URL to gs:// URI for Speech-to-Text
|
||||
gcs_uri = _public_url_to_gcs_uri(audio_url)
|
||||
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||
|
||||
background_tasks.add_task(
|
||||
_run_intelligence_pipeline,
|
||||
@@ -68,21 +67,7 @@ async def upload_call_audio(
|
||||
gcs_uri=gcs_uri,
|
||||
)
|
||||
|
||||
return {"url": audio_url}
|
||||
|
||||
|
||||
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a public GCS URL (possibly signed) like
|
||||
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
|
||||
to a gs:// URI usable by Speech-to-Text.
|
||||
Returns None if the URL doesn't look like a GCS URL.
|
||||
"""
|
||||
prefix = "https://storage.googleapis.com/"
|
||||
if url and url.startswith(prefix):
|
||||
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
|
||||
return "gs://" + path
|
||||
return None
|
||||
return {"url": gcs_uri}
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -93,6 +93,13 @@ export interface CallRecord {
|
||||
freq: number | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
/** Private gs:// object location. Present on the Firestore doc; not playable. */
|
||||
audio_gcs_uri?: string | null;
|
||||
/**
|
||||
* Short-lived playback link. Minted per read by the API — only populated on
|
||||
* calls fetched via c2api, never on docs read straight from Firestore.
|
||||
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
|
||||
*/
|
||||
audio_url: string | null;
|
||||
transcript: string | null;
|
||||
transcript_corrected: string | null;
|
||||
|
||||
@@ -12,9 +12,17 @@ MQTT_DYNSEC_ADMIN_PASS={{ vault_mqtt_dynsec_admin_pass }}
|
||||
|
||||
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||
# NOTE: because there is no service-account key file here, c2-core cannot mint
|
||||
# GCS *signed* URLs. Call audio is therefore served through c2-core's own
|
||||
# /media route (app/routers/media.py) rather than direct-from-bucket links.
|
||||
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||
|
||||
# Absolute origin for call-audio playback links. The browser fetches <audio src>
|
||||
# directly, so a relative path would resolve against the frontend origin
|
||||
# (https://{{ domain }}) instead of the API's.
|
||||
PUBLIC_API_URL=https://api.{{ domain }}
|
||||
|
||||
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||
|
||||
Reference in New Issue
Block a user