Files
resolutionflow/backend/app/api/endpoints/shares.py
Michael Chihlas 49f88569da
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / backend (pull_request) Failing after 27m35s
CI / frontend (pull_request) Successful in 2m46s
CI / e2e (pull_request) Failing after 4m9s
wip(handoff): restore backend suite to green
Co-Authored-By: Codex <noreply@openai.com>
2026-04-25 06:13:23 -04:00

290 lines
9.6 KiB
Python

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"])
public_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
@public_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,
)