diff --git a/docker-compose.yml b/docker-compose.yml index ff16cf0..3448e90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: environment: - REDIS_URL=redis://redis:6379/5 - CACHE_DIR=/data/cache + - L2_SERVER=http://activitypub_l2-server:8200 volumes: - l1_cache:/data/cache depends_on: diff --git a/server.py b/server.py index d263c39..a5af277 100644 --- a/server.py +++ b/server.py @@ -16,16 +16,21 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, UploadFile, File -from fastapi.responses import FileResponse, HTMLResponse +from fastapi import FastAPI, HTTPException, UploadFile, File, Depends, Form, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel import redis +import requests as http_requests from urllib.parse import urlparse from celery_app import app as celery_app from tasks import render_effect +# L2 server for auth verification +L2_SERVER = os.environ.get("L2_SERVER", "http://localhost:8200") + # Cache directory (use /data/cache in Docker, ~/.artdag/cache locally) CACHE_DIR = Path(os.environ.get("CACHE_DIR", str(Path.home() / ".artdag" / "cache"))) CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -90,6 +95,48 @@ class RunStatus(BaseModel): error: Optional[str] = None celery_task_id: Optional[str] = None effects_commit: Optional[str] = None + username: Optional[str] = None # Owner of the run + + +# ============ Auth ============ + +security = HTTPBearer(auto_error=False) + + +def verify_token_with_l2(token: str) -> Optional[str]: + """Verify token with L2 server, return username if valid.""" + try: + resp = http_requests.post( + f"{L2_SERVER}/auth/verify", + headers={"Authorization": f"Bearer {token}"}, + timeout=5 + ) + if resp.status_code == 200: + return resp.json().get("username") + except Exception: + pass + return None + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> Optional[str]: + """Get username if authenticated, None otherwise.""" + if not credentials: + return None + return verify_token_with_l2(credentials.credentials) + + +async def get_required_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> str: + """Get username, raise 401 if not authenticated.""" + if not credentials: + raise HTTPException(401, "Not authenticated") + username = verify_token_with_l2(credentials.credentials) + if not username: + raise HTTPException(401, "Invalid token") + return username def file_hash(path: Path) -> str: @@ -219,8 +266,8 @@ async def root(): @app.post("/runs", response_model=RunStatus) -async def create_run(request: RunRequest): - """Start a new rendering run.""" +async def create_run(request: RunRequest, username: str = Depends(get_required_user)): + """Start a new rendering run. Requires authentication.""" run_id = str(uuid.uuid4()) # Generate output name if not provided @@ -233,7 +280,8 @@ async def create_run(request: RunRequest): recipe=request.recipe, inputs=request.inputs, output_name=output_name, - created_at=datetime.now(timezone.utc).isoformat() + created_at=datetime.now(timezone.utc).isoformat(), + username=username ) # Submit to Celery @@ -468,6 +516,14 @@ def detect_media_type(cache_path: Path) -> str: return "unknown" +def get_user_from_cookie(request) -> Optional[str]: + """Get username from auth cookie.""" + token = request.cookies.get("auth_token") + if not token: + return None + return verify_token_with_l2(token) + + UI_CSS = """ * { box-sizing: border-box; } body { @@ -526,15 +582,33 @@ UI_CSS = """ code { background: #222; padding: 2px 6px; border-radius: 4px; } """ -UI_HTML = """ +def render_ui_html(username: Optional[str] = None) -> str: + """Render main UI HTML with optional user context.""" + user_info = "" + if username: + user_info = f''' +