fractafrag/services/api/app/routers/users.py
John Lightner c4b8c0fe38 Tracks B+C+D: Auth system, renderer, full frontend shell
Track B — Auth & User System (complete):
- User registration with bcrypt + Turnstile verification
- JWT access/refresh token flow with httpOnly cookie rotation
- Redis refresh token blocklist for logout
- User profile + settings update endpoints (username, email)
- API key generation with bcrypt hashing (ff_key_ prefix)
- BYOK key management with AES-256-GCM encryption at rest
- Free tier rate limiting (5 shaders/month)
- Tier-gated endpoints (Pro/Studio for BYOK, API keys, bounty posting)

Track C — Shader Submission & Renderer (complete):
- GLSL validator: entry point check, banned extensions, infinite loop detection,
  brace balancing, loop bound warnings, code length limits
- Puppeteer/headless Chromium renderer with Shadertoy-compatible uniform injection
  (iTime, iResolution, iMouse), WebGL2 with SwiftShader fallback
- Shader compilation error detection via page title signaling
- Thumbnail capture at t=1s, preview frame at t=duration
- Renderer client service for API→renderer HTTP communication
- Shader submission pipeline: validate GLSL → create record → enqueue render job
- Desire fulfillment linking on shader submit
- Re-validation and re-render on shader code update
- Fork endpoint copies code, tags, metadata, enqueues new render

Track D — Frontend Shell (complete):
- React 18 + Vite + TypeScript + Tailwind CSS + TanStack Query + Zustand
- Dark theme with custom fracta color palette and surface tones
- Responsive layout with sticky navbar, gradient branding
- Auth: Login + Register pages with JWT token management
- API client with automatic 401 refresh interceptor
- ShaderCanvas: Full WebGL2 renderer component with Shadertoy uniforms,
  mouse tracking, ResizeObserver, debounced recompilation, error callbacks
- GLSL Editor: Split pane (code textarea + live preview), 400ms debounced
  preview, metadata panel (description, tags, type), GLSL validation errors,
  shader publish flow, fork-from-existing support
- Feed: Infinite scroll with IntersectionObserver sentinel, dwell time tracking,
  skeleton loading states, empty state with CTA
- Explore: Search + tag filter + sort tabs (trending/new/top), grid layout
- ShaderDetail: Full-screen preview, vote controls, view source toggle, fork button
- Bounties: Desire queue list sorted by heat score, status badges, tip display
- BountyDetail: Single desire view with style hints, fulfill CTA
- Profile: User header with avatar initial, shader grid
- Settings: Account info, API key management (create/revoke/copy), subscription tiers
- Generate: AI generation UI stub with prompt input, style controls, example prompts

76 files, ~5,700 lines of application code.
2026-03-24 20:56:42 -05:00

114 lines
4 KiB
Python

"""Users & Settings router."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import User
from app.schemas import UserPublic, UserMe, UserUpdate, ByokKeysUpdate
from app.middleware.auth import get_current_user, require_tier
from app.services.byok import encrypt_key, get_stored_providers
router = APIRouter()
@router.get("/users/{username}", response_model=UserPublic)
async def get_user_profile(username: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/me", response_model=UserMe)
async def get_me(user: User = Depends(get_current_user)):
return user
@router.put("/me", response_model=UserMe)
async def update_me(
body: UserUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
updates = body.model_dump(exclude_unset=True)
if not updates:
return user
# Check uniqueness for username/email changes
if "username" in updates and updates["username"] != user.username:
existing = await db.execute(
select(User).where(User.username == updates["username"])
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already taken")
if "email" in updates and updates["email"] != user.email:
existing = await db.execute(
select(User).where(User.email == updates["email"])
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already taken")
for field, value in updates.items():
setattr(user, field, value)
return user
@router.put("/me/ai-keys")
async def update_byok_keys(
body: ByokKeysUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_tier("pro", "studio")),
):
"""Store encrypted BYOK API keys for AI providers."""
from app.services.byok import save_user_keys
await save_user_keys(db, user, body)
providers = await get_stored_providers(db, user)
return {"status": "ok", "configured_providers": providers}
@router.get("/me/ai-keys")
async def get_byok_keys(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_tier("pro", "studio")),
):
"""List which providers have BYOK keys configured (never returns actual keys)."""
providers = await get_stored_providers(db, user)
return {"configured_providers": providers}
# ── Creator Economy Stubs (501) ─────────────────────────────
@router.get("/dashboard")
async def creator_dashboard(user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Creator dashboard coming in future release")
@router.get("/shaders/{shader_id}/unlock-status")
async def unlock_status(shader_id: str, user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Source unlock coming in future release")
@router.post("/shaders/{shader_id}/unlock")
async def unlock_source(shader_id: str, user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Source unlock coming in future release")
@router.post("/shaders/{shader_id}/commercial")
async def purchase_commercial(shader_id: str, user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Commercial licensing coming in future release")
@router.post("/me/creator/apply")
async def apply_verified(user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Verified creator program coming in future release")
@router.get("/me/creator/earnings")
async def creator_earnings(user: User = Depends(get_current_user)):
raise HTTPException(status_code=501, detail="Creator earnings coming in future release")