SurfSense/surfsense_backend/app/routes/public_chat_routes.py

265 lines
7.9 KiB
Python

"""
Routes for public chat access via immutable snapshots.
All public endpoints use share_token for access - no authentication required
for read operations. Clone requires authentication.
"""
import os
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import User, get_async_session
from app.schemas.new_chat import (
CloneResponse,
PublicChatResponse,
)
from app.services.public_chat_service import (
clone_from_snapshot,
get_public_chat,
get_snapshot_podcast,
get_snapshot_report,
get_snapshot_video_presentation,
)
from app.users import current_active_user
router = APIRouter(prefix="/public", tags=["public"])
@router.get("/{share_token}", response_model=PublicChatResponse)
async def read_public_chat(
share_token: str,
session: AsyncSession = Depends(get_async_session),
):
"""
Get a public chat snapshot by share token.
No authentication required.
Returns immutable snapshot data (sanitized, citations stripped).
"""
return await get_public_chat(session, share_token)
@router.post("/{share_token}/clone", response_model=CloneResponse)
async def clone_public_chat(
share_token: str,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Clone a public chat snapshot to the user's account.
Creates thread and copies messages.
Requires authentication.
"""
return await clone_from_snapshot(session, share_token, user)
@router.get("/{share_token}/podcasts/{podcast_id}")
async def get_public_podcast(
share_token: str,
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get podcast details from a public chat snapshot.
No authentication required - the share_token provides access.
Returns podcast info including transcript.
"""
podcast_info = await get_snapshot_podcast(session, share_token, podcast_id)
if not podcast_info:
raise HTTPException(status_code=404, detail="Podcast not found")
return {
"id": podcast_info.get("original_id"),
"title": podcast_info.get("title"),
"status": "ready",
"podcast_transcript": podcast_info.get("transcript"),
}
@router.get("/{share_token}/podcasts/{podcast_id}/stream")
async def stream_public_podcast(
share_token: str,
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Stream a podcast from a public chat snapshot.
No authentication required - the share_token provides access.
Looks up podcast by original_id in the snapshot's podcasts array.
"""
podcast_info = await get_snapshot_podcast(session, share_token, podcast_id)
if not podcast_info:
raise HTTPException(status_code=404, detail="Podcast not found")
file_path = podcast_info.get("file_path")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Podcast audio file not found")
def iterfile():
with open(file_path, mode="rb") as file_like:
yield from file_like
return StreamingResponse(
iterfile(),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}",
},
)
@router.get("/{share_token}/video-presentations/{video_presentation_id}")
async def get_public_video_presentation(
share_token: str,
video_presentation_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get video presentation details from a public chat snapshot.
No authentication required - the share_token provides access.
Returns slide data (with public audio URLs) and scene codes.
"""
vp_info = await get_snapshot_video_presentation(
session, share_token, video_presentation_id
)
if not vp_info:
raise HTTPException(status_code=404, detail="Video presentation not found")
slides = vp_info.get("slides") or []
public_slides = _replace_audio_paths_with_public_urls(
share_token, video_presentation_id, slides
)
return {
"id": vp_info.get("original_id"),
"title": vp_info.get("title"),
"status": "ready",
"slides": public_slides,
"scene_codes": vp_info.get("scene_codes"),
"slide_count": len(slides) if slides else None,
}
@router.get(
"/{share_token}/video-presentations/{video_presentation_id}/slides/{slide_number}/audio"
)
async def stream_public_slide_audio(
share_token: str,
video_presentation_id: int,
slide_number: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Stream a slide's audio from a public chat snapshot.
No authentication required - the share_token provides access.
"""
from pathlib import Path
vp_info = await get_snapshot_video_presentation(
session, share_token, video_presentation_id
)
if not vp_info:
raise HTTPException(status_code=404, detail="Video presentation not found")
slides = vp_info.get("slides") or []
slide_data = None
for s in slides:
if s.get("slide_number") == slide_number:
slide_data = s
break
if not slide_data:
raise HTTPException(status_code=404, detail=f"Slide {slide_number} not found")
file_path = slide_data.get("audio_file")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Slide audio file not found")
ext = Path(file_path).suffix.lower()
media_type = "audio/wav" if ext == ".wav" else "audio/mpeg"
def iterfile():
with open(file_path, mode="rb") as file_like:
yield from file_like
return StreamingResponse(
iterfile(),
media_type=media_type,
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={Path(file_path).name}",
},
)
def _replace_audio_paths_with_public_urls(
share_token: str,
video_presentation_id: int,
slides: list[dict],
) -> list[dict]:
"""Replace server-local audio_file paths with public streaming API URLs."""
result = []
for slide in slides:
slide_copy = dict(slide)
slide_number = slide_copy.get("slide_number")
audio_file = slide_copy.pop("audio_file", None)
if audio_file and slide_number is not None:
slide_copy["audio_url"] = (
f"/api/v1/public/{share_token}"
f"/video-presentations/{video_presentation_id}"
f"/slides/{slide_number}/audio"
)
else:
slide_copy["audio_url"] = None
result.append(slide_copy)
return result
@router.get("/{share_token}/reports/{report_id}/content")
async def get_public_report_content(
share_token: str,
report_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get report content from a public chat snapshot.
No authentication required - the share_token provides access.
Returns report content including title, markdown body, metadata, and versions.
"""
from app.services.public_chat_service import get_snapshot_report_versions
report_info = await get_snapshot_report(session, share_token, report_id)
if not report_info:
raise HTTPException(status_code=404, detail="Report not found")
# Get version siblings from the same snapshot
versions = await get_snapshot_report_versions(
session, share_token, report_info.get("report_group_id")
)
return {
"id": report_info.get("original_id"),
"title": report_info.get("title"),
"content": report_info.get("content"),
"report_metadata": report_info.get("report_metadata"),
"report_group_id": report_info.get("report_group_id"),
"versions": versions,
}