import secrets from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import joinedload from sqlalchemy.exc import IntegrityError from app.core.database import get_db from app.core.admin_database import get_admin_db from app.models.session import Session from app.models.session_share import SessionShare, SessionShareView from app.models.user import User from app.models.account import Account from app.schemas.session_share import ShareCreate, ShareResponse, SharePublicView from app.api.deps import get_current_active_user, require_engineer_or_admin from app.core.audit import log_audit from app.core.rate_limit import limiter router = APIRouter(tags=["shares"]) def build_share_response(share: SessionShare) -> ShareResponse: return ShareResponse( id=share.id, session_id=share.session_id, account_id=share.account_id, share_token=share.share_token, share_name=share.share_name, visibility=share.visibility, created_by=share.created_by, created_at=share.created_at, updated_at=share.updated_at, expires_at=share.expires_at, view_count=share.view_count, last_viewed_at=share.last_viewed_at, is_active=share.is_active, ) # --- Session Share CRUD --- @router.post( "/sessions/{session_id}/shares", response_model=ShareResponse, status_code=status.HTTP_201_CREATED ) async def create_share( session_id: UUID, share_data: ShareCreate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_engineer_or_admin)] ): """Create a share link for a session. Only the session owner can create shares. Public shares require account.allow_public_shares policy. """ # Verify session exists and user owns it result = await db.execute( select(Session).where(Session.id == session_id) ) session = result.scalar_one_or_none() if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" ) if session.user_id != current_user.id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" ) # Require account_id for account-scoped shares if share_data.visibility == "account" and not current_user.account_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot create account-scoped share without an account" ) # Check account policy for public shares if share_data.visibility == "public" and current_user.account_id: account_result = await db.execute( select(Account).where(Account.id == current_user.account_id) ) account = account_result.scalar_one_or_none() if account and not account.allow_public_shares: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Your organization does not allow public session sharing. Use account-only visibility." ) # Generate token with collision retry max_retries = 3 for attempt in range(max_retries): try: share_token = secrets.token_urlsafe(48) share = SessionShare( session_id=session_id, account_id=current_user.account_id, share_token=share_token, share_name=share_data.share_name, visibility=share_data.visibility, created_by=current_user.id, expires_at=share_data.expires_at, ) db.add(share) await db.flush() await log_audit(db, current_user.id, "share.create", "session_share", share.id, {"session_id": str(session_id), "visibility": share_data.visibility}) await db.commit() await db.refresh(share) return build_share_response(share) except IntegrityError as e: await db.rollback() if "session_shares_share_token_key" in str(e) and attempt < max_retries - 1: continue raise @router.get("/shares/my-shares", response_model=list[ShareResponse]) async def list_my_shares( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100) ): """List all shares created by the current user.""" result = await db.execute( select(SessionShare) .where( SessionShare.created_by == current_user.id, SessionShare.is_active == True ) .order_by(SessionShare.created_at.desc()) .offset(skip) .limit(limit) ) shares = result.scalars().all() return [build_share_response(s) for s in shares] @router.delete("/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_share( share_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Revoke a share link (soft delete - sets is_active=False).""" result = await db.execute( select(SessionShare).where(SessionShare.id == share_id) ) share = result.scalar_one_or_none() if not share: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Share not found" ) if share.created_by != current_user.id and not current_user.is_super_admin: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Share not found" ) share.is_active = False await log_audit(db, current_user.id, "share.revoke", "session_share", share.id, {"session_id": str(share.session_id)}) await db.commit() return None # --- Public Share Access --- async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[User]: """Try to extract authenticated user from request, return None if not authenticated.""" auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return None token = auth_header.replace("Bearer ", "") try: from app.core.security import decode_token payload = decode_token(token) if not payload or payload.get("type") != "access": return None user_id = payload.get("sub") if not user_id: return None result = await db.execute(select(User).where(User.id == UUID(user_id))) return result.scalar_one_or_none() except Exception: return None @router.get("/share/{share_token}", response_model=SharePublicView) @limiter.limit("30/minute") async def access_share( share_token: str, request: Request, db: Annotated[AsyncSession, Depends(get_admin_db)], ): """Access a shared session via share token. Public shares: No authentication required. Account-only shares: Requires authentication + account membership. """ current_user = await _get_optional_user(request, db) # Lookup share result = await db.execute( select(SessionShare) .options(joinedload(SessionShare.session)) .where(SessionShare.share_token == share_token) ) share = result.scalar_one_or_none() # Validate share if not share or not share.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Share not found or has been revoked" ) if share.expires_at and share.expires_at < datetime.now(timezone.utc): raise HTTPException( status_code=status.HTTP_410_GONE, detail="Share link has expired" ) # Check visibility if share.visibility == "account": if not current_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="This share requires authentication" ) if current_user.account_id != share.account_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this session" ) # Record view session = share.session view = SessionShareView( share_id=share.id, session_id=session.id, viewer_id=current_user.id if current_user else None, viewer_ip=request.client.host if request.client else None, viewer_user_agent=request.headers.get("user-agent"), ) db.add(view) share.view_count += 1 share.last_viewed_at = datetime.now(timezone.utc) await db.commit() # Build read-only response tree_snapshot = session.tree_snapshot or {} return SharePublicView( session_id=session.id, tree_name=tree_snapshot.get("question", "Untitled Tree"), tree_description=tree_snapshot.get("description"), tree_structure=tree_snapshot, path_taken=session.path_taken or [], decisions=session.decisions or [], custom_steps=session.custom_steps or [], started_at=session.started_at, completed_at=session.completed_at, ticket_number=session.ticket_number, client_name=session.client_name, share_name=share.share_name, visibility=share.visibility, )