From 09177b9d369755061cfacc68c18eedde8eef4bd0 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 10:33:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20share=5Ftoken=20column=20with?= =?UTF-8?q?=20migration=20026,=20wired=20token=20generati=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/models.py" - "alembic/versions/026_add_share_token.py" - "backend/pipeline/stages.py" - "backend/routers/shorts.py" - "backend/routers/shorts_public.py" - "backend/main.py" GSD-Task: S01/T01 --- alembic/versions/026_add_share_token.py | 45 ++++++++++++ backend/main.py | 3 +- backend/models.py | 3 + backend/pipeline/stages.py | 2 + backend/routers/shorts.py | 2 + backend/routers/shorts_public.py | 95 +++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/026_add_share_token.py create mode 100644 backend/routers/shorts_public.py diff --git a/alembic/versions/026_add_share_token.py b/alembic/versions/026_add_share_token.py new file mode 100644 index 0000000..c31f2ca --- /dev/null +++ b/alembic/versions/026_add_share_token.py @@ -0,0 +1,45 @@ +"""Add share_token column to generated_shorts for public sharing. + +Revision ID: 026_add_share_token +Revises: 025_add_generated_shorts +""" + +import secrets + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from alembic import op + +revision = "026_add_share_token" +down_revision = "025_add_generated_shorts" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add nullable column first + op.add_column( + "generated_shorts", + sa.Column("share_token", sa.String(16), nullable=True), + ) + + # Backfill existing complete shorts with unique tokens + conn = op.get_bind() + rows = conn.execute( + sa.text("SELECT id FROM generated_shorts WHERE status = 'complete' AND share_token IS NULL") + ).fetchall() + for (row_id,) in rows: + token = secrets.token_urlsafe(8) # ~11 chars, fits in String(16) + conn.execute( + sa.text("UPDATE generated_shorts SET share_token = :token WHERE id = :id"), + {"token": token, "id": row_id}, + ) + + # Create unique index + op.create_index("ix_generated_shorts_share_token", "generated_shorts", ["share_token"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_generated_shorts_share_token", table_name="generated_shorts") + op.drop_column("generated_shorts", "share_token") diff --git a/backend/main.py b/backend/main.py index 7e43e3e..248e1fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import get_settings -from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, stats, techniques, topics, videos +from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, shorts_public, stats, techniques, topics, videos def _setup_logging() -> None: @@ -102,6 +102,7 @@ app.include_router(files.router, prefix="/api/v1") app.include_router(reports.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1") app.include_router(shorts.router, prefix="/api/v1") +app.include_router(shorts_public.router, prefix="/api/v1") app.include_router(stats.router, prefix="/api/v1") app.include_router(techniques.router, prefix="/api/v1") app.include_router(topics.router, prefix="/api/v1") diff --git a/backend/models.py b/backend/models.py index 1d77731..11e03fa 100644 --- a/backend/models.py +++ b/backend/models.py @@ -857,6 +857,9 @@ class GeneratedShort(Base): server_default="pending", ) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + share_token: Mapped[str | None] = mapped_column( + String(16), nullable=True, unique=True, index=True, + ) created_at: Mapped[datetime] = mapped_column( default=_now, server_default=func.now() ) diff --git a/backend/pipeline/stages.py b/backend/pipeline/stages.py index d7cc032..c3a4e28 100644 --- a/backend/pipeline/stages.py +++ b/backend/pipeline/stages.py @@ -14,6 +14,7 @@ import json import logging import os import re +import secrets import subprocess import time from collections import defaultdict @@ -2999,6 +3000,7 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str: short.status = ShortStatus.complete short.file_size_bytes = file_size short.minio_object_key = minio_key + short.share_token = secrets.token_urlsafe(8) session.commit() elapsed_preset = time.monotonic() - preset_start diff --git a/backend/routers/shorts.py b/backend/routers/shorts.py index b56851c..0d798c7 100644 --- a/backend/routers/shorts.py +++ b/backend/routers/shorts.py @@ -41,6 +41,7 @@ class GeneratedShortResponse(BaseModel): duration_secs: float | None = None width: int height: int + share_token: str | None = None created_at: datetime model_config = {"from_attributes": True} @@ -158,6 +159,7 @@ async def list_shorts( duration_secs=s.duration_secs, width=s.width, height=s.height, + share_token=s.share_token, created_at=s.created_at, ) for s in shorts diff --git a/backend/routers/shorts_public.py b/backend/routers/shorts_public.py new file mode 100644 index 0000000..66aeca1 --- /dev/null +++ b/backend/routers/shorts_public.py @@ -0,0 +1,95 @@ +"""Public (unauthenticated) endpoint for sharing generated shorts. + +Resolves a share_token to video metadata and a presigned download URL. +No auth dependency — anyone with the token can access the short. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from database import get_session +from models import GeneratedShort, HighlightCandidate, ShortStatus, SourceVideo + +logger = logging.getLogger("chrysopedia.shorts_public") + +router = APIRouter(prefix="/public/shorts", tags=["shorts-public"]) + + +class PublicShortResponse(BaseModel): + """Public metadata for a shared short — no internal IDs exposed.""" + format_preset: str + width: int + height: int + duration_secs: float | None + creator_name: str + highlight_title: str + download_url: str + + +@router.get("/{share_token}", response_model=PublicShortResponse) +async def get_public_short( + share_token: str, + db: AsyncSession = Depends(get_session), +): + """Resolve a share token to short metadata and a presigned download URL.""" + stmt = ( + select(GeneratedShort) + .where(GeneratedShort.share_token == share_token) + .options( + selectinload(GeneratedShort.highlight_candidate) + .selectinload(HighlightCandidate.key_moment), + selectinload(GeneratedShort.highlight_candidate) + .selectinload(HighlightCandidate.source_video) + .selectinload(SourceVideo.creator), + ) + ) + result = await db.execute(stmt) + short = result.scalar_one_or_none() + + if short is None: + raise HTTPException(status_code=404, detail="Short not found") + + if short.status != ShortStatus.complete: + raise HTTPException(status_code=404, detail="Short not found") + + if not short.minio_object_key: + raise HTTPException( + status_code=500, + detail="Short is complete but has no storage key", + ) + + highlight = short.highlight_candidate + key_moment = highlight.key_moment + source_video = highlight.source_video + creator = source_video.creator + + from minio_client import generate_download_url + + try: + url = generate_download_url(short.minio_object_key) + except Exception as exc: + logger.error( + "Failed to generate download URL for share_token=%s: %s", + share_token, exc, + ) + raise HTTPException( + status_code=503, + detail="Failed to generate download URL", + ) from exc + + return PublicShortResponse( + format_preset=short.format_preset.value, + width=short.width, + height=short.height, + duration_secs=short.duration_secs, + creator_name=creator.name, + highlight_title=key_moment.title, + download_url=url, + )