feat: add HTMX web UI with login/register forms
- Home page showing README and stats - Login/register forms with HTMX - Registry and activities pages - Cookie-based auth for web UI - JWT secret from Docker secrets (/run/secrets/jwt_secret) - Updated README with secret generation instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
488
server.py
488
server.py
@@ -18,11 +18,12 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, Depends, Cookie
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
import requests
|
||||
import markdown
|
||||
|
||||
from auth import (
|
||||
UserCreate, UserLogin, Token, User,
|
||||
@@ -40,6 +41,12 @@ L1_SERVER = os.environ.get("ARTDAG_L1", "http://localhost:8100")
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(DATA_DIR / "assets").mkdir(exist_ok=True)
|
||||
|
||||
# Load README
|
||||
README_PATH = Path(__file__).parent / "README.md"
|
||||
README_CONTENT = ""
|
||||
if README_PATH.exists():
|
||||
README_CONTENT = README_PATH.read_text()
|
||||
|
||||
app = FastAPI(
|
||||
title="Art DAG L2 Server",
|
||||
description="ActivityPub server for Art DAG ownership and federation",
|
||||
@@ -184,11 +191,482 @@ def sign_activity(activity: dict) -> dict:
|
||||
return activity
|
||||
|
||||
|
||||
# ============ ActivityPub Endpoints ============
|
||||
# ============ HTML Templates ============
|
||||
|
||||
def base_html(title: str, content: str, username: str = None) -> str:
|
||||
"""Base HTML template."""
|
||||
user_section = f'''
|
||||
<div class="user-info">
|
||||
Logged in as <strong>{username}</strong>
|
||||
<button hx-post="/ui/logout" hx-swap="innerHTML" hx-target="body">Logout</button>
|
||||
</div>
|
||||
''' if username else '''
|
||||
<div class="auth-links">
|
||||
<a href="/ui/login">Login</a> | <a href="/ui/register">Register</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} - Art DAG L2</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}}
|
||||
header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 2px solid #333;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}}
|
||||
header h1 {{
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}}
|
||||
header h1 a {{
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.user-info, .auth-links {{
|
||||
font-size: 0.9rem;
|
||||
}}
|
||||
.auth-links a {{
|
||||
color: #0066cc;
|
||||
}}
|
||||
.user-info button {{
|
||||
margin-left: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}}
|
||||
nav {{
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}}
|
||||
nav a {{
|
||||
margin-right: 15px;
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}}
|
||||
nav a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
main {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.form-group {{
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.form-group label {{
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.form-group input {{
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}}
|
||||
button[type="submit"], .btn {{
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}}
|
||||
button[type="submit"]:hover, .btn:hover {{
|
||||
background: #0055aa;
|
||||
}}
|
||||
.error {{
|
||||
color: #cc0000;
|
||||
padding: 10px;
|
||||
background: #ffeeee;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.success {{
|
||||
color: #006600;
|
||||
padding: 10px;
|
||||
background: #eeffee;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
/* README styling */
|
||||
.readme h1 {{ font-size: 1.8rem; margin-top: 0; }}
|
||||
.readme h2 {{ font-size: 1.4rem; margin-top: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 5px; }}
|
||||
.readme h3 {{ font-size: 1.1rem; }}
|
||||
.readme pre {{
|
||||
background: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}}
|
||||
.readme code {{
|
||||
background: #f4f4f4;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}}
|
||||
.readme pre code {{
|
||||
background: none;
|
||||
padding: 0;
|
||||
}}
|
||||
.readme table {{
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}}
|
||||
.readme th, .readme td {{
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
.readme th {{
|
||||
background: #f4f4f4;
|
||||
}}
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.stat-box {{
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-box .number {{
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}}
|
||||
.stat-box .label {{
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}}
|
||||
@media (max-width: 600px) {{
|
||||
body {{ padding: 10px; }}
|
||||
header {{ flex-direction: column; align-items: flex-start; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/ui">Art DAG L2</a></h1>
|
||||
{user_section}
|
||||
</header>
|
||||
<nav>
|
||||
<a href="/ui">Home</a>
|
||||
<a href="/ui/registry">Registry</a>
|
||||
<a href="/ui/activities">Activities</a>
|
||||
<a href="{L1_SERVER}/ui" target="_blank">L1 Server</a>
|
||||
</nav>
|
||||
<main>
|
||||
{content}
|
||||
</main>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
def get_user_from_cookie(request: Request) -> Optional[str]:
|
||||
"""Get username from auth cookie."""
|
||||
token = request.cookies.get("auth_token")
|
||||
if token:
|
||||
return verify_token(token)
|
||||
return None
|
||||
|
||||
|
||||
# ============ UI Endpoints ============
|
||||
|
||||
@app.get("/ui", response_class=HTMLResponse)
|
||||
async def ui_home(request: Request):
|
||||
"""Home page with README and stats."""
|
||||
username = get_user_from_cookie(request)
|
||||
registry = load_registry()
|
||||
activities = load_activities()
|
||||
users = load_users(DATA_DIR)
|
||||
|
||||
readme_html = markdown.markdown(README_CONTENT, extensions=['tables', 'fenced_code'])
|
||||
|
||||
content = f'''
|
||||
<div class="stats">
|
||||
<div class="stat-box">
|
||||
<div class="number">{len(registry.get("assets", {}))}</div>
|
||||
<div class="label">Assets</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="number">{len(activities)}</div>
|
||||
<div class="label">Activities</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="number">{len(users)}</div>
|
||||
<div class="label">Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="readme">
|
||||
{readme_html}
|
||||
</div>
|
||||
'''
|
||||
return HTMLResponse(base_html("Home", content, username))
|
||||
|
||||
|
||||
@app.get("/ui/login", response_class=HTMLResponse)
|
||||
async def ui_login_page(request: Request):
|
||||
"""Login page."""
|
||||
username = get_user_from_cookie(request)
|
||||
if username:
|
||||
return HTMLResponse(base_html("Already Logged In", f'''
|
||||
<div class="success">You are already logged in as <strong>{username}</strong></div>
|
||||
<p><a href="/ui">Go to home page</a></p>
|
||||
''', username))
|
||||
|
||||
content = '''
|
||||
<h2>Login</h2>
|
||||
<div id="login-result"></div>
|
||||
<form hx-post="/ui/login" hx-target="#login-result" hx-swap="innerHTML">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<p style="margin-top: 20px;">Don't have an account? <a href="/ui/register">Register</a></p>
|
||||
'''
|
||||
return HTMLResponse(base_html("Login", content))
|
||||
|
||||
|
||||
@app.post("/ui/login", response_class=HTMLResponse)
|
||||
async def ui_login_submit(request: Request):
|
||||
"""Handle login form submission."""
|
||||
form = await request.form()
|
||||
username = form.get("username", "").strip()
|
||||
password = form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
return HTMLResponse('<div class="error">Username and password are required</div>')
|
||||
|
||||
user = authenticate_user(DATA_DIR, username, password)
|
||||
if not user:
|
||||
return HTMLResponse('<div class="error">Invalid username or password</div>')
|
||||
|
||||
token = create_access_token(user.username)
|
||||
|
||||
response = HTMLResponse(f'''
|
||||
<div class="success">Login successful! Redirecting...</div>
|
||||
<script>window.location.href = "/ui";</script>
|
||||
''')
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value=token.access_token,
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 30, # 30 days
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/ui/register", response_class=HTMLResponse)
|
||||
async def ui_register_page(request: Request):
|
||||
"""Register page."""
|
||||
username = get_user_from_cookie(request)
|
||||
if username:
|
||||
return HTMLResponse(base_html("Already Logged In", f'''
|
||||
<div class="success">You are already logged in as <strong>{username}</strong></div>
|
||||
<p><a href="/ui">Go to home page</a></p>
|
||||
''', username))
|
||||
|
||||
content = '''
|
||||
<h2>Register</h2>
|
||||
<div id="register-result"></div>
|
||||
<form hx-post="/ui/register" hx-target="#register-result" hx-swap="innerHTML">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, underscore, dash only">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email (optional)</label>
|
||||
<input type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password2">Confirm Password</label>
|
||||
<input type="password" id="password2" name="password2" required minlength="6">
|
||||
</div>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p style="margin-top: 20px;">Already have an account? <a href="/ui/login">Login</a></p>
|
||||
'''
|
||||
return HTMLResponse(base_html("Register", content))
|
||||
|
||||
|
||||
@app.post("/ui/register", response_class=HTMLResponse)
|
||||
async def ui_register_submit(request: Request):
|
||||
"""Handle register form submission."""
|
||||
form = await request.form()
|
||||
username = form.get("username", "").strip()
|
||||
email = form.get("email", "").strip() or None
|
||||
password = form.get("password", "")
|
||||
password2 = form.get("password2", "")
|
||||
|
||||
if not username or not password:
|
||||
return HTMLResponse('<div class="error">Username and password are required</div>')
|
||||
|
||||
if password != password2:
|
||||
return HTMLResponse('<div class="error">Passwords do not match</div>')
|
||||
|
||||
if len(password) < 6:
|
||||
return HTMLResponse('<div class="error">Password must be at least 6 characters</div>')
|
||||
|
||||
try:
|
||||
user = create_user(DATA_DIR, username, password, email)
|
||||
except ValueError as e:
|
||||
return HTMLResponse(f'<div class="error">{str(e)}</div>')
|
||||
|
||||
token = create_access_token(user.username)
|
||||
|
||||
response = HTMLResponse(f'''
|
||||
<div class="success">Registration successful! Redirecting...</div>
|
||||
<script>window.location.href = "/ui";</script>
|
||||
''')
|
||||
response.set_cookie(
|
||||
key="auth_token",
|
||||
value=token.access_token,
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 30, # 30 days
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/ui/logout", response_class=HTMLResponse)
|
||||
async def ui_logout():
|
||||
"""Handle logout."""
|
||||
response = HTMLResponse('''
|
||||
<script>window.location.href = "/ui";</script>
|
||||
''')
|
||||
response.delete_cookie("auth_token")
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/ui/registry", response_class=HTMLResponse)
|
||||
async def ui_registry_page(request: Request):
|
||||
"""Registry page showing all assets."""
|
||||
username = get_user_from_cookie(request)
|
||||
registry = load_registry()
|
||||
assets = registry.get("assets", {})
|
||||
|
||||
if not assets:
|
||||
content = '<h2>Registry</h2><p>No assets registered yet.</p>'
|
||||
else:
|
||||
rows = ""
|
||||
for name, asset in sorted(assets.items(), key=lambda x: x[1].get("created_at", ""), reverse=True):
|
||||
hash_short = asset.get("content_hash", "")[:16] + "..."
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td><strong>{name}</strong></td>
|
||||
<td>{asset.get("asset_type", "")}</td>
|
||||
<td><code>{hash_short}</code></td>
|
||||
<td>{", ".join(asset.get("tags", []))}</td>
|
||||
</tr>
|
||||
'''
|
||||
content = f'''
|
||||
<h2>Registry ({len(assets)} assets)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Content Hash</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
'''
|
||||
return HTMLResponse(base_html("Registry", content, username))
|
||||
|
||||
|
||||
@app.get("/ui/activities", response_class=HTMLResponse)
|
||||
async def ui_activities_page(request: Request):
|
||||
"""Activities page showing all signed activities."""
|
||||
username = get_user_from_cookie(request)
|
||||
activities = load_activities()
|
||||
|
||||
if not activities:
|
||||
content = '<h2>Activities</h2><p>No activities yet.</p>'
|
||||
else:
|
||||
rows = ""
|
||||
for activity in reversed(activities):
|
||||
obj = activity.get("object_data", {})
|
||||
rows += f'''
|
||||
<tr>
|
||||
<td>{activity.get("activity_type", "")}</td>
|
||||
<td>{obj.get("name", "")}</td>
|
||||
<td><code>{obj.get("contentHash", {}).get("value", "")[:16]}...</code></td>
|
||||
<td>{activity.get("published", "")[:10]}</td>
|
||||
</tr>
|
||||
'''
|
||||
content = f'''
|
||||
<h2>Activities ({len(activities)} total)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Object</th>
|
||||
<th>Content Hash</th>
|
||||
<th>Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
'''
|
||||
return HTMLResponse(base_html("Activities", content, username))
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Server info."""
|
||||
async def root(request: Request):
|
||||
"""Server info or redirect to UI."""
|
||||
# If browser, redirect to UI
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept and "application/json" not in accept:
|
||||
return HTMLResponse(status_code=302, headers={"Location": "/ui"})
|
||||
|
||||
registry = load_registry()
|
||||
activities = load_activities()
|
||||
users = load_users(DATA_DIR)
|
||||
|
||||
Reference in New Issue
Block a user