diff --git a/backend/.env.example b/backend/.env.example index 5429564..a7b70ac 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,17 @@ LYRIA_API_KEY=your-api-key-here LYRIA_DEFAULT_MODEL=lyria-3-clip-preview TZ=America/New_York -# Optional: Default ID3 tag values applied on download when track metadata is empty +# Default ID3 tag values applied on download when track metadata is empty # DEFAULT_ID3_ARTIST=Kithara Studio # DEFAULT_ID3_ALBUM=Kithara Studio Collection -# DEFAULT_ID3_GENRE=Hardcore Techno \ No newline at end of file +# DEFAULT_ID3_GENRE=Hardcore Techno + +# Auth mode: single (no login) or multi (full auth) +KITHARA_MODE=single + +# Bootstrap admin (only used in multi mode, first startup) +# KITHARA_ADMIN_USER=admin +# KITHARA_ADMIN_PASS=changeme + +# JWT secret (auto-generated if not set, persists in DB) +# KITHARA_JWT_SECRET=your-secret-here \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 1d59efc..ce87314 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,5 @@ librosa==0.10.2 soundfile==0.12.1 google-genai==1.0.0 mutagen==1.47.0 +PyJWT==2.8.0 +passlib[bcrypt]==1.7.4 diff --git a/backend/src/auth.py b/backend/src/auth.py new file mode 100644 index 0000000..16cab60 --- /dev/null +++ b/backend/src/auth.py @@ -0,0 +1,267 @@ +import os +import uuid +import hashlib +import secrets +from datetime import datetime, timezone, timedelta + +import aiosqlite +import jwt +from passlib.hash import bcrypt + +from database import DB_PATH + +# ─── Configuration ─────────────────────────────────────────────────────────── + +KITHARA_MODE = os.getenv("KITHARA_MODE", "single") + +def is_multi_user() -> bool: + return KITHARA_MODE.lower() == "multi" + +def is_single_user() -> bool: + return not is_multi_user() + +# ─── JWT ──────────────────────────────────────────────────────────────────── + +def _get_jwt_secret() -> str: + """Get or generate JWT secret. Persists in DB for durability across restarts.""" + env_secret = os.getenv("KITHARA_JWT_SECRET") + if env_secret: + return env_secret + # Check DB for persisted secret + import asyncio + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # Can't await from here if event loop is already running + # Fall back to generating a new one each startup (sessions invalid on restart) + return secrets.token_hex(32) + except RuntimeError: + pass + return secrets.token_hex(32) + +JWT_SECRET = _get_jwt_secret() +JWT_ALGORITHM = "HS256" +JWT_EXPIRY_HOURS = 72 # 3 days + + +def create_access_token(user_id: str, role: str) -> str: + """Create a JWT token for a user.""" + payload = { + "sub": user_id, + "role": role, + "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRY_HOURS), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + """Decode and validate a JWT token. Returns payload or None.""" + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +# ─── Password Hashing ──────────────────────────────────────────────────────── + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return bcrypt.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + """Verify a password against a hash.""" + return bcrypt.verify(password, password_hash) + + +# ─── User Management ───────────────────────────────────────────────────────── + +async def ensure_users_table(): + """Create users and invite_codes tables if they don't exist.""" + async with aiosqlite.connect(DB_PATH) as db: + await db.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT DEFAULT 'user', + api_key TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + + CREATE TABLE IF NOT EXISTS invite_codes ( + code TEXT PRIMARY KEY, + created_by TEXT REFERENCES users(id), + max_uses INTEGER DEFAULT 1, + current_uses INTEGER DEFAULT 0, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + await db.commit() + + +async def create_user(username: str, password: str, role: str = "user", display_name: str = None) -> dict: + """Create a new user. Returns user dict.""" + user_id = str(uuid.uuid4()) + password_hash = hash_password(password) + + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + try: + await db.execute( + "INSERT INTO users (id, username, password_hash, role, display_name) VALUES (?, ?, ?, ?, ?)", + (user_id, username, password_hash, role, display_name or username) + ) + await db.commit() + except aiosqlite.IntegrityError: + return None # Username taken + + return await get_user_by_id(user_id) + + +async def get_user_by_id(user_id: str) -> dict | None: + """Get user by ID.""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + +async def get_user_by_username(username: str) -> dict | None: + """Get user by username.""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + +async def authenticate_user(username: str, password: str) -> dict | None: + """Authenticate a user. Returns user dict if valid, None if not.""" + user = await get_user_by_username(username) + if not user: + return None + if not verify_password(password, user["password_hash"]): + return None + return user + + +async def list_users() -> list[dict]: + """List all users (admin tool).""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT id, username, display_name, role, api_key IS NOT NULL as has_api_key, created_at FROM users ORDER BY created_at") as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +async def set_user_role(user_id: str, role: str) -> bool: + """Set a user's role. Returns True if successful.""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) + await db.commit() + return cursor.rowcount > 0 + + +async def set_user_api_key(user_id: str, api_key: str | None) -> bool: + """Set or clear a user's API key. Returns True if successful.""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("UPDATE users SET api_key = ? WHERE id = ?", (api_key, user_id)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_user(user_id: str) -> bool: + """Delete a user.""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# ─── Invite Codes ──────────────────────────────────────────────────────────── + +async def create_invite_code(created_by: str, max_uses: int = 1, expires_hours: int = None) -> str: + """Generate an invite code. Returns the code string.""" + code = secrets.token_urlsafe(16) + expires_at = None + if expires_hours: + expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_hours)).isoformat() + + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO invite_codes (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)", + (code, created_by, max_uses, expires_at) + ) + await db.commit() + return code + + +async def use_invite_code(code: str) -> bool: + """Try to use an invite code. Returns True if valid and consumed.""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM invite_codes WHERE code = ?", (code,)) as cursor: + row = await cursor.fetchone() + if not row: + return False + invite = dict(row) + + # Check expiry + if invite["expires_at"]: + expires = datetime.fromisoformat(invite["expires_at"]) + if datetime.now(timezone.utc) > expires.replace(tzinfo=timezone.utc): + return False + + # Check max uses + if invite["current_uses"] >= invite["max_uses"]: + return False + + # Increment uses + await db.execute( + "UPDATE invite_codes SET current_uses = current_uses + 1 WHERE code = ?", + (code,) + ) + await db.commit() + return True + + +async def list_invite_codes() -> list[dict]: + """List all invite codes (admin tool).""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM invite_codes ORDER BY created_at DESC") as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + +# ─── Bootstrap ─────────────────────────────────────────────────────────────── + +async def bootstrap_admin(): + """Create the first admin user from env vars if in multi mode and no users exist.""" + if not is_multi_user(): + return + + # Check if any users exist + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute("SELECT COUNT(*) FROM users") as cursor: + row = await cursor.fetchone() + if row[0] > 0: + return # Users exist, skip bootstrap + + admin_user = os.getenv("KITHARA_ADMIN_USER") + admin_pass = os.getenv("KITHARA_ADMIN_PASS") + + if admin_user and admin_pass: + user = await create_user(admin_user, admin_pass, role="admin", display_name="Admin") + if user: + print(f"[kithara] Bootstrapped admin user: {admin_user}") \ No newline at end of file diff --git a/backend/src/database.py b/backend/src/database.py index ff6631d..7d15cfd 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS clip_favorites ( CREATE INDEX IF NOT EXISTS idx_favorites_user ON clip_favorites(user_id, created_at DESC); + -- Migration: add artwork_filename column to existing databases -- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed """ @@ -112,6 +113,30 @@ async def init_db(): await db.execute("ALTER TABLE clips ADD COLUMN genre TEXT") except Exception: pass # Column already exists + # Migration: add user_id column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN user_id TEXT DEFAULT 'default'") + except Exception: + pass # Column already exists + # Migration: add shared column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN shared INTEGER DEFAULT 0") + except Exception: + pass # Column already exists + # Migration: add remixed_from column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN remixed_from TEXT") + except Exception: + pass # Column already exists + # Create indexes for new columns (safe to run every time + try: + await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at DESC)") + except Exception: + pass + try: + await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_shared ON clips(shared, created_at DESC)") + except Exception: + pass await db.commit() async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str: diff --git a/backend/src/main.py b/backend/src/main.py index a629c37..72bdb54 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,6 +1,7 @@ -from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File +from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pathlib import Path import os import shutil @@ -11,6 +12,13 @@ load_dotenv(Path(__file__).parent.parent / ".env") from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata, toggle_favorite, get_favorites, is_favorite +from auth import ( + is_multi_user, is_single_user, create_access_token, decode_access_token, + authenticate_user, create_user, get_user_by_id, get_user_by_username, + list_users, set_user_role, set_user_api_key, delete_user, + create_invite_code, use_invite_code, list_invite_codes, + ensure_users_table, bootstrap_admin +) from kithara_client import generate_clip, save_clip_file from waveform import generate_waveform_svg, get_waveform_path from analysis import analyze_clip @@ -36,13 +44,236 @@ ARTWORK_DIR.mkdir(parents=True, exist_ok=True) ALLOWED_ARTWORK_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} MAX_ARTWORK_SIZE = 5 * 1024 * 1024 # 5 MB +security = HTTPBearer(auto_error=False) + +# ─── Auth Dependency ──────────────────────────────────────────────────────── + +async def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)): + """ + Dependency that returns the current user dict, or None in single-user mode. + In multi-user mode, requires a valid JWT token. + In single-user mode, returns None (no auth needed). + """ + if is_single_user(): + return None + + if not credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + + payload = decode_access_token(credentials.credentials) + if not payload: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + user = await get_user_by_id(payload["sub"]) + if not user: + raise HTTPException(status_code=401, detail="User not found") + + return user + +async def require_admin(user = Depends(get_current_user)): + """Dependency that requires the current user to be an admin.""" + if is_single_user(): + return None + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + if user["role"] != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user + +def get_user_id(user) -> str: + """Get user_id from auth context, or 'default' for single-user mode.""" + if user is None: + return "default" + return user["id"] + @app.on_event("startup") async def startup(): await init_db() + await ensure_users_table() + await bootstrap_admin() @app.get("/api/health") async def health(): - return {"status": "ok", "version": "0.1.0"} + return { + "status": "ok", + "version": "0.1.0", + "mode": "multi" if is_multi_user() else "single" + } + +# ─── Auth Endpoints ────────────────────────────────────────────────────────── + +@app.post("/api/auth/login") +async def login(request: Request): + """Authenticate user and return JWT token.""" + if is_single_user(): + raise HTTPException(status_code=400, detail="Login not available in single-user mode") + + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "") + + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + + user = await authenticate_user(username, password) + if not user: + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = create_access_token(user["id"], user["role"]) + return { + "token": token, + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "role": user["role"], + } + } + +@app.post("/api/auth/register") +async def register(request: Request): + """Register a new user using an invite code.""" + if is_single_user(): + raise HTTPException(status_code=400, detail="Registration not available in single-user mode") + + body = await request.json() + invite_code = body.get("invite_code", "").strip() + username = body.get("username", "").strip() + password = body.get("password", "") + display_name = body.get("display_name", "").strip() or username + + if not invite_code or not username or not password: + raise HTTPException(status_code=400, detail="Invite code, username, and password required") + + if len(password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + if not await use_invite_code(invite_code): + raise HTTPException(status_code=400, detail="Invalid or expired invite code") + + user = await create_user(username, password, role="user", display_name=display_name) + if not user: + raise HTTPException(status_code=400, detail="Username already taken") + + token = create_access_token(user["id"], user["role"]) + return { + "token": token, + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "role": user["role"], + } + } + +@app.get("/api/auth/me") +async def get_me(user = Depends(get_current_user)): + """Get current user info.""" + if is_single_user(): + return {"mode": "single", "user": None} + + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + return { + "mode": "multi", + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "role": user["role"], + "has_api_key": bool(user.get("api_key")), + } + } + +@app.put("/api/auth/api-key") +async def update_api_key(request: Request, user = Depends(get_current_user)): + """Update the current user's Lyria API key (BYOK).""" + if is_single_user(): + raise HTTPException(status_code=400, detail="Not available in single-user mode") + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + body = await request.json() + api_key = body.get("api_key", "").strip() or None # Empty string = clear + + await set_user_api_key(user["id"], api_key) + return {"status": "ok"} + +# ─── Admin Endpoints ───────────────────────────────────────────────────────── + +@app.get("/api/admin/users") +async def admin_list_users(admin = Depends(require_admin)): + """List all users. Admin only.""" + users = await list_users() + return {"users": users} + +@app.post("/api/admin/users") +async def admin_create_user(request: Request, admin = Depends(require_admin)): + """Create a new user. Admin only.""" + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "") + role = body.get("role", "user") + display_name = body.get("display_name", "").strip() or username + + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password required") + if len(password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + if role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") + + user = await create_user(username, password, role=role, display_name=display_name) + if not user: + raise HTTPException(status_code=400, detail="Username already taken") + + return {"user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "role": user["role"], + }} + +@app.post("/api/admin/invites") +async def admin_create_invite(request: Request, admin = Depends(require_admin)): + """Generate an invite code. Admin only.""" + body = await request.json() + max_uses = body.get("max_uses", 1) + expires_hours = body.get("expires_hours") + + code = await create_invite_code(admin["id"], max_uses=max_uses, expires_hours=expires_hours) + return {"code": code, "max_uses": max_uses, "expires_hours": expires_hours} + +@app.get("/api/admin/invites") +async def admin_list_invites(admin = Depends(require_admin)): + """List all invite codes. Admin only.""" + invites = await list_invite_codes() + return {"invites": invites} + +@app.delete("/api/admin/users/{user_id}") +async def admin_delete_user(user_id: str, admin = Depends(require_admin)): + """Delete a user. Admin only.""" + if user_id == admin["id"]: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + deleted = await delete_user(user_id) + if not deleted: + raise HTTPException(status_code=404, detail="User not found") + return {"status": "ok"} + +@app.put("/api/admin/users/{user_id}/role") +async def admin_set_role(user_id: str, request: Request, admin = Depends(require_admin)): + """Set a user's role. Admin only.""" + body = await request.json() + role = body.get("role") + if role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") + + updated = await set_user_role(user_id, role) + if not updated: + raise HTTPException(status_code=404, detail="User not found") + return {"status": "ok"} @app.get("/api/models") async def list_models():