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:
195
server.py
195
server.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user