from __future__ import annotations import asyncio import hashlib from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.core.config import get_settings from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView from app.schemas import ( PublicAssetAnalyticsResponse, PublicAssetLookupResponse, PublicAssetResponse, PublishRequest, ShareLinkRequest, ) from app.services.publication import publish_version from app.services.storage import storage_client from dlib.auth import AuthenticatedUser from dlib.cv import docx_bytes_to_pdf, generate_patched_docx from dlib.integrations.paperless import get_paperless_client router = APIRouter(prefix="/public", tags=["public"]) async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None: ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "") ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None view = PublicAssetView( public_asset_id=asset.id, viewed_at=datetime.now(timezone.utc), user_agent=request.headers.get("user-agent", "")[:512] or None, ip_hash=ip_hash, ) session.add(view) await session.commit() async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset: stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True)) result = await session.execute(stmt) asset = result.scalars().one_or_none() if not asset: raise HTTPException(status_code=404, detail="Asset not found") return asset async def _assert_owner(session: AsyncSession, asset: PublicAsset, owner_id: str) -> None: if not asset.version_id: raise HTTPException(status_code=403, detail="Not authorized") stmt = ( select(CvVersion) .join(CvVersion.document) .where(CvVersion.id == asset.version_id, CvDocument.owner_id == owner_id) ) if not (await session.execute(stmt)).scalars().one_or_none(): raise HTTPException(status_code=403, detail="Not authorized") @router.post("/publish", response_model=PublicAssetResponse) async def publish( payload: PublishRequest, session: AsyncSession = Depends(get_db), user: AuthenticatedUser = Depends(get_current_user), ): asset = await publish_version( session, owner_id=user.sub, version_id=payload.version_id, submission_id=payload.submission_id, slug=payload.slug, expires_at=payload.expires_at, ) if not asset: raise HTTPException(status_code=404, detail="Version or submission not found") return _response_from_asset(asset) @router.post("/{slug}/share-links", response_model=PublicAssetResponse) async def create_share_link( slug: str, payload: ShareLinkRequest, session: AsyncSession = Depends(get_db), user: AuthenticatedUser = Depends(get_current_user), ): asset = await _get_public_asset(session, slug) await _assert_owner(session, asset, user.sub) if not asset.paperless_document_id: raise HTTPException(status_code=409, detail="Asset not synced to paperless") settings = get_settings() client = get_paperless_client(settings) if not client: raise HTTPException(status_code=503, detail="Paperless integration not enabled") _, share_url = await asyncio.to_thread( client.create_share_link, asset.paperless_document_id, payload.expiration_date ) asset.paperless_share_slug = share_url.split("/share/")[-1] await session.commit() await session.refresh(asset) return _response_from_asset(asset) @router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse) async def get_analytics( slug: str, session: AsyncSession = Depends(get_db), user: AuthenticatedUser = Depends(get_current_user), ): asset = await _get_public_asset(session, slug) await _assert_owner(session, asset, user.sub) view_count = ( await session.execute( select(func.count()).where(PublicAssetView.public_asset_id == asset.id) ) ).scalar() or 0 last_viewed_at = ( await session.execute( select(PublicAssetView.viewed_at) .where(PublicAssetView.public_asset_id == asset.id) .order_by(PublicAssetView.viewed_at.desc()) .limit(1) ) ).scalar() return PublicAssetAnalyticsResponse( slug=slug, view_count=view_count, last_viewed_at=last_viewed_at ) @router.get("/{slug}/pdf") async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)): asset = await _get_public_asset(session, slug) await _log_view(session, asset, request) version: CvVersion | None = None if asset.version_id: stmt = select(CvVersion).where(CvVersion.id == asset.version_id) version = (await session.execute(stmt)).scalars().one_or_none() docx_bytes = storage_client.download_bytes(key=asset.artifact_key) blocks = version.structured_blocks or [] if version else [] patched = generate_patched_docx(docx_bytes, blocks) pdf_bytes = docx_bytes_to_pdf(patched) return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'}, ) @router.get("/{slug}", response_model=PublicAssetLookupResponse) async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)): asset = await _get_public_asset(session, slug) await _log_view(session, asset, request) return PublicAssetLookupResponse(asset=_response_from_asset(asset)) def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse: settings = get_settings() base = settings.public_base_url.rstrip("/") paperless_share_url = ( f"{settings.paperless_base_url}/share/{asset.paperless_share_slug}" if settings.paperless_base_url and asset.paperless_share_slug else None ) return PublicAssetResponse( id=asset.id, slug=asset.slug, artifact_key=asset.artifact_key, is_public=asset.is_public, created_at=asset.created_at, version_id=asset.version_id, submission_id=asset.submission_id, url=f"{base}/cv/{asset.slug}", paperless_share_url=paperless_share_url, )