290 lines
9.6 KiB
Python
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,
|
|
)
|