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."""
|
"""Render a page with nav bar and content. Used for clean URL pages."""
|
||||||
user_info = ""
|
user_info = ""
|
||||||
if username:
|
if username:
|
||||||
|
# Display as @user@server format
|
||||||
|
actor_id = f"@{username}@{L2_DOMAIN}" if not username.startswith("@") else username
|
||||||
user_info = f'''
|
user_info = f'''
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-400">
|
<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>
|
<a href="/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
|
||||||
</div>
|
</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."""
|
"""Render main UI HTML with optional user context."""
|
||||||
user_info = ""
|
user_info = ""
|
||||||
if username:
|
if username:
|
||||||
|
# Display as @user@server format
|
||||||
|
actor_id = f"@{username}@{L2_DOMAIN}" if not username.startswith("@") else username
|
||||||
user_info = f'''
|
user_info = f'''
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-400">
|
<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="/ui/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
|
<a href="/logout" class="text-blue-400 hover:text-blue-300">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
'''
|
'''
|
||||||
else:
|
else:
|
||||||
user_info = '''
|
user_info = '''
|
||||||
<div class="text-sm">
|
<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>
|
</div>
|
||||||
'''
|
'''
|
||||||
|
|
||||||
runs_active = "border-b-2 border-blue-500 text-white" if tab == "runs" else "text-gray-400 hover:text-white"
|
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"
|
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":
|
if tab == "runs":
|
||||||
content_url = "/ui/runs"
|
content_url = "/ui/runs"
|
||||||
elif tab == "recipes":
|
elif tab == "recipes":
|
||||||
content_url = "/ui/recipes-list"
|
content_url = "/ui/recipes-list"
|
||||||
else:
|
else:
|
||||||
content_url = "/ui/cache-list"
|
content_url = "/ui/media-list"
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<!DOCTYPE html>
|
<!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">
|
<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">
|
<header class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<h1 class="text-2xl font-bold">
|
<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>
|
</h1>
|
||||||
{user_info}
|
{user_info}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="flex gap-6 mb-6 border-b border-dark-500 pb-0">
|
<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="/runs" 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="/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="/media" class="pb-3 px-1 font-medium transition-colors {media_active}">Media</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="content" hx-get="{content_url}" hx-trigger="load" hx-swap="innerHTML">
|
<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:
|
# Auth routes - L1 never handles credentials, redirects to L2
|
||||||
"""Generate login or register page HTML with Tailwind CSS."""
|
# L2 sets auth_token cookie with domain=.rose-ash.com for shared auth
|
||||||
is_login = page_type == "login"
|
|
||||||
title = "Login" if is_login else "Register"
|
|
||||||
|
|
||||||
login_active = "bg-blue-600 text-white" if is_login else "bg-dark-500 text-gray-400 hover:text-white"
|
L1_PUBLIC_URL = os.environ.get("L1_PUBLIC_URL", "http://localhost:8100")
|
||||||
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>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
UI_LOGIN_HTML = get_auth_page_html("login")
|
@app.get("/login")
|
||||||
UI_REGISTER_HTML = get_auth_page_html("register")
|
|
||||||
|
|
||||||
|
|
||||||
# Clean URL auth routes
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
|
||||||
async def login_page():
|
async def login_page():
|
||||||
"""Login page (clean URL)."""
|
"""Redirect to L2 server for login. L1 never handles credentials."""
|
||||||
return UI_LOGIN_HTML
|
# 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")
|
@app.get("/register")
|
||||||
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)
|
|
||||||
async def register_page():
|
async def register_page():
|
||||||
"""Register page (clean URL)."""
|
"""Redirect to L2 server for registration. L1 never handles credentials."""
|
||||||
return UI_REGISTER_HTML
|
return_url = f"{L1_PUBLIC_URL}/runs"
|
||||||
|
return RedirectResponse(url=f"{L2_SERVER}/register?return_to={return_url}", status_code=302)
|
||||||
|
|
||||||
@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}"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
"""Logout - clear cookie (clean URL)."""
|
"""Logout - clear cookie and redirect to L2 logout."""
|
||||||
response = RedirectResponse(url="/runs", status_code=303)
|
# 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")
|
response.delete_cookie("auth_token")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -3194,41 +3091,25 @@ async def logout():
|
|||||||
async def ui_index(tab: str = "runs"):
|
async def ui_index(tab: str = "runs"):
|
||||||
"""Redirect /ui to clean URLs."""
|
"""Redirect /ui to clean URLs."""
|
||||||
if tab == "cache":
|
if tab == "cache":
|
||||||
return RedirectResponse(url="/cache", status_code=302)
|
return RedirectResponse(url="/media", status_code=302)
|
||||||
return RedirectResponse(url="/runs", status_code=302)
|
return RedirectResponse(url="/runs", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ui/login")
|
@app.get("/ui/login")
|
||||||
async def ui_login_page():
|
async def ui_login_page():
|
||||||
"""Redirect to clean URL."""
|
"""Redirect to L2 login."""
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
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")
|
@app.get("/ui/register")
|
||||||
async def ui_register_page():
|
async def ui_register_page():
|
||||||
"""Redirect to clean URL."""
|
"""Redirect to L2 register."""
|
||||||
return RedirectResponse(url="/register", status_code=302)
|
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")
|
@app.get("/ui/logout")
|
||||||
async def ui_logout():
|
async def ui_logout():
|
||||||
"""Redirect to clean URL."""
|
"""Redirect to logout."""
|
||||||
return RedirectResponse(url="/logout", status_code=302)
|
return RedirectResponse(url="/logout", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@@ -3392,19 +3273,19 @@ async def ui_runs(request: Request):
|
|||||||
return '\n'.join(html_parts)
|
return '\n'.join(html_parts)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ui/cache-list", response_class=HTMLResponse)
|
@app.get("/ui/media-list", response_class=HTMLResponse)
|
||||||
async def ui_cache_list(
|
async def ui_media_list(
|
||||||
request: Request,
|
request: Request,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
collection: Optional[str] = None,
|
collection: Optional[str] = None,
|
||||||
tag: 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)
|
current_user = get_user_from_cookie(request)
|
||||||
|
|
||||||
# Require login to see cache
|
# Require login to see media
|
||||||
if not current_user:
|
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
|
# Get hashes owned by/associated with this user
|
||||||
user_hashes = get_user_cache_hashes(current_user)
|
user_hashes = get_user_cache_hashes(current_user)
|
||||||
|
|||||||
Reference in New Issue
Block a user