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.
232 lines
7.9 KiB
Python
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
|