Move auth to L2 only, display @user@server format

- L1 never handles credentials - redirects to L2 for login/register
- L2 sets shared cookie (domain=.rose-ash.com) for cross-subdomain auth
- Display logged-in user as @user@server (ActivityPub format)
- Remove login/register form handling from L1
- Add L1_PUBLIC_URL env var for redirect callbacks
- Rename /ui/cache-list to /ui/media-list
- Update nav links to use clean URLs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-08 15:32:55 +00:00
parent bc07a101d1
commit 66beda70c4

195
server.py
View File

@@ -2940,9 +2940,11 @@ def render_page(title: str, content: str, username: Optional[str] = None, active
"""Render a page with nav bar and content. Used for clean URL pages."""
user_info = ""
if username:
# Display as @user@server format
actor_id = f"@{username}@{L2_DOMAIN}" if not username.startswith("@") else username
user_info = f'''
<div class="flex items-center gap-4 text-sm text-gray-400">
Logged in as <strong class="text-white">{username}</strong>
Logged in as <strong class="text-white">{actor_id}</strong>
<a href="/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
</div>
'''
@@ -2994,29 +2996,31 @@ def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str:
"""Render main UI HTML with optional user context."""
user_info = ""
if username:
# Display as @user@server format
actor_id = f"@{username}@{L2_DOMAIN}" if not username.startswith("@") else username
user_info = f'''
<div class="flex items-center gap-4 text-sm text-gray-400">
Logged in as <strong class="text-white">{username}</strong>
<a href="/ui/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
Logged in as <strong class="text-white">{actor_id}</strong>
<a href="/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
</div>
'''
else:
user_info = '''
<div class="text-sm">
<a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a>
<a href="/login" class="text-blue-400 hover:text-blue-300">Login</a>
</div>
'''
runs_active = "border-b-2 border-blue-500 text-white" if tab == "runs" else "text-gray-400 hover:text-white"
recipes_active = "border-b-2 border-blue-500 text-white" if tab == "recipes" else "text-gray-400 hover:text-white"
cache_active = "border-b-2 border-blue-500 text-white" if tab == "cache" else "text-gray-400 hover:text-white"
media_active = "border-b-2 border-blue-500 text-white" if tab == "media" else "text-gray-400 hover:text-white"
if tab == "runs":
content_url = "/ui/runs"
elif tab == "recipes":
content_url = "/ui/recipes-list"
else:
content_url = "/ui/cache-list"
content_url = "/ui/media-list"
return f"""
<!DOCTYPE html>
@@ -3031,15 +3035,15 @@ def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str:
<div class="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
<header class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="text-2xl font-bold">
<a href="/ui" class="text-white hover:text-gray-200">Art DAG L1 Server</a>
<a href="/runs" class="text-white hover:text-gray-200">Art DAG L1 Server</a>
</h1>
{user_info}
</header>
<nav class="flex gap-6 mb-6 border-b border-dark-500 pb-0">
<a href="/ui" class="pb-3 px-1 font-medium transition-colors {runs_active}">Runs</a>
<a href="/ui?tab=recipes" class="pb-3 px-1 font-medium transition-colors {recipes_active}">Recipes</a>
<a href="/ui?tab=cache" class="pb-3 px-1 font-medium transition-colors {cache_active}">Cache</a>
<a href="/runs" class="pb-3 px-1 font-medium transition-colors {runs_active}">Runs</a>
<a href="/recipes" class="pb-3 px-1 font-medium transition-colors {recipes_active}">Recipes</a>
<a href="/media" class="pb-3 px-1 font-medium transition-colors {media_active}">Media</a>
</nav>
<div id="content" hx-get="{content_url}" hx-trigger="load" hx-swap="innerHTML">
@@ -3053,139 +3057,32 @@ def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str:
"""
def get_auth_page_html(page_type: str = "login", error: str = None) -> str:
"""Generate login or register page HTML with Tailwind CSS."""
is_login = page_type == "login"
title = "Login" if is_login else "Register"
# Auth routes - L1 never handles credentials, redirects to L2
# L2 sets auth_token cookie with domain=.rose-ash.com for shared auth
login_active = "bg-blue-600 text-white" if is_login else "bg-dark-500 text-gray-400 hover:text-white"
register_active = "bg-dark-500 text-gray-400 hover:text-white" if is_login else "bg-blue-600 text-white"
error_html = f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">{error}</div>' if error else ''
form_fields = '''
<input type="text" name="username" placeholder="Username" required
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<input type="password" name="password" placeholder="Password" required
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
'''
if not is_login:
form_fields += '''
<input type="email" name="email" placeholder="Email (optional)"
class="w-full px-4 py-3 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
'''
return f"""
<!DOCTYPE html>
<html class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} | Art DAG L1 Server</title>
{TAILWIND_CONFIG}
</head>
<body class="bg-dark-900 text-gray-100 min-h-screen">
<div class="max-w-md mx-auto px-4 py-8 sm:px-6">
<h1 class="text-2xl font-bold mb-6">
<a href="/" class="text-white hover:text-gray-200">Art DAG L1 Server</a>
</h1>
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back
</a>
<div class="bg-dark-700 rounded-lg p-6">
<div class="flex gap-2 mb-6">
<a href="/login" class="px-4 py-2 rounded-lg font-medium transition-colors {login_active}">Login</a>
<a href="/register" class="px-4 py-2 rounded-lg font-medium transition-colors {register_active}">Register</a>
</div>
{error_html}
<form method="POST" action="/{page_type}" class="space-y-4">
{form_fields}
<button type="submit"
class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
{title}
</button>
</form>
</div>
</div>
</body>
</html>
"""
L1_PUBLIC_URL = os.environ.get("L1_PUBLIC_URL", "http://localhost:8100")
UI_LOGIN_HTML = get_auth_page_html("login")
UI_REGISTER_HTML = get_auth_page_html("register")
# Clean URL auth routes
@app.get("/login", response_class=HTMLResponse)
@app.get("/login")
async def login_page():
"""Login page (clean URL)."""
return UI_LOGIN_HTML
"""Redirect to L2 server for login. L1 never handles credentials."""
# Redirect to L2 with return URL so L2 can redirect back after login
return_url = f"{L1_PUBLIC_URL}/runs"
return RedirectResponse(url=f"{L2_SERVER}/login?return_to={return_url}", status_code=302)
@app.post("/login")
async def login(username: str = Form(...), password: str = Form(...)):
"""Process login form (clean URL)."""
try:
resp = http_requests.post(
f"{L2_SERVER}/auth/login",
json={"username": username, "password": password},
timeout=5
)
if resp.status_code == 200:
token = resp.json().get("access_token")
response = RedirectResponse(url="/runs", status_code=303)
response.set_cookie("auth_token", token, httponly=True, max_age=30*24*60*60)
return response
except Exception:
pass
return HTMLResponse(get_auth_page_html("login", "Invalid username or password"))
@app.get("/register", response_class=HTMLResponse)
@app.get("/register")
async def register_page():
"""Register page (clean URL)."""
return UI_REGISTER_HTML
@app.post("/register")
async def register(
username: str = Form(...),
password: str = Form(...),
email: str = Form(None)
):
"""Process registration form (clean URL)."""
try:
resp = http_requests.post(
f"{L2_SERVER}/auth/register",
json={"username": username, "password": password, "email": email},
timeout=5
)
if resp.status_code == 200:
token = resp.json().get("access_token")
response = RedirectResponse(url="/runs", status_code=303)
response.set_cookie("auth_token", token, httponly=True, max_age=30*24*60*60)
return response
elif resp.status_code == 400:
error = resp.json().get("detail", "Registration failed")
return HTMLResponse(get_auth_page_html("register", error))
except Exception as e:
return HTMLResponse(get_auth_page_html("register", f"Registration failed: {e}"))
"""Redirect to L2 server for registration. L1 never handles credentials."""
return_url = f"{L1_PUBLIC_URL}/runs"
return RedirectResponse(url=f"{L2_SERVER}/register?return_to={return_url}", status_code=302)
@app.get("/logout")
async def logout():
"""Logout - clear cookie (clean URL)."""
response = RedirectResponse(url="/runs", status_code=303)
"""Logout - clear cookie and redirect to L2 logout."""
# Clear local cookie and redirect to L2 to clear shared cookie
response = RedirectResponse(url=f"{L2_SERVER}/logout?return_to={L1_PUBLIC_URL}/", status_code=302)
response.delete_cookie("auth_token")
return response
@@ -3194,41 +3091,25 @@ async def logout():
async def ui_index(tab: str = "runs"):
"""Redirect /ui to clean URLs."""
if tab == "cache":
return RedirectResponse(url="/cache", status_code=302)
return RedirectResponse(url="/media", status_code=302)
return RedirectResponse(url="/runs", status_code=302)
@app.get("/ui/login")
async def ui_login_page():
"""Redirect to clean URL."""
"""Redirect to L2 login."""
return RedirectResponse(url="/login", status_code=302)
@app.post("/ui/login")
async def ui_login(username: str = Form(...), password: str = Form(...)):
"""Redirect POST to clean URL handler."""
return await login(username, password)
@app.get("/ui/register")
async def ui_register_page():
"""Redirect to clean URL."""
"""Redirect to L2 register."""
return RedirectResponse(url="/register", status_code=302)
@app.post("/ui/register")
async def ui_register(
username: str = Form(...),
password: str = Form(...),
email: str = Form(None)
):
"""Redirect POST to clean URL handler."""
return await register(username, password, email)
@app.get("/ui/logout")
async def ui_logout():
"""Redirect to clean URL."""
"""Redirect to logout."""
return RedirectResponse(url="/logout", status_code=302)
@@ -3392,19 +3273,19 @@ async def ui_runs(request: Request):
return '\n'.join(html_parts)
@app.get("/ui/cache-list", response_class=HTMLResponse)
async def ui_cache_list(
@app.get("/ui/media-list", response_class=HTMLResponse)
async def ui_media_list(
request: Request,
folder: Optional[str] = None,
collection: Optional[str] = None,
tag: Optional[str] = None
):
"""HTMX partial: list of cached items with optional filtering."""
"""HTMX partial: list of media items with optional filtering."""
current_user = get_user_from_cookie(request)
# Require login to see cache
# Require login to see media
if not current_user:
return '<p class="text-gray-400 py-8 text-center"><a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a> to see cached content.</p>'
return '<p class="text-gray-400 py-8 text-center"><a href="/login" class="text-blue-400 hover:text-blue-300">Login</a> to see media.</p>'
# Get hashes owned by/associated with this user
user_hashes = get_user_cache_hashes(current_user)