diff --git a/client/artdag.py b/client/artdag.py index d28df4c..bbcbc0f 100755 --- a/client/artdag.py +++ b/client/artdag.py @@ -21,11 +21,11 @@ CONFIG_FILE = CONFIG_DIR / "config.json" # Defaults - can be overridden by env vars, config file, or CLI args _DEFAULT_SERVER = "http://localhost:8100" -_DEFAULT_L2_SERVER = "http://localhost:8200" +_DEFAULT_ACCOUNT_SERVER = "https://account.rose-ash.com" # Active server URLs (set during CLI init) DEFAULT_SERVER = None -DEFAULT_L2_SERVER = None +DEFAULT_ACCOUNT_SERVER = None def load_config() -> dict: @@ -51,9 +51,9 @@ def get_server(): return DEFAULT_SERVER -def get_l2_server(): - """Get L2 server URL.""" - return DEFAULT_L2_SERVER +def get_account_server(): + """Get account server URL.""" + return DEFAULT_ACCOUNT_SERVER def load_token() -> dict: @@ -115,24 +115,24 @@ def _get_default_server(): return config.get("server", _DEFAULT_SERVER) -def _get_default_l2(): - """Get default L2 server from env, config, or builtin default.""" - if os.environ.get("ARTDAG_L2"): - return os.environ["ARTDAG_L2"] +def _get_default_account(): + """Get default account server from env, config, or builtin default.""" + if os.environ.get("ARTDAG_ACCOUNT"): + return os.environ["ARTDAG_ACCOUNT"] config = load_config() - return config.get("l2", _DEFAULT_L2_SERVER) + return config.get("account", _DEFAULT_ACCOUNT_SERVER) @click.group() @click.option("--server", "-s", default=None, help="L1 server URL (saved for future use)") -@click.option("--l2", default=None, - help="L2 server URL (saved for future use)") +@click.option("--account", default=None, + help="Account server URL (saved for future use)") @click.pass_context -def cli(ctx, server, l2): +def cli(ctx, server, account): """Art DAG Client - interact with L1 rendering server.""" ctx.ensure_object(dict) - global DEFAULT_SERVER, DEFAULT_L2_SERVER + global DEFAULT_SERVER, DEFAULT_ACCOUNT_SERVER config = load_config() config_changed = False @@ -146,134 +146,106 @@ def cli(ctx, server, l2): else: DEFAULT_SERVER = _get_default_server() - if l2: - DEFAULT_L2_SERVER = l2 - if config.get("l2") != l2: - config["l2"] = l2 + if account: + DEFAULT_ACCOUNT_SERVER = account + if config.get("account") != account: + config["account"] = account config_changed = True else: - DEFAULT_L2_SERVER = _get_default_l2() + DEFAULT_ACCOUNT_SERVER = _get_default_account() # Save config if changed if config_changed: save_config(config) ctx.obj["server"] = DEFAULT_SERVER - ctx.obj["l2"] = DEFAULT_L2_SERVER + ctx.obj["account"] = DEFAULT_ACCOUNT_SERVER # ============ Auth Commands ============ @cli.command() -@click.argument("username") -@click.option("--password", "-p", prompt=True, hide_input=True) -def login(username, password): - """Login to get access token.""" +def login(): + """Login via device authorization flow.""" + import webbrowser + + account = get_account_server() + + # Request device code try: - # Server expects form data, not JSON resp = requests.post( - f"{get_l2_server()}/auth/login", - data={"username": username, "password": password} + f"{account}/auth/device/authorize", + json={"client_id": "artdag"}, ) - if resp.status_code == 200: - # Check if we got a token back in a cookie - if "auth_token" in resp.cookies: - token = resp.cookies["auth_token"] - # Decode token to get username and expiry - import base64 - try: - # JWT format: header.payload.signature - payload = token.split(".")[1] - # Add padding if needed - payload += "=" * (4 - len(payload) % 4) - decoded = json.loads(base64.urlsafe_b64decode(payload)) - token_data = { - "access_token": token, - "username": decoded.get("username", username), - "expires_at": decoded.get("exp", "") - } - save_token(token_data) - click.echo(f"Logged in as {token_data['username']}") - if token_data.get("expires_at"): - click.echo(f"Token expires: {token_data['expires_at']}") - except Exception: - # If we can't decode, just save the token - save_token({"access_token": token, "username": username}) - click.echo(f"Logged in as {username}") - else: - # HTML response - check for success/error - if "successful" in resp.text.lower(): - click.echo(f"Login successful but no token received. Try logging in via web browser.") - elif "invalid" in resp.text.lower(): - click.echo(f"Login failed: Invalid username or password", err=True) - sys.exit(1) - else: - click.echo(f"Login failed: {resp.text}", err=True) - sys.exit(1) - else: - click.echo(f"Login failed: {resp.text}", err=True) - sys.exit(1) + resp.raise_for_status() + data = resp.json() except requests.RequestException as e: click.echo(f"Login failed: {e}", err=True) sys.exit(1) + device_code = data["device_code"] + user_code = data["user_code"] + verification_uri = data["verification_uri"] + expires_in = data.get("expires_in", 900) + interval = data.get("interval", 5) -@cli.command() -@click.argument("username") -@click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True) -@click.option("--email", "-e", default=None, help="Email (optional)") -def register(username, password, email): - """Register a new account.""" + click.echo("To sign in, open this URL in your browser:") + click.echo(f" {verification_uri}") + click.echo(f" and enter code: {user_code}") + click.echo() + + # Try to open browser automatically try: - # Server expects form data, not JSON - form_data = { - "username": username, - "password": password, - "password2": password, - } - if email: - form_data["email"] = email + webbrowser.open(verification_uri) + except Exception: + pass - resp = requests.post( - f"{get_l2_server()}/auth/register", - data=form_data - ) - if resp.status_code == 200: - # Check if we got a token back in a cookie - if "auth_token" in resp.cookies: - token = resp.cookies["auth_token"] - # Decode token to get username and expiry - import base64 - try: - # JWT format: header.payload.signature - payload = token.split(".")[1] - # Add padding if needed - payload += "=" * (4 - len(payload) % 4) - decoded = json.loads(base64.urlsafe_b64decode(payload)) - token_data = { - "access_token": token, - "username": decoded.get("username", username), - "expires_at": decoded.get("exp", "") - } - save_token(token_data) - click.echo(f"Registered and logged in as {token_data['username']}") - except Exception: - # If we can't decode, just save the token - save_token({"access_token": token, "username": username}) - click.echo(f"Registered and logged in as {username}") - else: - # HTML response - registration successful - if "successful" in resp.text.lower(): - click.echo(f"Registered as {username}. Please login to get a token.") - else: - click.echo(f"Registration failed: {resp.text}", err=True) - sys.exit(1) - else: - click.echo(f"Registration failed: {resp.text}", err=True) + # Poll for approval + click.echo("Waiting for authorization", nl=False) + deadline = time.time() + expires_in + while time.time() < deadline: + time.sleep(interval) + click.echo(".", nl=False) + + try: + resp = requests.post( + f"{account}/auth/device/token", + json={"device_code": device_code, "client_id": "artdag"}, + ) + data = resp.json() + except requests.RequestException: + continue + + error = data.get("error") + if error == "authorization_pending": + continue + elif error == "expired_token": + click.echo() + click.echo("Code expired. Please try again.", err=True) sys.exit(1) - except requests.RequestException as e: - click.echo(f"Registration failed: {e}", err=True) - sys.exit(1) + elif error == "access_denied": + click.echo() + click.echo("Authorization denied.", err=True) + sys.exit(1) + elif error: + click.echo() + click.echo(f"Login failed: {error}", err=True) + sys.exit(1) + + # Success + token_data = { + "access_token": data["access_token"], + "username": data.get("username", ""), + "display_name": data.get("display_name", ""), + } + save_token(token_data) + click.echo() + click.echo(f"Logged in as {token_data['username'] or token_data['display_name']}") + return + + click.echo() + click.echo("Timed out waiting for authorization.", err=True) + sys.exit(1) @cli.command() @@ -291,22 +263,12 @@ def whoami(): click.echo("Not logged in") return - try: - resp = requests.get( - f"{get_l2_server()}/auth/me", - headers={"Authorization": f"Bearer {token_data['access_token']}"} - ) - if resp.status_code == 200: - user = resp.json() - click.echo(f"Username: {user['username']}") - click.echo(f"Created: {user['created_at']}") - if user.get('email'): - click.echo(f"Email: {user['email']}") - else: - click.echo("Token invalid or expired. Please login again.", err=True) - clear_token() - except requests.RequestException as e: - click.echo(f"Error: {e}", err=True) + username = token_data.get("username", "") + display_name = token_data.get("display_name", "") + if username: + click.echo(f"Username: {username}") + if display_name: + click.echo(f"Name: {display_name}") @cli.command("config") @@ -332,11 +294,11 @@ def show_config(clear): else: click.echo(f" (default)") - click.echo(f"L2 Server: {DEFAULT_L2_SERVER}") - if config.get("l2"): + click.echo(f"Account Server: {DEFAULT_ACCOUNT_SERVER}") + if config.get("account"): click.echo(f" (saved)") - elif os.environ.get("ARTDAG_L2"): - click.echo(f" (from ARTDAG_L2 env)") + elif os.environ.get("ARTDAG_ACCOUNT"): + click.echo(f" (from ARTDAG_ACCOUNT env)") else: click.echo(f" (default)") @@ -459,7 +421,7 @@ def run(recipe, input_hash, name, wait): # Check auth token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Resolve named assets @@ -691,7 +653,7 @@ def delete_run(run_id, force): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Get run info first @@ -741,7 +703,7 @@ def delete_cache(cid, force): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) if not force: @@ -927,7 +889,7 @@ def upload(filepath, name): # Check auth token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -980,13 +942,13 @@ def publish(run_id, output_name): # Check auth token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Post to L2 server with auth, including which L1 server has the run try: resp = requests.post( - f"{get_l2_server()}/registry/record-run", + f"{get_server()}/registry/record-run", json={"run_id": run_id, "output_name": output_name, "l1_server": get_server()}, headers={"Authorization": f"Bearer {token_data['access_token']}"} ) @@ -1031,7 +993,7 @@ def meta(cid, origin, origin_url, origin_note, description, tags, folder, add_co """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) headers = get_auth_header(require_token=True) @@ -1200,7 +1162,7 @@ def folder_list(): """List all folders.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1225,7 +1187,7 @@ def folder_create(path): """Create a new folder.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1251,7 +1213,7 @@ def folder_delete(path): """Delete a folder (must be empty).""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1287,7 +1249,7 @@ def collection_list(): """List all collections.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1312,7 +1274,7 @@ def collection_create(name): """Create a new collection.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1338,7 +1300,7 @@ def collection_delete(name): """Delete a collection.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -1546,7 +1508,7 @@ def upload_recipe(filepath): """Upload a recipe file (YAML or S-expression). Requires login.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Read content @@ -1607,7 +1569,7 @@ def upload_effect(filepath, name): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Check it's a sexp or py file @@ -1854,7 +1816,7 @@ def run_recipe(recipe_id, inputs, wait): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Parse inputs @@ -1922,7 +1884,7 @@ def delete_recipe(recipe_id, force): """Delete a recipe. Requires login.""" token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) if not force: @@ -1969,7 +1931,7 @@ def generate_plan(recipe_file, inputs, features, output): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Read recipe YAML @@ -2047,7 +2009,7 @@ def execute_plan(plan_file, wait): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Read plan JSON @@ -2098,7 +2060,7 @@ def run_recipe_v2(recipe_file, inputs, features, wait): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Read recipe YAML @@ -2213,7 +2175,7 @@ def run_status_v2(run_id): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) try: @@ -2267,7 +2229,7 @@ def run_stream(recipe_file, output, duration, fps, sources, audio, wait): """ token_data = load_token() if not token_data.get("access_token"): - click.echo("Not logged in. Please run: artdag login ", err=True) + click.echo("Not logged in. Please run: artdag login", err=True) sys.exit(1) # Read recipe file diff --git a/l1/app/__init__.py b/l1/app/__init__.py index 3b945b3..15617da 100644 --- a/l1/app/__init__.py +++ b/l1/app/__init__.py @@ -18,8 +18,6 @@ from artdag_common.middleware.auth import get_user_from_cookie from .config import settings -# Paths that should never trigger a silent auth check -_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/", "/inbox", "/health", "/internal/", "/oembed") _SILENT_CHECK_COOLDOWN = 300 # 5 minutes _DEVICE_COOKIE = "artdag_did" _DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days @@ -60,14 +58,15 @@ def create_app() -> FastAPI: async def shutdown(): await close_db() - # Silent auth check — auto-login via prompt=none OAuth + # Silent auth check — auto-login via prompt=none OAuth. + # Only runs for browser page loads (Accept: text/html). # NOTE: registered BEFORE device_id so device_id is outermost (runs first) @app.middleware("http") async def silent_auth_check(request: Request, call_next): - path = request.url.path + accept = request.headers.get("accept", "") if ( request.method != "GET" - or any(path.startswith(p) for p in _SKIP_PREFIXES) + or "text/html" not in accept or request.headers.get("hx-request") # skip HTMX ): return await call_next(request) @@ -148,17 +147,14 @@ def create_app() -> FastAPI: return response # Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini into - # request.state for full-page HTML renders. Skips HTMX, API, and - # internal paths. Failures are silent (fragments default to ""). - _FRAG_SKIP = ("/auth/", "/api/", "/internal/", "/health", "/oembed", - "/ipfs/", "/download/", "/inbox", "/static/") - + # request.state for full-page HTML renders. Opt-in: only fetches for + # browser page loads (Accept: text/html, non-HTMX GET requests). @app.middleware("http") async def coop_fragments_middleware(request: Request, call_next): - path = request.url.path + accept = request.headers.get("accept", "") if ( request.method != "GET" - or any(path.startswith(p) for p in _FRAG_SKIP) + or "text/html" not in accept or request.headers.get("hx-request") or request.headers.get(fragments.FRAGMENT_HEADER) ): @@ -171,7 +167,7 @@ def create_app() -> FastAPI: user = get_user_from_cookie(request) auth_params = {"email": user.email or user.username} if user else {} - nav_params = {"app_name": "artdag", "path": path} + nav_params = {"app_name": "artdag", "path": request.url.path} try: nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([ diff --git a/l1/app/dependencies.py b/l1/app/dependencies.py index fc59947..1e11831 100644 --- a/l1/app/dependencies.py +++ b/l1/app/dependencies.py @@ -54,6 +54,77 @@ def get_templates(request: Request) -> Environment: return request.app.state.templates +async def _verify_opaque_grant(token: str) -> Optional[UserContext]: + """Verify an opaque grant token via account server, with Redis cache.""" + import httpx + import json + + if not settings.internal_account_url: + return None + + # Check L1 Redis cache first + cache_key = f"grant_verify:{token[:16]}" + try: + r = get_redis_client() + cached = r.get(cache_key) + if cached is not None: + if cached == "__invalid__": + return None + data = json.loads(cached) + return UserContext( + username=data["username"], + actor_id=data["actor_id"], + token=token, + email=data.get("email", ""), + ) + except Exception: + pass + + # Call account server + verify_url = f"{settings.internal_account_url.rstrip('/')}/auth/internal/verify-grant" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(verify_url, params={"token": token}) + if resp.status_code != 200: + return None + data = resp.json() + if not data.get("valid"): + # Cache negative result briefly + try: + r = get_redis_client() + r.set(cache_key, "__invalid__", ex=60) + except Exception: + pass + return None + except Exception: + return None + + username = data.get("username", "") + display_name = data.get("display_name", "") + actor_id = f"@{username}" if username else "" + ctx = UserContext( + username=username, + actor_id=actor_id, + token=token, + email=username, + ) + + # Cache positive result for 5 minutes + try: + r = get_redis_client() + cache_data = json.dumps({ + "username": username, + "actor_id": actor_id, + "email": username, + "display_name": display_name, + }) + r.set(cache_key, cache_data, ex=300) + except Exception: + pass + + return ctx + + async def get_current_user(request: Request) -> Optional[UserContext]: """ Get the current user from request (cookie or header). @@ -61,11 +132,19 @@ async def get_current_user(request: Request) -> Optional[UserContext]: This is a permissive dependency - returns None if not authenticated. Use require_auth for routes that require authentication. """ - # Try header first (API clients) + # Try header first (API clients — JWT tokens) ctx = get_user_from_header(request) if ctx: return ctx + # Try opaque grant token (device flow / CLI tokens) + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + ctx = await _verify_opaque_grant(token) + if ctx: + return ctx + # Fall back to cookie (browser) return get_user_from_cookie(request) diff --git a/l1/streaming/multi_res_output.py b/l1/streaming/multi_res_output.py index 33e4413..dfa7d8d 100644 --- a/l1/streaming/multi_res_output.py +++ b/l1/streaming/multi_res_output.py @@ -244,6 +244,7 @@ class MultiResolutionHLSOutput: "-bufsize", f"{quality.bitrate * 2}k", ]) cmd.extend([ + "-pix_fmt", "yuv420p", # Required for browser MSE compatibility "-g", str(int(self.fps * self.segment_duration)), # Keyframe interval = segment duration "-keyint_min", str(int(self.fps * self.segment_duration)), "-sc_threshold", "0", # Disable scene change detection for consistent segments diff --git a/l2/app/dependencies.py b/l2/app/dependencies.py index d10d063..e3c0e1e 100644 --- a/l2/app/dependencies.py +++ b/l2/app/dependencies.py @@ -19,6 +19,34 @@ def get_templates(request: Request): return request.app.state.templates +async def _verify_opaque_grant(token: str) -> Optional[dict]: + """Verify an opaque grant token via account server.""" + import httpx + + if not settings.internal_account_url: + return None + + verify_url = f"{settings.internal_account_url.rstrip('/')}/auth/internal/verify-grant" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(verify_url, params={"token": token}) + if resp.status_code != 200: + return None + data = resp.json() + if not data.get("valid"): + return None + except Exception: + return None + + username = data.get("username", "") + return { + "username": username, + "actor_id": f"https://{settings.domain}/users/{username}", + "token": token, + "sub": username, + } + + async def get_current_user(request: Request) -> Optional[dict]: """ Get current user from cookie or header. @@ -39,22 +67,20 @@ async def get_current_user(request: Request) -> Optional[dict]: if not token: return None - # Verify token + # Verify JWT token username = verify_token(token) - if not username: - return None + if username: + claims = get_token_claims(token) + if claims: + return { + "username": username, + "actor_id": f"https://{settings.domain}/users/{username}", + "token": token, + **claims, + } - # Get full claims - claims = get_token_claims(token) - if not claims: - return None - - return { - "username": username, - "actor_id": f"https://{settings.domain}/users/{username}", - "token": token, - **claims, - } + # JWT failed — try as opaque grant token + return await _verify_opaque_grant(token) async def require_auth(request: Request) -> dict: