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:
gilesb
2026-01-07 15:37:35 +00:00
parent a2190801e8
commit d83510f24b
4 changed files with 552 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ Ownership registry and ActivityPub federation for Art DAG.
- **Activities**: Creates signed ownership claims (Create activities)
- **Federation**: ActivityPub endpoints for follow/share
- **L1 Integration**: Records completed L1 runs as owned assets
- **Authentication**: User registration, login, JWT tokens
## Setup
@@ -27,6 +28,53 @@ python setup_keys.py
python server.py
```
## JWT Secret Configuration
The JWT secret is used to sign authentication tokens. **Without a persistent secret, tokens are invalidated on server restart.**
### Generate a secret
```bash
# Generate a 64-character hex secret
openssl rand -hex 32
# Or with Python
python -c "import secrets; print(secrets.token_hex(32))"
```
### Local development
```bash
export JWT_SECRET="your-generated-secret-here"
python server.py
```
### Docker Swarm (recommended for production)
Create a Docker secret:
```bash
# From a generated value
openssl rand -hex 32 | docker secret create jwt_secret -
# Or from a file
echo "your-secret-here" > jwt_secret.txt
docker secret create jwt_secret jwt_secret.txt
rm jwt_secret.txt
```
Reference in docker-compose.yml:
```yaml
services:
l2-server:
secrets:
- jwt_secret
secrets:
jwt_secret:
external: true
```
The server reads secrets from `/run/secrets/jwt_secret` automatically.
## Key Setup
ActivityPub requires RSA keys for signing activities. Generate them:

20
auth.py
View File

@@ -20,11 +20,29 @@ from pydantic import BaseModel
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.environ.get("JWT_SECRET", secrets.token_hex(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
def load_jwt_secret() -> str:
"""Load JWT secret from Docker secret, env var, or generate."""
# Try Docker secret first
secret_path = Path("/run/secrets/jwt_secret")
if secret_path.exists():
return secret_path.read_text().strip()
# Try environment variable
if os.environ.get("JWT_SECRET"):
return os.environ["JWT_SECRET"]
# Generate one (tokens won't persist across restarts!)
print("WARNING: No JWT_SECRET configured. Tokens will be invalidated on restart.")
return secrets.token_hex(32)
SECRET_KEY = load_jwt_secret()
class User(BaseModel):
"""A registered user."""
username: str

View File

@@ -4,3 +4,5 @@ requests>=2.31.0
cryptography>=42.0.0
passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
markdown>=3.5.0
python-multipart>=0.0.6

488
server.py
View File

@@ -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)