fractafrag/services/api/app/routers/shaders.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

232 lines
7.9 KiB
Python

"""Shaders router — CRUD, submit, fork, search."""
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models import User, Shader
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic
from app.middleware.auth import get_current_user, get_optional_user
from app.services.glsl_validator import validate_glsl
router = APIRouter()
@router.get("", response_model=list[ShaderPublic])
async def list_shaders(
q: str | None = Query(None, description="Search query"),
tags: list[str] | None = Query(None, description="Filter by tags"),
shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"),
sort: str = Query("trending", description="Sort: trending, new, top"),
limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
query = select(Shader).where(Shader.is_public == True, Shader.render_status == "ready")
if q:
query = query.where(Shader.title.ilike(f"%{q}%"))
if tags:
query = query.where(Shader.tags.overlap(tags))
if shader_type:
query = query.where(Shader.shader_type == shader_type)
if sort == "new":
query = query.order_by(Shader.created_at.desc())
elif sort == "top":
query = query.order_by(Shader.score.desc())
else: # trending
query = query.order_by(Shader.score.desc(), Shader.created_at.desc())
query = query.limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
@router.get("/{shader_id}", response_model=ShaderPublic)
async def get_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if not shader.is_public and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
# Increment view count
shader.view_count += 1
return shader
@router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def create_shader(
body: ShaderCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Rate limit: free tier gets 5 submissions/month
if user.subscription_tier == "free":
month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
count_result = await db.execute(
select(func.count()).select_from(Shader).where(
Shader.author_id == user.id,
Shader.created_at >= month_start,
)
)
monthly_count = count_result.scalar()
if monthly_count >= 5:
raise HTTPException(
status_code=429,
detail="Free tier: 5 shader submissions per month. Upgrade to Pro for unlimited."
)
# Validate GLSL
validation = validate_glsl(body.glsl_code, body.shader_type)
if not validation.valid:
raise HTTPException(
status_code=422,
detail={
"message": "GLSL validation failed",
"errors": validation.errors,
"warnings": validation.warnings,
}
)
shader = Shader(
author_id=user.id,
title=body.title,
description=body.description,
glsl_code=body.glsl_code,
tags=body.tags,
shader_type=body.shader_type,
is_public=body.is_public,
style_metadata=body.style_metadata,
render_status="pending",
)
db.add(shader)
await db.flush()
# Enqueue render job
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
# If Celery isn't available (dev without worker), mark as ready
# with no thumbnail — the frontend can still render live
shader.render_status = "ready"
# If this shader fulfills a desire, link them
if body.fulfills_desire_id:
from app.models import Desire
desire = (await db.execute(
select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open")
)).scalar_one_or_none()
if desire:
desire.status = "fulfilled"
desire.fulfilled_by_shader = shader.id
desire.fulfilled_at = datetime.now(timezone.utc)
return shader
@router.put("/{shader_id}", response_model=ShaderPublic)
async def update_shader(
shader_id: UUID,
body: ShaderUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
updates = body.model_dump(exclude_unset=True)
# Re-validate GLSL if code changed
if "glsl_code" in updates:
validation = validate_glsl(updates["glsl_code"], shader.shader_type)
if not validation.valid:
raise HTTPException(
status_code=422,
detail={
"message": "GLSL validation failed",
"errors": validation.errors,
"warnings": validation.warnings,
}
)
# Re-render if code changed
shader.render_status = "pending"
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
for field, value in updates.items():
setattr(shader, field, value)
shader.updated_at = datetime.now(timezone.utc)
return shader
@router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
await db.delete(shader)
@router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def fork_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
original = result.scalar_one_or_none()
if not original:
raise HTTPException(status_code=404, detail="Shader not found")
if not original.is_public:
raise HTTPException(status_code=404, detail="Shader not found")
forked = Shader(
author_id=user.id,
title=f"Fork of {original.title}",
description=f"Forked from {original.title}",
glsl_code=original.glsl_code,
tags=original.tags,
shader_type=original.shader_type,
forked_from=original.id,
style_metadata=original.style_metadata,
render_status="pending",
)
db.add(forked)
await db.flush()
# Enqueue render for the fork
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(forked.id)])
except Exception:
forked.render_status = "ready"
return forked