From 28843ea185e803171f9eaf691586236e903e6f78 Mon Sep 17 00:00:00 2001 From: gilesb Date: Fri, 9 Jan 2026 17:18:41 +0000 Subject: [PATCH] Add Renderers page for L1 server management - Add user_renderers table to track which L1 servers users are attached to - Add L1_SERVERS config to define available renderers - Add /renderers page showing attachment status for each L1 server - Add attach functionality (redirects to L1 /auth with token) - Add detach functionality with HTMX updates - Add Renderers link to nav bar Co-Authored-By: Claude Opus 4.5 --- db.py | 46 ++++++++++++++++++++++ server.py | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/db.py b/db.py index 42e092c..fac7726 100644 --- a/db.py +++ b/db.py @@ -100,6 +100,15 @@ CREATE TABLE IF NOT EXISTS followers ( UNIQUE(username, acct) ); +-- User's attached L1 renderers +CREATE TABLE IF NOT EXISTS user_renderers ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL REFERENCES users(username), + l1_url TEXT NOT NULL, + attached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(username, l1_url) +); + -- Indexes CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); CREATE INDEX IF NOT EXISTS idx_assets_content_hash ON assets(content_hash); @@ -715,3 +724,40 @@ async def get_anchor_stats() -> dict: "anchored_activities": anchored_activities, "unanchored_activities": unanchored_activities } + + +# ============ User Renderers (L1 attachments) ============ + +async def get_user_renderers(username: str) -> list[str]: + """Get L1 renderer URLs attached by a user.""" + async with get_connection() as conn: + rows = await conn.fetch( + "SELECT l1_url FROM user_renderers WHERE username = $1 ORDER BY attached_at", + username + ) + return [row["l1_url"] for row in rows] + + +async def attach_renderer(username: str, l1_url: str) -> bool: + """Attach a user to an L1 renderer. Returns True if newly attached.""" + async with get_connection() as conn: + try: + await conn.execute( + """INSERT INTO user_renderers (username, l1_url) + VALUES ($1, $2) + ON CONFLICT (username, l1_url) DO NOTHING""", + username, l1_url + ) + return True + except Exception: + return False + + +async def detach_renderer(username: str, l1_url: str) -> bool: + """Detach a user from an L1 renderer. Returns True if was attached.""" + async with get_connection() as conn: + result = await conn.execute( + "DELETE FROM user_renderers WHERE username = $1 AND l1_url = $2", + username, l1_url + ) + return "DELETE 1" in result diff --git a/server.py b/server.py index afc84f8..5170e15 100644 --- a/server.py +++ b/server.py @@ -48,6 +48,10 @@ L1_PUBLIC_URL = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash. EFFECTS_REPO_URL = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects") IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "") +# Known L1 renderers (comma-separated URLs) +L1_SERVERS_STR = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com") +L1_SERVERS = [s.strip() for s in L1_SERVERS_STR.split(",") if s.strip()] + # Cookie domain for sharing auth across subdomains (e.g., ".rose-ash.com") # If not set, derives from DOMAIN (strips first subdomain, adds leading dot) def _get_cookie_domain(): @@ -319,6 +323,7 @@ def base_html(title: str, content: str, username: str = None) -> str: Activities Users Anchors + Renderers Download Client @@ -2824,6 +2829,112 @@ async def test_ots_connection(): ''') +# ============ Renderers (L1 servers) ============ + +@app.get("/renderers", response_class=HTMLResponse) +async def renderers_page(request: Request): + """Page to manage L1 renderer attachments.""" + username = get_user_from_cookie(request) + + if not username: + content = ''' +

Renderers

+

Log in to manage your renderer connections.

+ ''' + return HTMLResponse(base_html("Renderers", content)) + + # Get user's attached renderers + attached = await db.get_user_renderers(username) + token = request.cookies.get("auth_token", "") + + # Build renderer list + rows = [] + for l1_url in L1_SERVERS: + is_attached = l1_url in attached + # Extract display name from URL + display_name = l1_url.replace("https://", "").replace("http://", "") + + if is_attached: + status = 'Attached' + action = f''' + + Open + + + ''' + else: + status = 'Not attached' + # Attach redirects to L1 with token + attach_url = f"{l1_url}/auth?auth_token={token}" + action = f''' + + Attach + + ''' + + row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-") + rows.append(f''' +
+
+
{display_name}
+
{l1_url}
+
+
+ {status} + {action} +
+
+ ''') + + content = f''' +

Renderers

+

Connect to L1 rendering servers. After attaching, you can run effects and manage media on that renderer.

+
+ {"".join(rows) if rows else '

No renderers configured.

'} +
+ ''' + return HTMLResponse(base_html("Renderers", content, username)) + + +@app.post("/renderers/detach", response_class=HTMLResponse) +async def detach_renderer(request: Request): + """Detach from an L1 renderer.""" + username = get_user_from_cookie(request) + if not username: + return HTMLResponse('
Not logged in
') + + form = await request.form() + l1_url = form.get("l1_url", "") + + await db.detach_renderer(username, l1_url) + + # Return updated row + display_name = l1_url.replace("https://", "").replace("http://", "") + token = request.cookies.get("auth_token", "") + attach_url = f"{l1_url}/auth?auth_token={token}" + row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-") + + return HTMLResponse(f''' +
+
+
{display_name}
+
{l1_url}
+
+
+ Not attached + + Attach + +
+
+ ''') + + # ============ Client Download ============ CLIENT_TARBALL = Path(__file__).parent / "artdag-client.tar.gz"