Security fixes
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
|
||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||
service_key: Optional[str] = None
|
||||
|
||||
# CORS — comma-separated list of allowed origins, or "*" for all
|
||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||
upload_max_bytes: int = 100 * 1024 * 1024
|
||||
|
||||
# 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] = ["*"]
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and token == settings.service_key:
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
return firebase_auth.verify_id_token(token)
|
||||
@@ -42,3 +45,72 @@ async def require_admin_token(
|
||||
if not decoded.get("admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
async def require_service_key(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept only the internal service key — used for bot-only endpoints."""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
if not settings.service_key:
|
||||
raise HTTPException(status_code=503, detail="Service key not configured")
|
||||
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
||||
raise HTTPException(status_code=403, detail="Service key required")
|
||||
return {"service": True}
|
||||
|
||||
|
||||
async def require_service_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the internal service key or a Firebase admin token.
|
||||
|
||||
Used for endpoints that the Discord bot (service key) and dashboard admins
|
||||
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if not decoded.get("admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Not persistent across restarts; good enough for a single-instance deployment.
|
||||
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
||||
|
||||
class _RateLimiter:
|
||||
def __init__(self, max_calls: int, window_seconds: int):
|
||||
self.max_calls = max_calls
|
||||
self.window = window_seconds
|
||||
self._log: dict[str, deque] = defaultdict(deque)
|
||||
|
||||
def check(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
q = self._log[key]
|
||||
while q and now - q[0] > self.window:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_calls:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limit exceeded. Please wait before trying again.",
|
||||
)
|
||||
q.append(now)
|
||||
|
||||
|
||||
# Shared limiter instances
|
||||
# trip chat: 20 requests per user per 5 minutes
|
||||
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
||||
# per-incident summarize: 5 per incident per 10 minutes
|
||||
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
# vocabulary bootstrap: 2 per system per hour
|
||||
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||
|
||||
@@ -5,7 +5,21 @@ from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
||||
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.")
|
||||
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
||||
client = storage.Client()
|
||||
signing_creds = None
|
||||
bucket = client.bucket(settings.gcs_bucket)
|
||||
blob = bucket.blob(f"calls/{filename}")
|
||||
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(
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||
from app.models import IncidentCreate, IncidentUpdate
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
||||
|
||||
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||
|
||||
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
|
||||
|
||||
|
||||
@router.post("/summarize")
|
||||
async def summarize_all_stale(background_tasks: BackgroundTasks):
|
||||
async def summarize_all_stale(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
||||
from app.internal.summarizer import _run_summary_pass
|
||||
background_tasks.add_task(_run_summary_pass)
|
||||
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
||||
|
||||
|
||||
@router.post("/{incident_id}/summarize")
|
||||
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
|
||||
async def summarize_incident(
|
||||
incident_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
):
|
||||
"""Immediately run the summarizer for a specific incident."""
|
||||
from app.internal.summarizer import _summarize_incident
|
||||
inc = await fstore.doc_get("incidents", incident_id)
|
||||
if not inc:
|
||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
||||
summarize_limiter.check(incident_id)
|
||||
background_tasks.add_task(_summarize_incident, inc)
|
||||
return {"ok": True, "incident_id": incident_id}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from app.models import CommandPayload
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||
from app.routers.tokens import assign_token, release_token
|
||||
|
||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
|
||||
|
||||
@router.post("/{node_id}/command")
|
||||
async def send_command(node_id: str, cmd: CommandPayload):
|
||||
async def send_command(
|
||||
node_id: str,
|
||||
cmd: CommandPayload,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
@@ -108,6 +112,7 @@ async def assign_system(
|
||||
system_id: str,
|
||||
hardware_preset: str = Query("rtl-sdr-v3"),
|
||||
ppm_override: Optional[float] = Query(None),
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
"""
|
||||
Assign a system to a node. Fetches the system config from Firestore
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional
|
||||
from app.models import SystemCreate, SystemRecord
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||
|
||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||
|
||||
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_system(body: SystemCreate):
|
||||
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||
system_id = str(uuid.uuid4())
|
||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
|
||||
|
||||
|
||||
@router.put("/{system_id}")
|
||||
async def update_system(system_id: str, body: SystemCreate):
|
||||
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
|
||||
|
||||
|
||||
@router.delete("/{system_id}", status_code=204)
|
||||
async def delete_system(system_id: str):
|
||||
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
|
||||
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
||||
|
||||
@router.put("/{system_id}/ai-flags")
|
||||
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
|
||||
async def update_system_ai_flags(
|
||||
system_id: str,
|
||||
body: AiFlagsBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Set per-system AI flag overrides. Only fields included in the body are
|
||||
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
||||
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
|
||||
|
||||
|
||||
@router.put("/{system_id}/ten-codes")
|
||||
async def update_ten_codes(system_id: str, body: TenCodesBody):
|
||||
async def update_ten_codes(
|
||||
system_id: str,
|
||||
body: TenCodesBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Replace the ten-code dictionary for a system."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
||||
async def bootstrap_vocabulary(system_id: str):
|
||||
async def bootstrap_vocabulary(
|
||||
system_id: str,
|
||||
decoded: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
bootstrap_limiter.check(system_id)
|
||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||
terms = await bootstrap_system_vocabulary(system_id)
|
||||
return {"added": len(terms), "terms": terms}
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/terms")
|
||||
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
async def add_vocabulary_term(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Manually add a term to the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.delete("/{system_id}/vocabulary/terms")
|
||||
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
async def remove_vocabulary_term(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Remove a term from the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/pending/approve")
|
||||
async def approve_pending(system_id: str, body: VocabularyTermBody):
|
||||
async def approve_pending(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Move a pending induction suggestion into the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
||||
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
|
||||
async def dismiss_pending(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Dismiss a pending induction suggestion without adding it."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
|
||||
@@ -22,13 +23,13 @@ async def list_tokens():
|
||||
"""List all tokens. The actual token string is masked for safety."""
|
||||
tokens = await fstore.collection_list("bot_tokens")
|
||||
return [
|
||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
||||
{**t, "token": "•••" + t["token"][-4:]}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def add_token(body: TokenCreate):
|
||||
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
|
||||
token_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
"token_id": token_id,
|
||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
||||
|
||||
|
||||
@router.post("/flush", status_code=200)
|
||||
async def flush_tokens():
|
||||
async def flush_tokens(_: dict = Depends(require_admin_token)):
|
||||
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
||||
def _find():
|
||||
from app.internal.firestore import db
|
||||
@@ -61,7 +62,11 @@ async def flush_tokens():
|
||||
|
||||
|
||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||
async def set_preferred_system(token_id: str, system_id: str):
|
||||
async def set_preferred_system(
|
||||
token_id: str,
|
||||
system_id: str,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Mark this token as the preferred bot for a system.
|
||||
When a discord_join is issued for any node in that system, this token
|
||||
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def delete_token(token_id: str):
|
||||
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
|
||||
@@ -3,12 +3,18 @@ import json
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal.auth import (
|
||||
require_service_or_firebase_token,
|
||||
require_service_key,
|
||||
require_service_key_or_admin,
|
||||
trip_chat_limiter,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||
|
||||
@@ -180,7 +186,7 @@ async def get_trip(trip_id: str):
|
||||
|
||||
|
||||
@router.delete("/{trip_id}")
|
||||
async def delete_trip(trip_id: str):
|
||||
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
@@ -192,7 +198,12 @@ async def delete_trip(trip_id: str):
|
||||
|
||||
|
||||
@router.post("/{trip_id}/join")
|
||||
async def join_trip(trip_id: str, body: AttendeeAction):
|
||||
async def join_trip(
|
||||
trip_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
@@ -203,7 +214,12 @@ async def join_trip(trip_id: str, body: AttendeeAction):
|
||||
|
||||
|
||||
@router.post("/{trip_id}/leave")
|
||||
async def leave_trip(trip_id: str, body: AttendeeAction):
|
||||
async def leave_trip(
|
||||
trip_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Leave a trip. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
@@ -253,7 +269,11 @@ async def create_event(trip_id: str, body: TripEventCreate):
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/events/{event_id}")
|
||||
async def delete_event(trip_id: str, event_id: str):
|
||||
async def delete_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
@@ -262,7 +282,13 @@ async def delete_event(trip_id: str, event_id: str):
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/join")
|
||||
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
async def join_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Join an event. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
@@ -278,7 +304,13 @@ async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/leave")
|
||||
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
async def leave_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Leave an event. Only the Discord bot (service key) may call this."""
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
@@ -293,10 +325,18 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{trip_id}/chat")
|
||||
async def trip_chat(trip_id: str, body: ChatRequest):
|
||||
async def trip_chat(
|
||||
trip_id: str,
|
||||
body: ChatRequest,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
):
|
||||
if not settings.openai_api_key:
|
||||
raise HTTPException(503, "OpenAI not configured.")
|
||||
|
||||
# Rate limit by caller identity
|
||||
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
|
||||
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
|
||||
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
@@ -306,10 +346,20 @@ async def trip_chat(trip_id: str, body: ChatRequest):
|
||||
from openai import AsyncOpenAI
|
||||
oai = AsyncOpenAI(api_key=settings.openai_api_key)
|
||||
|
||||
# Strip history to only user/assistant roles to prevent prompt injection
|
||||
safe_history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in body.history[-20:]
|
||||
if m.role in ("user", "assistant")
|
||||
]
|
||||
|
||||
# Truncate message to prevent oversized single requests
|
||||
user_message = body.message[:2000]
|
||||
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _build_system_prompt(trip, events)},
|
||||
*[{"role": m.role, "content": m.content} for m in body.history[-20:]],
|
||||
{"role": "user", "content": body.message},
|
||||
*safe_history,
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
|
||||
suggestions: list[dict] = []
|
||||
@@ -340,7 +390,10 @@ async def trip_chat(trip_id: str, body: ChatRequest):
|
||||
args = json.loads(tc.function.arguments)
|
||||
|
||||
if tc.function.name == "search_places":
|
||||
results = await _places_search(args.get("query", ""), args.get("near", ""))
|
||||
# Limit query string lengths before hitting the Maps API
|
||||
query = str(args.get("query", ""))[:200]
|
||||
near = str(args.get("near", ""))[:200]
|
||||
results = await _places_search(query, near)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(tags=["upload"])
|
||||
|
||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Empty file.")
|
||||
if len(data) > settings.upload_max_bytes:
|
||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||
|
||||
filename = file.filename
|
||||
audio_url = await upload_audio(data, filename)
|
||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
|
||||
if audio_url:
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Reference in New Issue
Block a user