18d96193ab
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
58 lines
2.2 KiB
Python
58 lines
2.2 KiB
Python
import asyncio
|
|
import datetime
|
|
from typing import Optional
|
|
from app.config import settings
|
|
from app.internal.logger import logger
|
|
|
|
|
|
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|
"""Return a safe GCS object name derived from the call_id.
|
|
|
|
We ignore the client-supplied filename entirely and derive the name from the
|
|
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"
|
|
return f"{call_id}{ext}"
|
|
|
|
|
|
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."""
|
|
if not settings.gcs_bucket:
|
|
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
|
return None
|
|
|
|
def _upload() -> str:
|
|
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.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
|
|
except Exception as e:
|
|
logger.error(f"GCS upload failed: {e}")
|
|
return None
|