diff --git a/backend/app/api/v1/routes_share.py b/backend/app/api/v1/routes_share.py new file mode 100644 index 0000000..8b4eb10 --- /dev/null +++ b/backend/app/api/v1/routes_share.py @@ -0,0 +1,213 @@ +"""Share-token endpoints — create/revoke/list tokens + public read-only view.""" + +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from motor.motor_asyncio import AsyncIOMotorDatabase +from pydantic import BaseModel + +from ...core.config import settings +from ...core.database import get_database +from ...core.dependencies import get_current_user, require_roles +from ...models.share_token import ShareToken, ShareTokenResponse +from ...models.user import User, UserRole +from ...services.gcs import get_signed_download_url + +router = APIRouter(tags=["share"]) + +_TOKENS = "share_tokens" +_JOBS = "jobs" + + +def _share_url(token: str) -> str: + base = getattr(settings, "app_url", "https://ai-sandbox.oliver.solutions/video-accessibility") + return f"{base}/share/{token}" + + +# ── Request schemas ─────────────────────────────────────────────────────────── + +class CreateShareTokenRequest(BaseModel): + expires_in_days: Optional[int] = 30 # None = no expiry + label: Optional[str] = None + + +class ShareTokenListResponse(BaseModel): + tokens: list[ShareTokenResponse] + + +class PublicJobPreviewLanguage(BaseModel): + captions_vtt_url: Optional[str] = None + audio_description_vtt_url: Optional[str] = None + accessible_video_mp4_url: Optional[str] = None + audio_description_mp3_url: Optional[str] = None + + +class PublicJobPreviewResponse(BaseModel): + job_id: str + job_title: str + job_status: str + source_language: str + languages: list[str] + language_outputs: dict[str, PublicJobPreviewLanguage] + + +# ── Authenticated routes ────────────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/share", response_model=ShareTokenResponse, status_code=201) +async def create_share_token( + job_id: str, + request: CreateShareTokenRequest, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Generate a read-only share link for a job.""" + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + token_id = secrets.token_hex(32) + now = datetime.utcnow() + expires_at = (now + timedelta(days=request.expires_in_days)) if request.expires_in_days else None + + token_doc = { + "_id": token_id, + "job_id": job_id, + "organization_id": job_doc.get("organization_id", ""), + "created_by_user_id": str(current_user.id), + "created_by_email": current_user.email, + "created_at": now, + "expires_at": expires_at, + "is_active": True, + "label": request.label, + } + await db[_TOKENS].insert_one(token_doc) + + return ShareTokenResponse( + id=token_id, + job_id=job_id, + created_by_email=current_user.email, + created_at=now, + expires_at=expires_at, + is_active=True, + label=request.label, + share_url=_share_url(token_id), + ) + + +@router.get("/jobs/{job_id}/share", response_model=ShareTokenListResponse) +async def list_share_tokens( + job_id: str, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """List all active share tokens for a job.""" + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + cursor = db[_TOKENS].find({"job_id": job_id, "is_active": True}) + tokens = [] + async for doc in cursor: + tokens.append(ShareTokenResponse( + id=doc["_id"], + job_id=doc["job_id"], + created_by_email=doc["created_by_email"], + created_at=doc["created_at"], + expires_at=doc.get("expires_at"), + is_active=doc["is_active"], + label=doc.get("label"), + share_url=_share_url(doc["_id"]), + )) + return ShareTokenListResponse(tokens=tokens) + + +@router.delete("/jobs/{job_id}/share/{token_id}", status_code=204) +async def revoke_share_token( + job_id: str, + token_id: str, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Revoke (deactivate) a share token.""" + result = await db[_TOKENS].update_one( + {"_id": token_id, "job_id": job_id}, + {"$set": {"is_active": False}}, + ) + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Token not found") + + +# ── Public route (no auth) ──────────────────────────────────────────────────── + +@router.get("/public/share/{token}", response_model=PublicJobPreviewResponse) +async def get_public_job_preview( + token: str, + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Return read-only job preview for a valid share token. No authentication required.""" + token_doc = await db[_TOKENS].find_one({"_id": token, "is_active": True}) + if not token_doc: + raise HTTPException(status_code=404, detail="Share link not found or has been revoked") + + if token_doc.get("expires_at") and token_doc["expires_at"] < datetime.utcnow(): + raise HTTPException(status_code=410, detail="Share link has expired") + + job_doc = await db[_JOBS].find_one({"_id": token_doc["job_id"]}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + outputs = job_doc.get("outputs") or {} + language_outputs: dict[str, PublicJobPreviewLanguage] = {} + + for lang, lang_output in outputs.items(): + if not isinstance(lang_output, dict): + continue + + lang_data = PublicJobPreviewLanguage() + + if "captions_vtt_gcs" in lang_output: + blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.captions_vtt_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "ad_vtt_gcs" in lang_output: + blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.audio_description_vtt_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "ad_mp3_gcs" in lang_output: + blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.audio_description_mp3_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "accessible_video_gcs" in lang_output: + blob_path = lang_output["accessible_video_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.accessible_video_mp4_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + language_outputs[lang] = lang_data + + return PublicJobPreviewResponse( + job_id=str(job_doc["_id"]), + job_title=job_doc.get("title", "Untitled"), + job_status=job_doc.get("status", ""), + source_language=job_doc.get("source", {}).get("language", "en"), + languages=list(outputs.keys()), + language_outputs=language_outputs, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 9f17d54..b3009ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from .api.v1.routes_organizations import router as organizations_router from .api.v1.routes_review_notes import router as review_notes_router from .api.v1.routes_tts import router as tts_router from .api.v1.routes_vtt_versions import router as vtt_versions_router +from .api.v1.routes_share import router as share_router from .api.v1.routes_websockets import router as websockets_router from .core.config import settings from .core.database import ( @@ -265,6 +266,7 @@ app.include_router(language_qc_router, prefix="/api/v1") app.include_router(glossaries_router, prefix="/api/v1") app.include_router(tts_router, prefix="/api/v1") app.include_router(admin_router, prefix="/api/v1") +app.include_router(share_router, prefix="/api/v1") app.include_router(websockets_router, prefix="/api/v1") diff --git a/backend/app/models/share_token.py b/backend/app/models/share_token.py new file mode 100644 index 0000000..4375f4f --- /dev/null +++ b/backend/app/models/share_token.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class ShareToken(BaseModel): + id: Optional[str] = None # token itself (32 hex chars), used as _id + job_id: str + organization_id: str + created_by_user_id: str + created_by_email: str + created_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + is_active: bool = True + label: Optional[str] = None # human-readable note e.g. "Sent to ACME 2026-05-01" + + +class ShareTokenResponse(BaseModel): + id: str + job_id: str + created_by_email: str + created_at: datetime + expires_at: Optional[datetime] = None + is_active: bool + label: Optional[str] = None + share_url: str # full public URL, assembled server-side diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 206ba63..b6586f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { GlossaryDetail } from './routes/admin/glossaries/GlossaryDetail'; import { AuditLog } from './routes/admin/AuditLog'; import { LinguistQueue } from './routes/jobs/LinguistQueue'; import { Downloads } from './routes/Downloads'; +import { ShareView } from './routes/ShareView'; import { AcceptInvite } from './routes/AcceptInvite'; import { NoAccess } from './routes/NoAccess'; import { OrgSettingsLayout } from './routes/org/OrgSettingsLayout'; @@ -59,6 +60,7 @@ function AppContent() {
{error}
+Read-only preview · Shared by Oliver
+No files available for this language yet.
+ )} ++ This is a read-only preview link. To request revisions, contact your Oliver project manager. +
+Generate a public link that lets the client preview this job without logging in. Link expires in 30 days.
+ + {!shareUrl ? ( + <> +Anyone with this link can view the job. Link expires in 30 days.
+