M0: Foundation scaffold — Docker Compose, DB schema, FastAPI app, all service stubs
Track A (Infrastructure & Data Layer): - docker-compose.yml with all 7 services (nginx, frontend, api, mcp, renderer, worker, postgres, redis) - docker-compose.override.yml for local dev (hot reload, port exposure) - PostgreSQL init.sql with full schema (15 tables, pgvector indexes, creator economy stubs) - .env.example with all required environment variables Track A+B (API Layer): - FastAPI app with 10 routers (auth, shaders, feed, votes, generate, desires, users, payments, mcp_keys, health) - SQLAlchemy ORM models for all 15 tables - Pydantic schemas for all request/response types - JWT auth middleware (access + refresh tokens, Redis blocklist) - Redis rate limiting middleware - Celery worker config with job stubs (render, embed, generate, feed cache, expire bounties) - Alembic migration framework Service stubs: - MCP server (health endpoint, 501 for all tools) - Renderer service (Express + Puppeteer scaffold, 501 for /render) - Frontend (package.json with React/Vite/Three.js/TanStack/Tailwind deps) - Nginx reverse proxy config (/, /api, /mcp, /renders) Project: - DECISIONS.md with 11 recorded architectural decisions - README.md with architecture overview - Sample shader seed data (plasma, fractal noise, raymarched sphere)
This commit is contained in:
parent
8cb2a50b6c
commit
05d39fdda8
46 changed files with 2931 additions and 0 deletions
43
.env.example
Normal file
43
.env.example
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Fractafrag Environment Variables
|
||||||
|
# Copy to .env and fill in values before running docker compose up
|
||||||
|
|
||||||
|
# ─── Database ───────────────────────────────────────────────
|
||||||
|
DB_PASS=changeme_use_a_real_password
|
||||||
|
POSTGRES_USER=fracta
|
||||||
|
POSTGRES_DB=fractafrag
|
||||||
|
|
||||||
|
# ─── Security ───────────────────────────────────────────────
|
||||||
|
JWT_SECRET=changeme_generate_with_openssl_rand_hex_64
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
|
# ─── Cloudflare Turnstile ───────────────────────────────────
|
||||||
|
TURNSTILE_SITE_KEY=your_turnstile_site_key
|
||||||
|
TURNSTILE_SECRET=your_turnstile_secret_key
|
||||||
|
|
||||||
|
# ─── Stripe ─────────────────────────────────────────────────
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# ─── AI Providers (platform keys for internal generation) ───
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# ─── MCP Server ─────────────────────────────────────────────
|
||||||
|
MCP_API_KEY_SALT=changeme_random_salt
|
||||||
|
|
||||||
|
# ─── Renderer ───────────────────────────────────────────────
|
||||||
|
MAX_RENDER_DURATION=8
|
||||||
|
RENDER_OUTPUT_DIR=/renders
|
||||||
|
|
||||||
|
# ─── BYOK Encryption ────────────────────────────────────────
|
||||||
|
BYOK_MASTER_KEY=changeme_generate_with_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# ─── Frontend (Vite) ────────────────────────────────────────
|
||||||
|
VITE_API_URL=http://localhost/api
|
||||||
|
VITE_MCP_URL=http://localhost/mcp
|
||||||
|
|
||||||
|
# ─── Redis ──────────────────────────────────────────────────
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# ─── Dependencies ─────────────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# ─── Environment ──────────────────────────────────────────
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# ─── Build artifacts ──────────────────────────────────────
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# ─── Docker volumes (local) ──────────────────────────────
|
||||||
|
pgdata/
|
||||||
|
redisdata/
|
||||||
|
renders/
|
||||||
|
|
||||||
|
# ─── IDE / Editor ─────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ─── GSD ──────────────────────────────────────────────────
|
||||||
|
.gsd/browser-state/
|
||||||
|
.gsd/browser-baselines/
|
||||||
|
.bg-shell/
|
||||||
|
|
||||||
|
# ─── SSL certs ────────────────────────────────────────────
|
||||||
|
services/nginx/certs/*.pem
|
||||||
|
services/nginx/certs/*.key
|
||||||
|
|
||||||
|
# ─── Alembic ──────────────────────────────────────────────
|
||||||
|
*.db
|
||||||
67
DECISIONS.md
Normal file
67
DECISIONS.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Fractafrag — Project Decisions
|
||||||
|
|
||||||
|
## D001 — Backend Language & Framework
|
||||||
|
- **Choice:** Python + FastAPI
|
||||||
|
- **Rationale:** AI/ML integrations (pgvector, LLM clients, embeddings) are Python-native. FastAPI gives async performance with Pydantic auto-generated OpenAPI docs. Celery + Redis is mature for job queues.
|
||||||
|
- **Made by:** Collaborative
|
||||||
|
- **Revisable:** No
|
||||||
|
|
||||||
|
## D002 — Frontend Stack
|
||||||
|
- **Choice:** React 18 + Vite + Three.js + TanStack Query + Zustand + Tailwind CSS
|
||||||
|
- **Rationale:** Three.js for 3D shader rendering, raw WebGL for feed thumbnails. React UI, TanStack Query for server state, Zustand for client state.
|
||||||
|
- **Made by:** Collaborative
|
||||||
|
- **Revisable:** No
|
||||||
|
|
||||||
|
## D003 — Database & Cache
|
||||||
|
- **Choice:** PostgreSQL 16 + pgvector + Redis 7
|
||||||
|
- **Rationale:** pgvector for taste/style/desire embeddings (ANN). Redis for sessions, feed cache, rate limiting, Celery broker.
|
||||||
|
- **Made by:** Collaborative
|
||||||
|
- **Revisable:** No
|
||||||
|
|
||||||
|
## D004 — Container Orchestration
|
||||||
|
- **Choice:** Single Docker Compose stack, self-hosted, no cloud dependencies
|
||||||
|
- **Rationale:** Self-contained with nginx reverse proxy. .env-driven config.
|
||||||
|
- **Made by:** Collaborative
|
||||||
|
- **Revisable:** No
|
||||||
|
|
||||||
|
## D005 — Media Storage (Q1)
|
||||||
|
- **Choice:** Docker volume initially, S3-compatible config flag for later migration
|
||||||
|
- **Rationale:** Volume is simplest for single-server. Add Minio/S3 when storage grows large.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D006 — Style Embedding Model (Q2)
|
||||||
|
- **Choice:** Heuristic classifier + LLM structured output for M1, fine-tune later
|
||||||
|
- **Rationale:** No training data yet for fine-tuning. Heuristic is fast/cheap, LLM fills accuracy gaps.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D007 — Renderer Approach (Q3)
|
||||||
|
- **Choice:** Puppeteer + Headless Chromium
|
||||||
|
- **Rationale:** Accurate browser-equivalent rendering. Profile at M2 and optimize if needed.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D008 — Generation Status UX (Q4)
|
||||||
|
- **Choice:** Polling for M5, SSE upgrade later
|
||||||
|
- **Rationale:** Simpler to implement. Generation takes 5-30s, 2s polling is acceptable UX.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D009 — Comments Scope (Q6)
|
||||||
|
- **Choice:** Defer to post-M5 polish sprint
|
||||||
|
- **Rationale:** Schema is in place. Feature is not on critical path for core product loop.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D010 — Moderation Approach (Q7)
|
||||||
|
- **Choice:** Admin API endpoints only (/api/v1/admin/queue). No admin UI for M4.
|
||||||
|
- **Rationale:** Simple approve/reject actions via API. Admin panel deferred until scale demands it.
|
||||||
|
- **Made by:** Agent (per spec recommendation)
|
||||||
|
- **Revisable:** Yes
|
||||||
|
|
||||||
|
## D011 — Creator Economy
|
||||||
|
- **Choice:** Deferred until organic traction (500 DAU, 1000 shaders, 20 active creators)
|
||||||
|
- **Rationale:** Build the hooks (schema stubs, engagement tracking), not the features. Monetization on a platform nobody uses is worthless.
|
||||||
|
- **Made by:** Collaborative (per spec Section 11)
|
||||||
|
- **Revisable:** Yes
|
||||||
79
README.md
Normal file
79
README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# 🔥 Fractafrag
|
||||||
|
|
||||||
|
**A self-hosted GLSL shader platform — browse, create, generate, and share real-time GPU visuals.**
|
||||||
|
|
||||||
|
Fractafrag fuses three experiences:
|
||||||
|
- **TikTok-style adaptive feed** of living, animated shaders that learns your taste
|
||||||
|
- **Shadertoy-style code editor** for writing, forking, and publishing GLSL shaders
|
||||||
|
- **AI generation layer** where you describe what you want and the platform writes the shader
|
||||||
|
|
||||||
|
Plus a **desire queue / bounty board** where users express what they want to see, and human creators or AI agents fulfill those requests.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and configure
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your secrets
|
||||||
|
|
||||||
|
# 2. Launch everything
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Open
|
||||||
|
open http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
nginx (reverse proxy)
|
||||||
|
├── / → React frontend (Vite)
|
||||||
|
├── /api/* → FastAPI backend
|
||||||
|
└── /mcp/* → MCP server (AI agent interface)
|
||||||
|
|
||||||
|
postgres (pgvector/pgvector:pg16) — primary datastore + vector similarity
|
||||||
|
redis (redis:7-alpine) — cache, rate limiting, job queue
|
||||||
|
renderer — headless Chromium shader renderer
|
||||||
|
worker — Celery job processor (render, embed, AI generate)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|-------|------|
|
||||||
|
| Frontend | React 18, Vite, Three.js, TanStack Query, Zustand, Tailwind CSS |
|
||||||
|
| Backend | Python, FastAPI, SQLAlchemy, Pydantic |
|
||||||
|
| Database | PostgreSQL 16 + pgvector, Redis 7 |
|
||||||
|
| Jobs | Celery + Redis |
|
||||||
|
| Renderer | Node.js + Puppeteer (Headless Chromium) |
|
||||||
|
| MCP | Python MCP SDK, HTTP+SSE transport |
|
||||||
|
| Payments | Stripe (subscriptions + Connect) |
|
||||||
|
| Container | Docker Compose, single-stack |
|
||||||
|
|
||||||
|
## Milestone Roadmap
|
||||||
|
|
||||||
|
| Milestone | Focus | Status |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| **M0** | Infrastructure + Auth | 🚧 In Progress |
|
||||||
|
| **M1** | Core Shader Loop (editor, submit, feed) | ⏳ |
|
||||||
|
| **M2** | Intelligence Layer (MCP, recommendations) | ⏳ |
|
||||||
|
| **M3** | Desire Economy (bounties, fulfillment) | ⏳ |
|
||||||
|
| **M4** | Monetization (Stripe, subscriptions) | ⏳ |
|
||||||
|
| **M5** | AI Generation (prompt → shader) | ⏳ |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API direct access (dev mode)
|
||||||
|
http://localhost:8000/api/docs # Swagger UI
|
||||||
|
http://localhost:8000/health # Health check
|
||||||
|
|
||||||
|
# Services
|
||||||
|
http://localhost:5173 # Vite dev server
|
||||||
|
http://localhost:3200 # MCP server
|
||||||
|
http://localhost:3100 # Renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private — see DECISIONS.md for project governance.
|
||||||
265
db/init.sql
Normal file
265
db/init.sql
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
-- Fractafrag Database Bootstrap
|
||||||
|
-- Runs on first container start via docker-entrypoint-initdb.d
|
||||||
|
|
||||||
|
-- Enable required extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- for text search
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- USERS
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user', -- user, moderator, admin
|
||||||
|
trust_tier TEXT NOT NULL DEFAULT 'standard', -- standard, creator, trusted_api
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
subscription_tier TEXT DEFAULT 'free', -- free, pro, studio
|
||||||
|
ai_credits_remaining INTEGER DEFAULT 0,
|
||||||
|
taste_vector vector(512), -- pgvector: learned taste embedding
|
||||||
|
-- Creator economy stubs (Section 11f — deferred, schema only)
|
||||||
|
is_verified_creator BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_creator_at TIMESTAMPTZ,
|
||||||
|
stripe_connect_account_id TEXT,
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_active_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- SHADERS
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE shaders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
glsl_code TEXT NOT NULL,
|
||||||
|
is_public BOOLEAN DEFAULT TRUE,
|
||||||
|
is_ai_generated BOOLEAN DEFAULT FALSE,
|
||||||
|
ai_provider TEXT, -- anthropic, openai, ollama, null
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
preview_url TEXT,
|
||||||
|
render_status TEXT DEFAULT 'pending', -- pending, rendering, ready, failed
|
||||||
|
style_vector vector(512), -- pgvector: visual style embedding
|
||||||
|
style_metadata JSONB, -- { chaos_level, color_temp, motion_type, ... }
|
||||||
|
tags TEXT[],
|
||||||
|
shader_type TEXT DEFAULT '2d', -- 2d, 3d, audio-reactive
|
||||||
|
forked_from UUID REFERENCES shaders(id) ON DELETE SET NULL,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
score FLOAT DEFAULT 0, -- cached hot score for feed ranking
|
||||||
|
-- Creator economy stubs (Section 11f)
|
||||||
|
access_tier TEXT DEFAULT 'open', -- open, source_locked, commercial
|
||||||
|
source_unlock_price_cents INTEGER,
|
||||||
|
commercial_license_price_cents INTEGER,
|
||||||
|
verified_creator_shader BOOLEAN DEFAULT FALSE,
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- VOTES
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
|
||||||
|
value SMALLINT NOT NULL CHECK (value IN (-1, 1)),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, shader_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- ENGAGEMENT EVENTS (dwell time, replays, shares)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE engagement_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- null for anonymous
|
||||||
|
session_id TEXT, -- anonymous session token
|
||||||
|
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL, -- dwell, replay, share, generate_similar
|
||||||
|
dwell_secs FLOAT,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- DESIRES / BOUNTIES
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE desires (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
prompt_embedding vector(512), -- embedded for similarity grouping
|
||||||
|
style_hints JSONB, -- { chaos_level, color_temp, etc }
|
||||||
|
tip_amount_cents INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'open', -- open, in_progress, fulfilled, expired
|
||||||
|
heat_score FLOAT DEFAULT 1, -- updated as similar desires accumulate
|
||||||
|
fulfilled_by_shader UUID REFERENCES shaders(id) ON DELETE SET NULL,
|
||||||
|
fulfilled_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Similar desire grouping (many-to-many)
|
||||||
|
CREATE TABLE desire_clusters (
|
||||||
|
cluster_id UUID,
|
||||||
|
desire_id UUID REFERENCES desires(id) ON DELETE CASCADE,
|
||||||
|
similarity FLOAT,
|
||||||
|
PRIMARY KEY (cluster_id, desire_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- BOUNTY TIPS
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE bounty_tips (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
desire_id UUID REFERENCES desires(id) ON DELETE CASCADE,
|
||||||
|
tipper_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
amount_cents INTEGER NOT NULL,
|
||||||
|
stripe_payment_intent_id TEXT,
|
||||||
|
status TEXT DEFAULT 'held', -- held, released, refunded
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- CREATOR PAYOUTS
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE creator_payouts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
creator_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
desire_id UUID REFERENCES desires(id) ON DELETE SET NULL,
|
||||||
|
gross_amount_cents INTEGER,
|
||||||
|
platform_fee_cents INTEGER, -- 10%
|
||||||
|
net_amount_cents INTEGER, -- 90%
|
||||||
|
stripe_transfer_id TEXT,
|
||||||
|
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- API KEYS (for MCP clients)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT UNIQUE NOT NULL, -- bcrypt hash of the actual key
|
||||||
|
key_prefix TEXT NOT NULL, -- first 8 chars for display (ff_key_XXXXXXXX)
|
||||||
|
name TEXT, -- user-given label
|
||||||
|
trust_tier TEXT DEFAULT 'probation', -- probation, trusted, premium
|
||||||
|
submissions_approved INTEGER DEFAULT 0,
|
||||||
|
rate_limit_per_hour INTEGER DEFAULT 10,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- AI GENERATION LOG
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE generation_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
shader_id UUID REFERENCES shaders(id) ON DELETE SET NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
prompt_text TEXT,
|
||||||
|
tokens_used INTEGER,
|
||||||
|
cost_cents INTEGER, -- platform cost for credit-based generations
|
||||||
|
success BOOLEAN,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- COMMENTS (schema in place, feature deferred to post-M5)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- CREATOR ECONOMY STUBS (Section 11f — dormant until activated)
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE source_unlocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
|
||||||
|
buyer_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
license_type TEXT NOT NULL, -- personal, commercial
|
||||||
|
amount_cents INTEGER NOT NULL,
|
||||||
|
platform_fee_cents INTEGER NOT NULL,
|
||||||
|
stripe_payment_intent_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE creator_engagement_snapshots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
creator_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
month DATE NOT NULL,
|
||||||
|
total_score FLOAT NOT NULL,
|
||||||
|
pool_share FLOAT,
|
||||||
|
payout_cents INTEGER,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- INDEXES
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Feed performance
|
||||||
|
CREATE INDEX idx_shaders_score ON shaders(score DESC) WHERE is_public = TRUE;
|
||||||
|
CREATE INDEX idx_shaders_created ON shaders(created_at DESC) WHERE is_public = TRUE;
|
||||||
|
CREATE INDEX idx_shaders_tags ON shaders USING GIN(tags);
|
||||||
|
CREATE INDEX idx_shaders_render_status ON shaders(render_status) WHERE render_status != 'ready';
|
||||||
|
|
||||||
|
-- Recommendation (pgvector ANN — ivfflat, will rebuild after data exists)
|
||||||
|
-- NOTE: ivfflat indexes require data in the table to build properly.
|
||||||
|
-- Run these AFTER seeding initial data:
|
||||||
|
-- CREATE INDEX idx_shaders_style_vector ON shaders
|
||||||
|
-- USING ivfflat (style_vector vector_cosine_ops) WITH (lists = 100);
|
||||||
|
-- CREATE INDEX idx_users_taste_vector ON users
|
||||||
|
-- USING ivfflat (taste_vector vector_cosine_ops) WITH (lists = 50);
|
||||||
|
-- CREATE INDEX idx_desires_embedding ON desires
|
||||||
|
-- USING ivfflat (prompt_embedding vector_cosine_ops) WITH (lists = 50);
|
||||||
|
|
||||||
|
-- For now, use HNSW (works on empty tables, better perf at small scale)
|
||||||
|
CREATE INDEX idx_shaders_style_vector ON shaders
|
||||||
|
USING hnsw (style_vector vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||||
|
CREATE INDEX idx_users_taste_vector ON users
|
||||||
|
USING hnsw (taste_vector vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||||
|
CREATE INDEX idx_desires_embedding ON desires
|
||||||
|
USING hnsw (prompt_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
|
||||||
|
|
||||||
|
-- Engagement
|
||||||
|
CREATE INDEX idx_engagement_user ON engagement_events(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_engagement_shader ON engagement_events(shader_id, event_type);
|
||||||
|
CREATE INDEX idx_engagement_session ON engagement_events(session_id, created_at DESC)
|
||||||
|
WHERE session_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Desires / bounties
|
||||||
|
CREATE INDEX idx_desires_status ON desires(status, heat_score DESC);
|
||||||
|
CREATE INDEX idx_desires_author ON desires(author_id);
|
||||||
|
|
||||||
|
-- API keys
|
||||||
|
CREATE INDEX idx_api_keys_user ON api_keys(user_id) WHERE revoked_at IS NULL;
|
||||||
|
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
|
||||||
|
|
||||||
|
-- Votes
|
||||||
|
CREATE INDEX idx_votes_shader ON votes(shader_id);
|
||||||
|
CREATE INDEX idx_votes_user ON votes(user_id);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
CREATE INDEX idx_comments_shader ON comments(shader_id, created_at);
|
||||||
|
CREATE INDEX idx_comments_parent ON comments(parent_id);
|
||||||
|
|
||||||
|
-- Text search
|
||||||
|
CREATE INDEX idx_shaders_title_trgm ON shaders USING GIN(title gin_trgm_ops);
|
||||||
|
CREATE INDEX idx_desires_prompt_trgm ON desires USING GIN(prompt_text gin_trgm_ops);
|
||||||
38
docker-compose.override.yml
Normal file
38
docker-compose.override.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# docker-compose.override.yml — Local dev overrides
|
||||||
|
# This file is automatically picked up by docker compose
|
||||||
|
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
volumes:
|
||||||
|
- ./services/api:/app
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # Direct access for debugging
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
volumes:
|
||||||
|
- ./services/frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
command: npm run dev -- --host 0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "5173:5173" # Vite dev server direct access
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
volumes:
|
||||||
|
- ./services/mcp:/app
|
||||||
|
ports:
|
||||||
|
- "3200:3200" # Direct MCP access
|
||||||
|
|
||||||
|
renderer:
|
||||||
|
ports:
|
||||||
|
- "3100:3100" # Direct renderer access
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "5432:5432" # Direct DB access for dev tools
|
||||||
|
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- "6379:6379" # Direct Redis access for dev tools
|
||||||
144
docker-compose.yml
Normal file
144
docker-compose.yml
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ─── Reverse Proxy ──────────────────────────────────────────
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./services/nginx/conf:/etc/nginx/conf.d:ro
|
||||||
|
- ./services/nginx/certs:/etc/ssl/certs:ro
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Frontend (React + Vite) ────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./services/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=${VITE_API_URL:-http://localhost/api}
|
||||||
|
- VITE_MCP_URL=${VITE_MCP_URL:-http://localhost/mcp}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── API (FastAPI) ──────────────────────────────────────────
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./services/api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-fracta}:${DB_PASS}@postgres:5432/${POSTGRES_DB:-fractafrag}
|
||||||
|
- DATABASE_URL_SYNC=postgresql://${POSTGRES_USER:-fracta}:${DB_PASS}@postgres:5432/${POSTGRES_DB:-fractafrag}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- JWT_ALGORITHM=${JWT_ALGORITHM:-HS256}
|
||||||
|
- JWT_ACCESS_TOKEN_EXPIRE_MINUTES=${JWT_ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||||
|
- JWT_REFRESH_TOKEN_EXPIRE_DAYS=${JWT_REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||||
|
- TURNSTILE_SECRET=${TURNSTILE_SECRET}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
- RENDERER_URL=http://renderer:3100
|
||||||
|
- BYOK_MASTER_KEY=${BYOK_MASTER_KEY}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── MCP Server ─────────────────────────────────────────────
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ./services/mcp
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=http://api:8000
|
||||||
|
- MCP_API_KEY_SALT=${MCP_API_KEY_SALT}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Renderer (Headless Chromium) ───────────────────────────
|
||||||
|
renderer:
|
||||||
|
build:
|
||||||
|
context: ./services/renderer
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
shm_size: "512mb"
|
||||||
|
environment:
|
||||||
|
- MAX_RENDER_DURATION=${MAX_RENDER_DURATION:-8}
|
||||||
|
- OUTPUT_DIR=${RENDER_OUTPUT_DIR:-/renders}
|
||||||
|
volumes:
|
||||||
|
- renders:/renders
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Worker (Celery) ────────────────────────────────────────
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./services/api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: celery -A app.worker.celery_app worker --loglevel=info --concurrency=4
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-fracta}:${DB_PASS}@postgres:5432/${POSTGRES_DB:-fractafrag}
|
||||||
|
- DATABASE_URL_SYNC=postgresql://${POSTGRES_USER:-fracta}:${DB_PASS}@postgres:5432/${POSTGRES_DB:-fractafrag}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- RENDERER_URL=http://renderer:3100
|
||||||
|
- BYOK_MASTER_KEY=${BYOK_MASTER_KEY}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
renderer:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── PostgreSQL + pgvector ──────────────────────────────────
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-fracta}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASS}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-fractafrag}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fracta} -d ${POSTGRES_DB:-fractafrag}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Redis ──────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
|
renders:
|
||||||
115
scripts/seed.py
Normal file
115
scripts/seed.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Fractafrag — Development seed data."""
|
||||||
|
|
||||||
|
# TODO: Implement seed script (Track A completion)
|
||||||
|
# This script will:
|
||||||
|
# 1. Create test users (admin, moderator, regular, pro, studio)
|
||||||
|
# 2. Insert sample shaders with known-good GLSL code
|
||||||
|
# 3. Create sample desires/bounties
|
||||||
|
# 4. Set up initial engagement data for recommendation testing
|
||||||
|
|
||||||
|
SAMPLE_SHADERS = [
|
||||||
|
{
|
||||||
|
"title": "Plasma Wave",
|
||||||
|
"glsl_code": """
|
||||||
|
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
vec2 uv = fragCoord / iResolution.xy;
|
||||||
|
float t = iTime;
|
||||||
|
float c = sin(uv.x * 10.0 + t) + sin(uv.y * 10.0 + t * 0.7);
|
||||||
|
c += sin((uv.x + uv.y) * 5.0 + t * 1.3);
|
||||||
|
c = c / 3.0 * 0.5 + 0.5;
|
||||||
|
fragColor = vec4(c, c * 0.5, 1.0 - c, 1.0);
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"tags": ["plasma", "colorful", "animated"],
|
||||||
|
"shader_type": "2d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fractal Noise",
|
||||||
|
"glsl_code": """
|
||||||
|
float hash(vec2 p) {
|
||||||
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
float noise(vec2 p) {
|
||||||
|
vec2 i = floor(p);
|
||||||
|
vec2 f = fract(p);
|
||||||
|
f = f * f * (3.0 - 2.0 * f);
|
||||||
|
return mix(
|
||||||
|
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
|
||||||
|
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
|
||||||
|
f.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
float fbm(vec2 p) {
|
||||||
|
float v = 0.0, a = 0.5;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
v += a * noise(p);
|
||||||
|
p *= 2.0;
|
||||||
|
a *= 0.5;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
vec2 uv = fragCoord / iResolution.xy;
|
||||||
|
float n = fbm(uv * 5.0 + iTime * 0.3);
|
||||||
|
fragColor = vec4(n * 0.3, n * 0.6, n, 1.0);
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"tags": ["noise", "fractal", "generative"],
|
||||||
|
"shader_type": "2d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ray March Sphere",
|
||||||
|
"glsl_code": """
|
||||||
|
float sdSphere(vec3 p, float r) { return length(p) - r; }
|
||||||
|
|
||||||
|
float map(vec3 p) {
|
||||||
|
return sdSphere(p - vec3(0.0, 0.0, 0.0), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 getNormal(vec3 p) {
|
||||||
|
vec2 e = vec2(0.001, 0.0);
|
||||||
|
return normalize(vec3(
|
||||||
|
map(p + e.xyy) - map(p - e.xyy),
|
||||||
|
map(p + e.yxy) - map(p - e.yxy),
|
||||||
|
map(p + e.yyx) - map(p - e.yyx)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
|
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
|
||||||
|
vec3 ro = vec3(0.0, 0.0, -3.0);
|
||||||
|
vec3 rd = normalize(vec3(uv, 1.0));
|
||||||
|
|
||||||
|
float t = 0.0;
|
||||||
|
for (int i = 0; i < 64; i++) {
|
||||||
|
vec3 p = ro + rd * t;
|
||||||
|
float d = map(p);
|
||||||
|
if (d < 0.001) break;
|
||||||
|
t += d;
|
||||||
|
if (t > 20.0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 col = vec3(0.05);
|
||||||
|
if (t < 20.0) {
|
||||||
|
vec3 p = ro + rd * t;
|
||||||
|
vec3 n = getNormal(p);
|
||||||
|
vec3 light = normalize(vec3(sin(iTime), 1.0, cos(iTime)));
|
||||||
|
float diff = max(dot(n, light), 0.0);
|
||||||
|
col = vec3(0.2, 0.5, 0.9) * diff + vec3(0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
fragColor = vec4(col, 1.0);
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"tags": ["raymarching", "3d", "sphere", "lighting"],
|
||||||
|
"shader_type": "3d",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Seed script — run with: python scripts/seed.py")
|
||||||
|
print(f"Sample shaders available: {len(SAMPLE_SHADERS)}")
|
||||||
|
# TODO: Connect to DB and insert seed data
|
||||||
21
services/api/Dockerfile
Normal file
21
services/api/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system deps
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python deps
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
|
# Copy app code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Default command (overridden in dev by docker-compose.override.yml)
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
39
services/api/alembic.ini
Normal file
39
services/api/alembic.ini
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Alembic configuration
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
sqlalchemy.url = postgresql://fracta:changeme@localhost:5432/fractafrag
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
services/api/app/__init__.py
Normal file
1
services/api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""App package."""
|
||||||
47
services/api/app/config.py
Normal file
47
services/api/app/config.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Fractafrag API — Application configuration."""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────
|
||||||
|
database_url: str = "postgresql+asyncpg://fracta:changeme@postgres:5432/fractafrag"
|
||||||
|
database_url_sync: str = "postgresql://fracta:changeme@postgres:5432/fractafrag"
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────
|
||||||
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
# ── JWT ───────────────────────────────────────────────────
|
||||||
|
jwt_secret: str = "changeme"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_access_token_expire_minutes: int = 15
|
||||||
|
jwt_refresh_token_expire_days: int = 30
|
||||||
|
|
||||||
|
# ── Cloudflare Turnstile ──────────────────────────────────
|
||||||
|
turnstile_secret: str = ""
|
||||||
|
|
||||||
|
# ── Stripe ────────────────────────────────────────────────
|
||||||
|
stripe_secret_key: str = ""
|
||||||
|
stripe_webhook_secret: str = ""
|
||||||
|
|
||||||
|
# ── Renderer ──────────────────────────────────────────────
|
||||||
|
renderer_url: str = "http://renderer:3100"
|
||||||
|
|
||||||
|
# ── BYOK Encryption ──────────────────────────────────────
|
||||||
|
byok_master_key: str = "changeme"
|
||||||
|
|
||||||
|
# ── AI Providers ──────────────────────────────────────────
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
openai_api_key: str = ""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
36
services/api/app/database.py
Normal file
36
services/api/app/database.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Fractafrag API — Database engine and session management."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy ORM models."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
"""FastAPI dependency: yields an async DB session."""
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
51
services/api/app/main.py
Normal file
51
services/api/app/main.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Fractafrag API — Main application entrypoint."""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.database import engine
|
||||||
|
from app.redis import close_redis
|
||||||
|
from app.routers import auth, shaders, feed, votes, generate, desires, users, payments, mcp_keys, health
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application startup and shutdown lifecycle."""
|
||||||
|
# Startup
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
await engine.dispose()
|
||||||
|
await close_redis()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Fractafrag API",
|
||||||
|
description="GLSL shader platform — browse, create, generate, and share real-time GPU visuals",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS — permissive in dev, lock down in production
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # TODO: restrict in production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Mount Routers ─────────────────────────────────────────
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
app.include_router(shaders.router, prefix="/api/v1/shaders", tags=["shaders"])
|
||||||
|
app.include_router(feed.router, prefix="/api/v1/feed", tags=["feed"])
|
||||||
|
app.include_router(votes.router, prefix="/api/v1", tags=["votes"])
|
||||||
|
app.include_router(generate.router, prefix="/api/v1/generate", tags=["generate"])
|
||||||
|
app.include_router(desires.router, prefix="/api/v1/desires", tags=["desires"])
|
||||||
|
app.include_router(users.router, prefix="/api/v1", tags=["users"])
|
||||||
|
app.include_router(payments.router, prefix="/api/v1/payments", tags=["payments"])
|
||||||
|
app.include_router(mcp_keys.router, prefix="/api/v1/me/api-keys", tags=["api-keys"])
|
||||||
1
services/api/app/middleware/__init__.py
Normal file
1
services/api/app/middleware/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Middleware package."""
|
||||||
144
services/api/app/middleware/auth.py
Normal file
144
services/api/app/middleware/auth.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Fractafrag — JWT Authentication middleware and dependencies."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status, Request, Response
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User
|
||||||
|
from app.redis import get_redis
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password Hashing ──────────────────────────────────────
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT Token Management ──────────────────────────────────
|
||||||
|
|
||||||
|
def create_access_token(user_id: UUID, username: str, role: str, tier: str) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
"tier": tier,
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_access_token_expire_minutes),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: UUID) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"type": "refresh",
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(days=settings.jwt_refresh_token_expire_days),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Refresh Token Blocklist (Redis) ───────────────────────
|
||||||
|
|
||||||
|
async def is_token_blocklisted(token: str) -> bool:
|
||||||
|
redis = await get_redis()
|
||||||
|
return await redis.exists(f"blocklist:{token}")
|
||||||
|
|
||||||
|
|
||||||
|
async def blocklist_token(token: str, ttl_seconds: int):
|
||||||
|
redis = await get_redis()
|
||||||
|
await redis.setex(f"blocklist:{token}", ttl_seconds, "1")
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI Dependencies ──────────────────────────────────
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Require authentication. Returns the current user."""
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
|
||||||
|
if payload.get("type") == "refresh":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot use refresh token for API access")
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Optional authentication. Returns user or None for anonymous requests."""
|
||||||
|
if credentials is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload.get("type") == "refresh":
|
||||||
|
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 HTTPException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles: str):
|
||||||
|
"""Dependency factory: require user to have one of the specified roles."""
|
||||||
|
async def check_role(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.role not in roles:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
return check_role
|
||||||
|
|
||||||
|
|
||||||
|
def require_tier(*tiers: str):
|
||||||
|
"""Dependency factory: require user to have one of the specified subscription tiers."""
|
||||||
|
async def check_tier(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.subscription_tier not in tiers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"This feature requires one of: {', '.join(tiers)}"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return check_tier
|
||||||
59
services/api/app/middleware/rate_limit.py
Normal file
59
services/api/app/middleware/rate_limit.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Fractafrag — Redis-backed rate limiting middleware."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from fastapi import Request, HTTPException, status
|
||||||
|
from app.redis import get_redis
|
||||||
|
|
||||||
|
|
||||||
|
async def check_rate_limit(
|
||||||
|
key: str,
|
||||||
|
max_requests: int,
|
||||||
|
window_seconds: int = 60,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check and enforce rate limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Unique identifier (e.g., "ip:1.2.3.4" or "user:uuid")
|
||||||
|
max_requests: Maximum requests allowed in the window
|
||||||
|
window_seconds: Time window in seconds
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 429 if rate limit exceeded
|
||||||
|
"""
|
||||||
|
redis = await get_redis()
|
||||||
|
redis_key = f"ratelimit:{key}"
|
||||||
|
|
||||||
|
pipe = redis.pipeline()
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - window_seconds
|
||||||
|
|
||||||
|
# Remove old entries outside the window
|
||||||
|
pipe.zremrangebyscore(redis_key, 0, window_start)
|
||||||
|
# Count current entries
|
||||||
|
pipe.zcard(redis_key)
|
||||||
|
# Add current request
|
||||||
|
pipe.zadd(redis_key, {str(now): now})
|
||||||
|
# Set TTL on the key
|
||||||
|
pipe.expire(redis_key, window_seconds)
|
||||||
|
|
||||||
|
results = await pipe.execute()
|
||||||
|
current_count = results[1]
|
||||||
|
|
||||||
|
if current_count >= max_requests:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"Rate limit exceeded. Max {max_requests} requests per {window_seconds}s.",
|
||||||
|
headers={"Retry-After": str(window_seconds)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_ip(request: Request, max_requests: int = 100):
|
||||||
|
"""Rate limit by IP address. Default: 100 req/min."""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
await check_rate_limit(f"ip:{ip}", max_requests)
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_user(user_id: str, max_requests: int = 300):
|
||||||
|
"""Rate limit by user ID. Default: 300 req/min."""
|
||||||
|
await check_rate_limit(f"user:{user_id}", max_requests)
|
||||||
12
services/api/app/models/__init__.py
Normal file
12
services/api/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""Models package."""
|
||||||
|
from app.models.models import (
|
||||||
|
User, Shader, Vote, EngagementEvent, Desire, DesireCluster,
|
||||||
|
BountyTip, CreatorPayout, ApiKey, GenerationLog, Comment,
|
||||||
|
SourceUnlock, CreatorEngagementSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User", "Shader", "Vote", "EngagementEvent", "Desire", "DesireCluster",
|
||||||
|
"BountyTip", "CreatorPayout", "ApiKey", "GenerationLog", "Comment",
|
||||||
|
"SourceUnlock", "CreatorEngagementSnapshot",
|
||||||
|
]
|
||||||
222
services/api/app/models/models.py
Normal file
222
services/api/app/models/models.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Fractafrag — SQLAlchemy ORM Models."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, String, Text, Boolean, Integer, Float, SmallInteger,
|
||||||
|
ForeignKey, DateTime, UniqueConstraint, Index, CheckConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
username = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
email = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String, nullable=False)
|
||||||
|
role = Column(String, nullable=False, default="user")
|
||||||
|
trust_tier = Column(String, nullable=False, default="standard")
|
||||||
|
stripe_customer_id = Column(String, nullable=True)
|
||||||
|
subscription_tier = Column(String, default="free")
|
||||||
|
ai_credits_remaining = Column(Integer, default=0)
|
||||||
|
taste_vector = Column(Vector(512), nullable=True)
|
||||||
|
# Creator economy stubs
|
||||||
|
is_verified_creator = Column(Boolean, default=False)
|
||||||
|
verified_creator_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
stripe_connect_account_id = Column(String, nullable=True)
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
last_active_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
shaders = relationship("Shader", back_populates="author")
|
||||||
|
votes = relationship("Vote", back_populates="user")
|
||||||
|
api_keys = relationship("ApiKey", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Shader(Base):
|
||||||
|
__tablename__ = "shaders"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
glsl_code = Column(Text, nullable=False)
|
||||||
|
is_public = Column(Boolean, default=True)
|
||||||
|
is_ai_generated = Column(Boolean, default=False)
|
||||||
|
ai_provider = Column(String, nullable=True)
|
||||||
|
thumbnail_url = Column(String, nullable=True)
|
||||||
|
preview_url = Column(String, nullable=True)
|
||||||
|
render_status = Column(String, default="pending")
|
||||||
|
style_vector = Column(Vector(512), nullable=True)
|
||||||
|
style_metadata = Column(JSONB, nullable=True)
|
||||||
|
tags = Column(ARRAY(String), default=list)
|
||||||
|
shader_type = Column(String, default="2d")
|
||||||
|
forked_from = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
view_count = Column(Integer, default=0)
|
||||||
|
score = Column(Float, default=0.0)
|
||||||
|
# Creator economy stubs
|
||||||
|
access_tier = Column(String, default="open")
|
||||||
|
source_unlock_price_cents = Column(Integer, nullable=True)
|
||||||
|
commercial_license_price_cents = Column(Integer, nullable=True)
|
||||||
|
verified_creator_shader = Column(Boolean, default=False)
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
author = relationship("User", back_populates="shaders")
|
||||||
|
votes = relationship("Vote", back_populates="shader")
|
||||||
|
|
||||||
|
|
||||||
|
class Vote(Base):
|
||||||
|
__tablename__ = "votes"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "shader_id"),)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
value = Column(SmallInteger, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="votes")
|
||||||
|
shader = relationship("Shader", back_populates="votes")
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementEvent(Base):
|
||||||
|
__tablename__ = "engagement_events"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
session_id = Column(String, nullable=True)
|
||||||
|
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
event_type = Column(String, nullable=False)
|
||||||
|
dwell_secs = Column(Float, nullable=True)
|
||||||
|
metadata = Column(JSONB, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Desire(Base):
|
||||||
|
__tablename__ = "desires"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
prompt_text = Column(Text, nullable=False)
|
||||||
|
prompt_embedding = Column(Vector(512), nullable=True)
|
||||||
|
style_hints = Column(JSONB, nullable=True)
|
||||||
|
tip_amount_cents = Column(Integer, default=0)
|
||||||
|
status = Column(String, default="open")
|
||||||
|
heat_score = Column(Float, default=1.0)
|
||||||
|
fulfilled_by_shader = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
fulfilled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class DesireCluster(Base):
|
||||||
|
__tablename__ = "desire_clusters"
|
||||||
|
|
||||||
|
cluster_id = Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
similarity = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class BountyTip(Base):
|
||||||
|
__tablename__ = "bounty_tips"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
tipper_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
amount_cents = Column(Integer, nullable=False)
|
||||||
|
stripe_payment_intent_id = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="held")
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatorPayout(Base):
|
||||||
|
__tablename__ = "creator_payouts"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
gross_amount_cents = Column(Integer)
|
||||||
|
platform_fee_cents = Column(Integer)
|
||||||
|
net_amount_cents = Column(Integer)
|
||||||
|
stripe_transfer_id = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="pending")
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
key_hash = Column(String, unique=True, nullable=False)
|
||||||
|
key_prefix = Column(String, nullable=False)
|
||||||
|
name = Column(String, nullable=True)
|
||||||
|
trust_tier = Column(String, default="probation")
|
||||||
|
submissions_approved = Column(Integer, default=0)
|
||||||
|
rate_limit_per_hour = Column(Integer, default=10)
|
||||||
|
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="api_keys")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationLog(Base):
|
||||||
|
__tablename__ = "generation_log"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
provider = Column(String, nullable=False)
|
||||||
|
prompt_text = Column(Text, nullable=True)
|
||||||
|
tokens_used = Column(Integer, nullable=True)
|
||||||
|
cost_cents = Column(Integer, nullable=True)
|
||||||
|
success = Column(Boolean, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(Base):
|
||||||
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
parent_id = Column(UUID(as_uuid=True), ForeignKey("comments.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# Creator economy stubs (dormant)
|
||||||
|
class SourceUnlock(Base):
|
||||||
|
__tablename__ = "source_unlocks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
buyer_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
license_type = Column(String, nullable=False)
|
||||||
|
amount_cents = Column(Integer, nullable=False)
|
||||||
|
platform_fee_cents = Column(Integer, nullable=False)
|
||||||
|
stripe_payment_intent_id = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatorEngagementSnapshot(Base):
|
||||||
|
__tablename__ = "creator_engagement_snapshots"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
month = Column(DateTime, nullable=False)
|
||||||
|
total_score = Column(Float, nullable=False)
|
||||||
|
pool_share = Column(Float, nullable=True)
|
||||||
|
payout_cents = Column(Integer, nullable=True)
|
||||||
|
paid_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
28
services/api/app/redis.py
Normal file
28
services/api/app/redis.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Fractafrag — Redis connection manager."""
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
redis_client: redis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> redis.Redis:
|
||||||
|
"""Get or create the Redis connection."""
|
||||||
|
global redis_client
|
||||||
|
if redis_client is None:
|
||||||
|
redis_client = redis.from_url(
|
||||||
|
settings.redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
return redis_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis():
|
||||||
|
"""Close Redis connection on shutdown."""
|
||||||
|
global redis_client
|
||||||
|
if redis_client:
|
||||||
|
await redis_client.close()
|
||||||
|
redis_client = None
|
||||||
1
services/api/app/routers/__init__.py
Normal file
1
services/api/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Routers package."""
|
||||||
157
services/api/app/routers/auth.py
Normal file
157
services/api/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Auth router — registration, login, refresh, logout."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserMe
|
||||||
|
from app.middleware.auth import (
|
||||||
|
hash_password, verify_password,
|
||||||
|
create_access_token, create_refresh_token,
|
||||||
|
decode_token, blocklist_token, is_token_blocklisted,
|
||||||
|
get_current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
REFRESH_COOKIE_NAME = "fractafrag_refresh"
|
||||||
|
REFRESH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_turnstile(token: str) -> bool:
|
||||||
|
"""Verify Cloudflare Turnstile token server-side."""
|
||||||
|
if not settings.turnstile_secret:
|
||||||
|
return True # Skip in dev if not configured
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
data={"secret": settings.turnstile_secret, "response": token},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
return result.get("success", False)
|
||||||
|
|
||||||
|
|
||||||
|
def set_refresh_cookie(response: Response, token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=REFRESH_COOKIE_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/v1/auth",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(
|
||||||
|
body: UserRegister,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Verify Turnstile
|
||||||
|
if not await verify_turnstile(body.turnstile_token):
|
||||||
|
raise HTTPException(status_code=400, detail="CAPTCHA verification failed")
|
||||||
|
|
||||||
|
# Check for existing user
|
||||||
|
existing = await db.execute(
|
||||||
|
select(User).where((User.email == body.email) | (User.username == body.username))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Username or email already taken")
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
username=body.username,
|
||||||
|
email=body.email,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Issue tokens
|
||||||
|
access = create_access_token(user.id, user.username, user.role, user.subscription_tier)
|
||||||
|
refresh = create_refresh_token(user.id)
|
||||||
|
set_refresh_cookie(response, refresh)
|
||||||
|
|
||||||
|
return TokenResponse(access_token=access)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(
|
||||||
|
body: UserLogin,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not await verify_turnstile(body.turnstile_token):
|
||||||
|
raise HTTPException(status_code=400, detail="CAPTCHA verification failed")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(body.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||||
|
|
||||||
|
# Update last active
|
||||||
|
user.last_active_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
access = create_access_token(user.id, user.username, user.role, user.subscription_tier)
|
||||||
|
refresh = create_refresh_token(user.id)
|
||||||
|
set_refresh_cookie(response, refresh)
|
||||||
|
|
||||||
|
return TokenResponse(access_token=access)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh_token(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
token = request.cookies.get(REFRESH_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="No refresh token")
|
||||||
|
|
||||||
|
if await is_token_blocklisted(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Token has been revoked")
|
||||||
|
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=401, detail="Not a refresh token")
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
# Rotate: blocklist old refresh, issue new pair
|
||||||
|
ttl = settings.jwt_refresh_token_expire_days * 86400
|
||||||
|
await blocklist_token(token, ttl)
|
||||||
|
|
||||||
|
access = create_access_token(user.id, user.username, user.role, user.subscription_tier)
|
||||||
|
new_refresh = create_refresh_token(user.id)
|
||||||
|
set_refresh_cookie(response, new_refresh)
|
||||||
|
|
||||||
|
return TokenResponse(access_token=access)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
token = request.cookies.get(REFRESH_COOKIE_NAME)
|
||||||
|
if token:
|
||||||
|
ttl = settings.jwt_refresh_token_expire_days * 86400
|
||||||
|
await blocklist_token(token, ttl)
|
||||||
|
|
||||||
|
response.delete_cookie(REFRESH_COOKIE_NAME, path="/api/v1/auth")
|
||||||
97
services/api/app/routers/desires.py
Normal file
97
services/api/app/routers/desires.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Desires & Bounties router."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User, Desire
|
||||||
|
from app.schemas import DesireCreate, DesirePublic
|
||||||
|
from app.middleware.auth import get_current_user, require_tier
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DesirePublic])
|
||||||
|
async def list_desires(
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
min_heat: float = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(Desire).where(Desire.heat_score >= min_heat)
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(Desire.status == status_filter)
|
||||||
|
else:
|
||||||
|
query = query.where(Desire.status == "open")
|
||||||
|
|
||||||
|
query = query.order_by(Desire.heat_score.desc()).limit(limit).offset(offset)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{desire_id}", response_model=DesirePublic)
|
||||||
|
async def get_desire(desire_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(Desire).where(Desire.id == desire_id))
|
||||||
|
desire = result.scalar_one_or_none()
|
||||||
|
if not desire:
|
||||||
|
raise HTTPException(status_code=404, detail="Desire not found")
|
||||||
|
return desire
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DesirePublic, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_desire(
|
||||||
|
body: DesireCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_tier("pro", "studio")),
|
||||||
|
):
|
||||||
|
desire = Desire(
|
||||||
|
author_id=user.id,
|
||||||
|
prompt_text=body.prompt_text,
|
||||||
|
style_hints=body.style_hints,
|
||||||
|
)
|
||||||
|
db.add(desire)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# TODO: Embed prompt text (Track G)
|
||||||
|
# TODO: Check similarity clustering (Track G)
|
||||||
|
# TODO: Enqueue process_desire worker job (Track G)
|
||||||
|
|
||||||
|
return desire
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{desire_id}/fulfill", status_code=status.HTTP_200_OK)
|
||||||
|
async def fulfill_desire(
|
||||||
|
desire_id: UUID,
|
||||||
|
shader_id: UUID = Query(..., description="Shader that fulfills this desire"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark a desire as fulfilled by a shader. (Track G)"""
|
||||||
|
desire = (await db.execute(select(Desire).where(Desire.id == desire_id))).scalar_one_or_none()
|
||||||
|
if not desire:
|
||||||
|
raise HTTPException(status_code=404, detail="Desire not found")
|
||||||
|
if desire.status != "open":
|
||||||
|
raise HTTPException(status_code=400, detail="Desire is not open")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
desire.status = "fulfilled"
|
||||||
|
desire.fulfilled_by_shader = shader_id
|
||||||
|
desire.fulfilled_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
return {"status": "fulfilled", "desire_id": desire_id, "shader_id": shader_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{desire_id}/tip")
|
||||||
|
async def tip_desire(
|
||||||
|
desire_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_tier("pro", "studio")),
|
||||||
|
):
|
||||||
|
"""Add a tip to a bounty. (Track H — stub)"""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Bounty tipping coming in M4"
|
||||||
|
)
|
||||||
86
services/api/app/routers/feed.py
Normal file
86
services/api/app/routers/feed.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Feed router — personalized feed, trending, new."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User, Shader
|
||||||
|
from app.schemas import ShaderFeedItem, DwellReport
|
||||||
|
from app.middleware.auth import get_optional_user, get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ShaderFeedItem])
|
||||||
|
async def get_feed(
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
cursor: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Personalized feed for authenticated users (pgvector taste match).
|
||||||
|
Trending/new for anonymous users.
|
||||||
|
"""
|
||||||
|
# TODO: Implement full recommendation engine (Track F)
|
||||||
|
# For now: return newest public shaders
|
||||||
|
query = (
|
||||||
|
select(Shader)
|
||||||
|
.where(Shader.is_public == True, Shader.render_status == "ready")
|
||||||
|
.order_by(Shader.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trending", response_model=list[ShaderFeedItem])
|
||||||
|
async def get_trending(
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(Shader)
|
||||||
|
.where(Shader.is_public == True, Shader.render_status == "ready")
|
||||||
|
.order_by(Shader.score.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/new", response_model=list[ShaderFeedItem])
|
||||||
|
async def get_new(
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(Shader)
|
||||||
|
.where(Shader.is_public == True, Shader.render_status == "ready")
|
||||||
|
.order_by(Shader.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dwell", status_code=204)
|
||||||
|
async def report_dwell(
|
||||||
|
body: DwellReport,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Report dwell time signal for recommendation engine."""
|
||||||
|
from app.models import EngagementEvent
|
||||||
|
|
||||||
|
event = EngagementEvent(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
session_id=body.session_id,
|
||||||
|
shader_id=body.shader_id,
|
||||||
|
event_type="dwell",
|
||||||
|
dwell_secs=body.dwell_secs,
|
||||||
|
metadata={"replayed": body.replayed},
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
# TODO: Update user taste vector (Track F)
|
||||||
49
services/api/app/routers/generate.py
Normal file
49
services/api/app/routers/generate.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""AI Generation router — start generation, poll status, check credits."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas import GenerateRequest, GenerateStatusResponse
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=GenerateStatusResponse)
|
||||||
|
async def start_generation(
|
||||||
|
body: GenerateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Start an AI shader generation job. (Track I — stub)"""
|
||||||
|
# TODO: Implement in Track I
|
||||||
|
# - Credits check / BYOK validation
|
||||||
|
# - Enqueue ai_generate job
|
||||||
|
# - Return job_id for polling
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="AI generation coming in M5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{job_id}", response_model=GenerateStatusResponse)
|
||||||
|
async def get_generation_status(
|
||||||
|
job_id: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Poll AI generation job status. (Track I — stub)"""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="AI generation coming in M5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credits")
|
||||||
|
async def get_credits(user: User = Depends(get_current_user)):
|
||||||
|
"""Check remaining AI generation credits."""
|
||||||
|
return {
|
||||||
|
"credits_remaining": user.ai_credits_remaining,
|
||||||
|
"subscription_tier": user.subscription_tier,
|
||||||
|
}
|
||||||
32
services/api/app/routers/health.py
Normal file
32
services/api/app/routers/health.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Health check endpoint — outside /api/v1 prefix for Docker healthchecks."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.redis import get_redis
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Basic health check — verifies API, DB, and Redis are reachable."""
|
||||||
|
checks = {"api": "ok", "database": "error", "redis": "error"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
await redis.ping()
|
||||||
|
checks["redis"] = "ok"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
healthy = all(v == "ok" for v in checks.values())
|
||||||
|
return {"status": "healthy" if healthy else "degraded", "checks": checks}
|
||||||
85
services/api/app/routers/mcp_keys.py
Normal file
85
services/api/app/routers/mcp_keys.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""MCP API Key management router."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User, ApiKey
|
||||||
|
from app.schemas import ApiKeyCreate, ApiKeyPublic, ApiKeyCreated
|
||||||
|
from app.middleware.auth import get_current_user, require_tier
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> tuple[str, str, str]:
|
||||||
|
"""Generate an API key. Returns (full_key, prefix, hash)."""
|
||||||
|
raw = secrets.token_bytes(32)
|
||||||
|
# base58-like encoding using alphanumeric chars
|
||||||
|
import base64
|
||||||
|
encoded = base64.b32encode(raw).decode().rstrip("=").lower()
|
||||||
|
full_key = f"ff_key_{encoded}"
|
||||||
|
prefix = full_key[:16] # ff_key_ + 8 chars
|
||||||
|
key_hash = pwd_context.hash(full_key)
|
||||||
|
return full_key, prefix, key_hash
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ApiKeyPublic])
|
||||||
|
async def list_api_keys(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiKey).where(ApiKey.user_id == user.id, ApiKey.revoked_at == None)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_api_key(
|
||||||
|
body: ApiKeyCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_tier("pro", "studio")),
|
||||||
|
):
|
||||||
|
full_key, prefix, key_hash = generate_api_key()
|
||||||
|
|
||||||
|
api_key = ApiKey(
|
||||||
|
user_id=user.id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=prefix,
|
||||||
|
name=body.name,
|
||||||
|
)
|
||||||
|
db.add(api_key)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return ApiKeyCreated(
|
||||||
|
id=api_key.id,
|
||||||
|
key_prefix=prefix,
|
||||||
|
name=body.name,
|
||||||
|
trust_tier=api_key.trust_tier,
|
||||||
|
rate_limit_per_hour=api_key.rate_limit_per_hour,
|
||||||
|
last_used_at=None,
|
||||||
|
created_at=api_key.created_at,
|
||||||
|
full_key=full_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def revoke_api_key(
|
||||||
|
key_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user.id)
|
||||||
|
)
|
||||||
|
api_key = result.scalar_one_or_none()
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
api_key.revoked_at = datetime.now(timezone.utc)
|
||||||
38
services/api/app/routers/payments.py
Normal file
38
services/api/app/routers/payments.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Payments router — Stripe subscriptions, credits, webhooks. (Track H — stubs)"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout")
|
||||||
|
async def create_checkout(user: User = Depends(get_current_user)):
|
||||||
|
"""Create Stripe checkout session for subscription. (Track H)"""
|
||||||
|
raise HTTPException(status_code=501, detail="Payments coming in M4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def stripe_webhook(request: Request):
|
||||||
|
"""Handle Stripe webhook events. (Track H)"""
|
||||||
|
raise HTTPException(status_code=501, detail="Payments coming in M4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portal")
|
||||||
|
async def customer_portal(user: User = Depends(get_current_user)):
|
||||||
|
"""Get Stripe customer portal URL. (Track H)"""
|
||||||
|
raise HTTPException(status_code=501, detail="Payments coming in M4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/credits")
|
||||||
|
async def purchase_credits(user: User = Depends(get_current_user)):
|
||||||
|
"""Purchase AI credit pack. (Track H)"""
|
||||||
|
raise HTTPException(status_code=501, detail="Payments coming in M4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect/onboard")
|
||||||
|
async def connect_onboard(user: User = Depends(get_current_user)):
|
||||||
|
"""Start Stripe Connect creator onboarding. (Track H)"""
|
||||||
|
raise HTTPException(status_code=501, detail="Payments coming in M4")
|
||||||
154
services/api/app/routers/shaders.py
Normal file
154
services/api/app/routers/shaders.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""Shaders router — CRUD, submit, fork, search."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, or_
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
# TODO: Turnstile verification for submit
|
||||||
|
# TODO: Rate limit check (free tier: 5/month)
|
||||||
|
# TODO: GLSL validation via glslang
|
||||||
|
# TODO: Enqueue render job
|
||||||
|
|
||||||
|
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()
|
||||||
|
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")
|
||||||
|
|
||||||
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(shader, field, value)
|
||||||
|
|
||||||
|
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,
|
||||||
|
render_status="pending",
|
||||||
|
)
|
||||||
|
db.add(forked)
|
||||||
|
await db.flush()
|
||||||
|
return forked
|
||||||
68
services/api/app/routers/users.py
Normal file
68
services/api/app/routers/users.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""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
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
|
||||||
|
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(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update user settings. (Expanded in Track B)"""
|
||||||
|
# TODO: Accept settings updates (username, email, etc.)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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")
|
||||||
68
services/api/app/routers/votes.py
Normal file
68
services/api/app/routers/votes.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Votes & engagement router."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import User, Shader, Vote, EngagementEvent
|
||||||
|
from app.schemas import VoteCreate
|
||||||
|
from app.middleware.auth import get_current_user, get_optional_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shaders/{shader_id}/vote", status_code=status.HTTP_200_OK)
|
||||||
|
async def vote_shader(
|
||||||
|
shader_id: UUID,
|
||||||
|
body: VoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Verify shader exists
|
||||||
|
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
|
||||||
|
if not shader:
|
||||||
|
raise HTTPException(status_code=404, detail="Shader not found")
|
||||||
|
|
||||||
|
# Upsert vote
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.value = body.value
|
||||||
|
else:
|
||||||
|
db.add(Vote(user_id=user.id, shader_id=shader_id, value=body.value))
|
||||||
|
|
||||||
|
# TODO: Recalculate hot score (Track F)
|
||||||
|
return {"status": "ok", "value": body.value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/shaders/{shader_id}/vote", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_vote(
|
||||||
|
shader_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
await db.delete(existing)
|
||||||
|
# TODO: Recalculate hot score (Track F)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/shaders/{shader_id}/replay", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def report_replay(
|
||||||
|
shader_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
event = EngagementEvent(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
shader_id=shader_id,
|
||||||
|
event_type="replay",
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
2
services/api/app/schemas/__init__.py
Normal file
2
services/api/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""Schemas package."""
|
||||||
|
from app.schemas.schemas import *
|
||||||
204
services/api/app/schemas/schemas.py
Normal file
204
services/api/app/schemas/schemas.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""Fractafrag — Pydantic Request/Response Schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# AUTH
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
username: str = Field(..., min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
turnstile_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
turnstile_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPublic(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
subscription_tier: str
|
||||||
|
is_verified_creator: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserMe(UserPublic):
|
||||||
|
email: str
|
||||||
|
ai_credits_remaining: int
|
||||||
|
trust_tier: str
|
||||||
|
last_active_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# SHADERS
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class ShaderCreate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: Optional[str] = Field(None, max_length=1000)
|
||||||
|
glsl_code: str = Field(..., min_length=10)
|
||||||
|
tags: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
shader_type: str = Field(default="2d", pattern=r"^(2d|3d|audio-reactive)$")
|
||||||
|
is_public: bool = True
|
||||||
|
style_metadata: Optional[dict] = None
|
||||||
|
fulfills_desire_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShaderUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=120)
|
||||||
|
description: Optional[str] = Field(None, max_length=1000)
|
||||||
|
glsl_code: Optional[str] = Field(None, min_length=10)
|
||||||
|
tags: Optional[list[str]] = None
|
||||||
|
is_public: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShaderPublic(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
author_id: Optional[UUID]
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
glsl_code: str
|
||||||
|
is_public: bool
|
||||||
|
is_ai_generated: bool
|
||||||
|
ai_provider: Optional[str]
|
||||||
|
thumbnail_url: Optional[str]
|
||||||
|
preview_url: Optional[str]
|
||||||
|
render_status: str
|
||||||
|
style_metadata: Optional[dict]
|
||||||
|
tags: list[str]
|
||||||
|
shader_type: str
|
||||||
|
forked_from: Optional[UUID]
|
||||||
|
view_count: int
|
||||||
|
score: float
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShaderFeedItem(BaseModel):
|
||||||
|
"""Lighter shader representation for feed responses."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
author_id: Optional[UUID]
|
||||||
|
title: str
|
||||||
|
thumbnail_url: Optional[str]
|
||||||
|
preview_url: Optional[str]
|
||||||
|
glsl_code: str
|
||||||
|
tags: list[str]
|
||||||
|
shader_type: str
|
||||||
|
score: float
|
||||||
|
view_count: int
|
||||||
|
is_ai_generated: bool
|
||||||
|
style_metadata: Optional[dict]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# VOTES & ENGAGEMENT
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class VoteCreate(BaseModel):
|
||||||
|
value: int = Field(..., ge=-1, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DwellReport(BaseModel):
|
||||||
|
shader_id: UUID
|
||||||
|
dwell_secs: float = Field(..., gt=0)
|
||||||
|
replayed: bool = False
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# DESIRES / BOUNTIES
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DesireCreate(BaseModel):
|
||||||
|
prompt_text: str = Field(..., min_length=5, max_length=500)
|
||||||
|
style_hints: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DesirePublic(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
author_id: Optional[UUID]
|
||||||
|
prompt_text: str
|
||||||
|
style_hints: Optional[dict]
|
||||||
|
tip_amount_cents: int
|
||||||
|
status: str
|
||||||
|
heat_score: float
|
||||||
|
fulfilled_by_shader: Optional[UUID]
|
||||||
|
fulfilled_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# AI GENERATION
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
prompt: str = Field(..., min_length=5, max_length=500)
|
||||||
|
provider: Optional[str] = None # anthropic, openai, ollama — auto-selected if None
|
||||||
|
style_metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateStatusResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str # queued, generating, rendering, complete, failed
|
||||||
|
shader_id: Optional[UUID] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# API KEYS
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class ApiKeyCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyPublic(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
key_prefix: str
|
||||||
|
name: Optional[str]
|
||||||
|
trust_tier: str
|
||||||
|
rate_limit_per_hour: int
|
||||||
|
last_used_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreated(ApiKeyPublic):
|
||||||
|
"""Returned only on creation — includes the full key (shown once)."""
|
||||||
|
full_key: str
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# PAGINATION
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel):
|
||||||
|
items: list
|
||||||
|
cursor: Optional[str] = None
|
||||||
|
has_more: bool = False
|
||||||
1
services/api/app/services/__init__.py
Normal file
1
services/api/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Services package — business logic layer."""
|
||||||
90
services/api/app/worker/__init__.py
Normal file
90
services/api/app/worker/__init__.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Fractafrag — Celery worker configuration."""
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
import os
|
||||||
|
|
||||||
|
redis_url = os.environ.get("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"fractafrag",
|
||||||
|
broker=redis_url,
|
||||||
|
backend=redis_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=120, # hard kill after 2 min
|
||||||
|
task_soft_time_limit=90, # soft warning at 90s
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
worker_max_tasks_per_child=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-discover tasks from worker modules
|
||||||
|
celery_app.autodiscover_tasks(["app.worker"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task Definitions ──────────────────────────────────────
|
||||||
|
|
||||||
|
@celery_app.task(name="render_shader", bind=True, max_retries=2)
|
||||||
|
def render_shader(self, shader_id: str):
|
||||||
|
"""Render a shader via the headless Chromium renderer. (Track C)"""
|
||||||
|
# TODO: Implement in Track C
|
||||||
|
# 1. Fetch shader GLSL from DB
|
||||||
|
# 2. POST to renderer service
|
||||||
|
# 3. Store thumbnail + preview URLs
|
||||||
|
# 4. Update shader render_status
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="embed_shader", bind=True)
|
||||||
|
def embed_shader(self, shader_id: str):
|
||||||
|
"""Generate style embedding vector for a shader. (Track C/F)"""
|
||||||
|
# TODO: Implement in Track C/F
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="process_desire", bind=True)
|
||||||
|
def process_desire(self, desire_id: str):
|
||||||
|
"""Process a new desire: embed, cluster, optionally auto-fulfill. (Track G)"""
|
||||||
|
# TODO: Implement in Track G
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="ai_generate", bind=True, max_retries=3)
|
||||||
|
def ai_generate(self, job_id: str, prompt: str, provider: str, user_id: str):
|
||||||
|
"""AI shader generation: prompt → LLM → GLSL → validate → render. (Track I)"""
|
||||||
|
# TODO: Implement in Track I
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="rebuild_feed_cache")
|
||||||
|
def rebuild_feed_cache():
|
||||||
|
"""Rebuild the anonymous feed cache (trending + new). Runs every 15 min. (Track F)"""
|
||||||
|
# TODO: Implement in Track F
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="expire_bounties")
|
||||||
|
def expire_bounties():
|
||||||
|
"""Mark old unfulfilled bounties as expired. Runs daily. (Track G)"""
|
||||||
|
# TODO: Implement in Track G
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Periodic Tasks (Celery Beat) ─────────────────────────
|
||||||
|
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"rebuild-feed-cache": {
|
||||||
|
"task": "rebuild_feed_cache",
|
||||||
|
"schedule": 900.0, # every 15 minutes
|
||||||
|
},
|
||||||
|
"expire-bounties": {
|
||||||
|
"task": "expire_bounties",
|
||||||
|
"schedule": 86400.0, # daily
|
||||||
|
},
|
||||||
|
}
|
||||||
47
services/api/migrations/env.py
Normal file
47
services/api/migrations/env.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Alembic migrations environment."""
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Override URL from env
|
||||||
|
db_url = os.environ.get("DATABASE_URL_SYNC")
|
||||||
|
if db_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
|
||||||
|
# Import models so Alembic can detect them
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import * # noqa: F401, F403
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
services/api/migrations/script.py.mako
Normal file
24
services/api/migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
33
services/api/pyproject.toml
Normal file
33
services/api/pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
[project]
|
||||||
|
name = "fractafrag-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Fractafrag API — GLSL shader platform backend"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"psycopg2-binary>=2.9.10",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"pydantic>=2.10.0",
|
||||||
|
"pydantic-settings>=2.7.0",
|
||||||
|
"pgvector>=0.3.6",
|
||||||
|
"redis>=5.2.0",
|
||||||
|
"celery[redis]>=5.4.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"stripe>=11.0.0",
|
||||||
|
"numpy>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
]
|
||||||
17
services/frontend/Dockerfile
Normal file
17
services/frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build for production (overridden in dev)
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve with a simple static server
|
||||||
|
RUN npm install -g serve
|
||||||
|
CMD ["serve", "-s", "dist", "-l", "5173"]
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
31
services/frontend/package.json
Normal file
31
services/frontend/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "fractafrag-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"axios": "^1.7.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
services/mcp/Dockerfile
Normal file
11
services/mcp/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir mcp httpx redis
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "server.py"]
|
||||||
|
|
||||||
|
EXPOSE 3200
|
||||||
33
services/mcp/server.py
Normal file
33
services/mcp/server.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Fractafrag MCP Server — stub entrypoint.
|
||||||
|
|
||||||
|
Full implementation in Track E.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
|
||||||
|
class MCPHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/health":
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"status": "ok", "service": "mcp"}).encode())
|
||||||
|
else:
|
||||||
|
self.send_response(501)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"error": "MCP server coming in M2"}).encode())
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.send_response(501)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"error": "MCP server coming in M2"}).encode())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = HTTPServer(("0.0.0.0", 3200), MCPHandler)
|
||||||
|
print("MCP server stub listening on :3200")
|
||||||
|
server.serve_forever()
|
||||||
56
services/nginx/conf/default.conf
Normal file
56
services/nginx/conf/default.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Frontend SPA
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MCP Server (SSE support)
|
||||||
|
location /mcp/ {
|
||||||
|
proxy_pass http://mcp:3200/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# SSE support
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rendered media (thumbnails, preview videos)
|
||||||
|
location /renders/ {
|
||||||
|
alias /renders/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok";
|
||||||
|
}
|
||||||
|
}
|
||||||
24
services/renderer/Dockerfile
Normal file
24
services/renderer/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Chromium dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
chromium \
|
||||||
|
fonts-liberation \
|
||||||
|
libatk1.0-0 libatk-bridge2.0-0 libcups2 libxcomposite1 \
|
||||||
|
libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
|
||||||
|
libasound2 libnspr4 libnss3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
10
services/renderer/package.json
Normal file
10
services/renderer/package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "fractafrag-renderer",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"puppeteer-core": "^23.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
services/renderer/server.js
Normal file
58
services/renderer/server.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Fractafrag Renderer — Headless Chromium shader render service.
|
||||||
|
*
|
||||||
|
* Accepts GLSL code via POST /render, renders in an isolated browser context,
|
||||||
|
* returns thumbnail + preview video.
|
||||||
|
*
|
||||||
|
* Full implementation in Track C.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
const PORT = 3100;
|
||||||
|
const OUTPUT_DIR = process.env.OUTPUT_DIR || '/renders';
|
||||||
|
const MAX_DURATION = parseInt(process.env.MAX_RENDER_DURATION || '8', 10);
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if (!existsSync(OUTPUT_DIR)) {
|
||||||
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', service: 'renderer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render endpoint (stub — Track C)
|
||||||
|
app.post('/render', async (req, res) => {
|
||||||
|
const { glsl, duration = 5, width = 640, height = 360, fps = 30 } = req.body;
|
||||||
|
|
||||||
|
if (!glsl) {
|
||||||
|
return res.status(400).json({ error: 'Missing glsl field' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Track C implementation
|
||||||
|
// 1. Launch Puppeteer page
|
||||||
|
// 2. Inject GLSL into shader template HTML
|
||||||
|
// 3. Capture frames for `duration` seconds
|
||||||
|
// 4. Encode to WebM/MP4 + extract thumbnail
|
||||||
|
// 5. Write to OUTPUT_DIR
|
||||||
|
// 6. Return URLs
|
||||||
|
|
||||||
|
res.status(501).json({
|
||||||
|
error: 'Renderer implementation coming in Track C',
|
||||||
|
thumbnail_url: null,
|
||||||
|
preview_url: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Renderer service listening on :${PORT}`);
|
||||||
|
console.log(`Output dir: ${OUTPUT_DIR}`);
|
||||||
|
console.log(`Max render duration: ${MAX_DURATION}s`);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue