- /users/{username}: Redirects to /ui/user/{username} for browsers (Accept: text/html)
- /objects/{hash}: Redirects to /ui/asset/{name} for browsers
- APIs still get JSON (application/activity+json) as before
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1825 lines
73 KiB
Python
1825 lines
73 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Art DAG L2 Server - ActivityPub
|
|
|
|
Manages ownership registry, activities, and federation.
|
|
- Registry of owned assets
|
|
- ActivityPub actor endpoints
|
|
- Sign and publish Create activities
|
|
- Federation with other servers
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
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,
|
|
create_user, authenticate_user, create_access_token,
|
|
verify_token, get_current_user, load_users
|
|
)
|
|
|
|
# Configuration
|
|
DOMAIN = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com")
|
|
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
|
L1_PUBLIC_URL = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash.com")
|
|
EFFECTS_REPO_URL = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
|
|
|
|
# Ensure data directory exists
|
|
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",
|
|
version="0.1.0"
|
|
)
|
|
|
|
|
|
# ============ Data Models ============
|
|
|
|
class Asset(BaseModel):
|
|
"""An owned asset."""
|
|
name: str
|
|
content_hash: str
|
|
asset_type: str # image, video, effect, recipe, infrastructure
|
|
tags: list[str] = []
|
|
metadata: dict = {}
|
|
url: Optional[str] = None
|
|
provenance: Optional[dict] = None
|
|
created_at: str = ""
|
|
|
|
|
|
class Activity(BaseModel):
|
|
"""An ActivityPub activity."""
|
|
activity_id: str
|
|
activity_type: str # Create, Update, Delete, Announce
|
|
actor_id: str
|
|
object_data: dict
|
|
published: str
|
|
signature: Optional[dict] = None
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
"""Request to register an asset."""
|
|
name: str
|
|
content_hash: str
|
|
asset_type: str
|
|
tags: list[str] = []
|
|
metadata: dict = {}
|
|
url: Optional[str] = None
|
|
provenance: Optional[dict] = None
|
|
|
|
|
|
class RecordRunRequest(BaseModel):
|
|
"""Request to record an L1 run."""
|
|
run_id: str
|
|
output_name: str
|
|
l1_server: str # URL of the L1 server that has this run
|
|
|
|
|
|
class PublishCacheRequest(BaseModel):
|
|
"""Request to publish a cache item from L1."""
|
|
content_hash: str
|
|
asset_name: str
|
|
asset_type: str = "image"
|
|
origin: dict # {type: "self"|"external", url?: str, note?: str}
|
|
description: Optional[str] = None
|
|
tags: list[str] = []
|
|
metadata: dict = {}
|
|
|
|
|
|
class UpdateAssetRequest(BaseModel):
|
|
"""Request to update an existing asset."""
|
|
description: Optional[str] = None
|
|
tags: Optional[list[str]] = None
|
|
metadata: Optional[dict] = None
|
|
origin: Optional[dict] = None
|
|
|
|
|
|
# ============ Storage ============
|
|
|
|
def load_registry() -> dict:
|
|
"""Load registry from disk."""
|
|
path = DATA_DIR / "registry.json"
|
|
if path.exists():
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
return {"version": "1.0", "assets": {}}
|
|
|
|
|
|
def save_registry(registry: dict):
|
|
"""Save registry to disk."""
|
|
path = DATA_DIR / "registry.json"
|
|
with open(path, "w") as f:
|
|
json.dump(registry, f, indent=2)
|
|
|
|
|
|
def load_activities() -> list:
|
|
"""Load activities from disk."""
|
|
path = DATA_DIR / "activities.json"
|
|
if path.exists():
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
return data.get("activities", [])
|
|
return []
|
|
|
|
|
|
def save_activities(activities: list):
|
|
"""Save activities to disk."""
|
|
path = DATA_DIR / "activities.json"
|
|
with open(path, "w") as f:
|
|
json.dump({"version": "1.0", "activities": activities}, f, indent=2)
|
|
|
|
|
|
def load_actor(username: str) -> dict:
|
|
"""Load actor data for a specific user with public key if available."""
|
|
actor = {
|
|
"id": f"https://{DOMAIN}/users/{username}",
|
|
"type": "Person",
|
|
"preferredUsername": username,
|
|
"name": username,
|
|
"inbox": f"https://{DOMAIN}/users/{username}/inbox",
|
|
"outbox": f"https://{DOMAIN}/users/{username}/outbox",
|
|
"followers": f"https://{DOMAIN}/users/{username}/followers",
|
|
"following": f"https://{DOMAIN}/users/{username}/following",
|
|
}
|
|
|
|
# Add public key if available
|
|
from keys import has_keys, load_public_key_pem
|
|
if has_keys(DATA_DIR, username):
|
|
actor["publicKey"] = {
|
|
"id": f"https://{DOMAIN}/users/{username}#main-key",
|
|
"owner": f"https://{DOMAIN}/users/{username}",
|
|
"publicKeyPem": load_public_key_pem(DATA_DIR, username)
|
|
}
|
|
|
|
return actor
|
|
|
|
|
|
def user_exists(username: str) -> bool:
|
|
"""Check if a user exists."""
|
|
users = load_users(DATA_DIR)
|
|
return username in users
|
|
|
|
|
|
def load_followers() -> list:
|
|
"""Load followers list."""
|
|
path = DATA_DIR / "followers.json"
|
|
if path.exists():
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
return []
|
|
|
|
|
|
def save_followers(followers: list):
|
|
"""Save followers list."""
|
|
path = DATA_DIR / "followers.json"
|
|
with open(path, "w") as f:
|
|
json.dump(followers, f, indent=2)
|
|
|
|
|
|
# ============ Signing ============
|
|
|
|
from keys import has_keys, load_public_key_pem, create_signature
|
|
|
|
|
|
def sign_activity(activity: dict, username: str) -> dict:
|
|
"""Sign an activity with the user's RSA private key."""
|
|
if not has_keys(DATA_DIR, username):
|
|
# No keys - use placeholder (for testing)
|
|
activity["signature"] = {
|
|
"type": "RsaSignature2017",
|
|
"creator": f"https://{DOMAIN}/users/{username}#main-key",
|
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
"signatureValue": "NO_KEYS_CONFIGURED"
|
|
}
|
|
else:
|
|
activity["signature"] = create_signature(DATA_DIR, username, DOMAIN, activity)
|
|
return activity
|
|
|
|
|
|
# ============ HTML Templates ============
|
|
|
|
# Tailwind CSS config for L2 - dark theme to match L1
|
|
TAILWIND_CONFIG = '''
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
dark: { 900: '#0a0a0a', 800: '#111', 700: '#1a1a1a', 600: '#222', 500: '#333' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
'''
|
|
|
|
|
|
def base_html(title: str, content: str, username: str = None) -> str:
|
|
"""Base HTML template with Tailwind CSS dark theme."""
|
|
user_section = f'''
|
|
<div class="flex items-center gap-4 text-sm text-gray-400">
|
|
Logged in as <strong class="text-white">{username}</strong>
|
|
<button hx-post="/ui/logout" hx-swap="innerHTML" hx-target="body"
|
|
class="px-3 py-1 bg-dark-500 hover:bg-dark-600 rounded text-blue-400 hover:text-blue-300 transition-colors">
|
|
Logout
|
|
</button>
|
|
</div>
|
|
''' if username else '''
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a>
|
|
<span class="text-gray-500">|</span>
|
|
<a href="/ui/register" class="text-blue-400 hover:text-blue-300">Register</a>
|
|
</div>
|
|
'''
|
|
|
|
return f'''<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{title} - Art DAG L2</title>
|
|
{TAILWIND_CONFIG}
|
|
</head>
|
|
<body class="bg-dark-900 text-gray-100 min-h-screen">
|
|
<div class="max-w-5xl 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 pb-4 border-b border-dark-500">
|
|
<h1 class="text-2xl font-bold">
|
|
<a href="/ui" class="text-white hover:text-gray-200">Art DAG L2</a>
|
|
</h1>
|
|
{user_section}
|
|
</header>
|
|
|
|
<nav class="flex flex-wrap gap-6 mb-6 pb-4 border-b border-dark-500">
|
|
<a href="/ui" class="text-gray-400 hover:text-white transition-colors">Home</a>
|
|
<a href="/ui/registry" class="text-gray-400 hover:text-white transition-colors">Registry</a>
|
|
<a href="/ui/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
|
|
<a href="/ui/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
|
|
</nav>
|
|
|
|
<main class="bg-dark-700 rounded-lg p-6">
|
|
{content}
|
|
</main>
|
|
</div>
|
|
</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="grid gap-4 sm:grid-cols-3 mb-8">
|
|
<div class="bg-dark-600 rounded-lg p-6 text-center">
|
|
<div class="text-3xl font-bold text-blue-400">{len(registry.get("assets", {}))}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Assets</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-6 text-center">
|
|
<div class="text-3xl font-bold text-blue-400">{len(activities)}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Activities</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-6 text-center">
|
|
<div class="text-3xl font-bold text-blue-400">{len(users)}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Users</div>
|
|
</div>
|
|
</div>
|
|
<div class="prose prose-invert max-w-none
|
|
prose-headings:text-white prose-headings:border-b prose-headings:border-dark-500 prose-headings:pb-2
|
|
prose-h1:text-2xl prose-h1:mt-0
|
|
prose-h2:text-xl prose-h2:mt-6
|
|
prose-a:text-blue-400 hover:prose-a:text-blue-300
|
|
prose-pre:bg-dark-600 prose-pre:border prose-pre:border-dark-500
|
|
prose-code:bg-dark-600 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-green-300
|
|
prose-table:border-collapse
|
|
prose-th:bg-dark-600 prose-th:border prose-th:border-dark-500 prose-th:px-4 prose-th:py-2
|
|
prose-td:border prose-td:border-dark-500 prose-td:px-4 prose-td:py-2">
|
|
{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="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
|
|
You are already logged in as <strong>{username}</strong>
|
|
</div>
|
|
<p><a href="/ui" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
|
|
''', username))
|
|
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Login</h2>
|
|
<div id="login-result"></div>
|
|
<form hx-post="/ui/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4 max-w-md">
|
|
<div>
|
|
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
|
<input type="text" id="username" name="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">
|
|
</div>
|
|
<div>
|
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
|
<input type="password" id="password" name="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">
|
|
</div>
|
|
<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">
|
|
Login
|
|
</button>
|
|
</form>
|
|
<p class="mt-6 text-gray-400">Don't have an account? <a href="/ui/register" class="text-blue-400 hover:text-blue-300">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="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Username and password are required</div>')
|
|
|
|
user = authenticate_user(DATA_DIR, username, password)
|
|
if not user:
|
|
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Invalid username or password</div>')
|
|
|
|
token = create_access_token(user.username)
|
|
|
|
response = HTMLResponse(f'''
|
|
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">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="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
|
|
You are already logged in as <strong>{username}</strong>
|
|
</div>
|
|
<p><a href="/ui" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
|
|
''', username))
|
|
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Register</h2>
|
|
<div id="register-result"></div>
|
|
<form hx-post="/ui/register" hx-target="#register-result" hx-swap="innerHTML" class="space-y-4 max-w-md">
|
|
<div>
|
|
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
|
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, underscore, dash only"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email (optional)</label>
|
|
<input type="email" id="email" name="email"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
|
<input type="password" id="password" name="password" required minlength="6"
|
|
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">
|
|
</div>
|
|
<div>
|
|
<label for="password2" class="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
|
|
<input type="password" id="password2" name="password2" required minlength="6"
|
|
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">
|
|
</div>
|
|
<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">
|
|
Register
|
|
</button>
|
|
</form>
|
|
<p class="mt-6 text-gray-400">Already have an account? <a href="/ui/login" class="text-blue-400 hover:text-blue-300">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="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Username and password are required</div>')
|
|
|
|
if password != password2:
|
|
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Passwords do not match</div>')
|
|
|
|
if len(password) < 6:
|
|
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">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="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">{str(e)}</div>')
|
|
|
|
token = create_access_token(user.username)
|
|
|
|
response = HTMLResponse(f'''
|
|
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">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 class="text-xl font-semibold text-white mb-4">Registry</h2>
|
|
<p class="text-gray-400">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] + "..."
|
|
owner = asset.get("owner", "unknown")
|
|
asset_type = asset.get("asset_type", "")
|
|
type_color = "bg-blue-600" if asset_type == "image" else "bg-purple-600" if asset_type == "video" else "bg-gray-600"
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4">
|
|
<a href="/ui/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
|
|
</td>
|
|
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs rounded">{asset_type}</span></td>
|
|
<td class="py-3 px-4">
|
|
<a href="/ui/user/{owner}" class="text-gray-400 hover:text-blue-300">{owner}</a>
|
|
</td>
|
|
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{hash_short}</code></td>
|
|
<td class="py-3 px-4 text-gray-400">{", ".join(asset.get("tags", []))}</td>
|
|
</tr>
|
|
'''
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Registry ({len(assets)} assets)</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-dark-600 text-left">
|
|
<th class="py-3 px-4 font-medium text-gray-300">Name</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Owner</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Tags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
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 class="text-xl font-semibold text-white mb-4">Activities</h2>
|
|
<p class="text-gray-400">No activities yet.</p>
|
|
'''
|
|
else:
|
|
rows = ""
|
|
for i, activity in enumerate(reversed(activities)):
|
|
# Index from end since we reversed
|
|
activity_index = len(activities) - 1 - i
|
|
obj = activity.get("object_data", {})
|
|
activity_type = activity.get("activity_type", "")
|
|
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
|
|
actor_id = activity.get("actor_id", "")
|
|
actor_name = actor_id.split("/")[-1] if actor_id else "unknown"
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs font-medium rounded">{activity_type}</span></td>
|
|
<td class="py-3 px-4 text-white">{obj.get("name", "Untitled")}</td>
|
|
<td class="py-3 px-4">
|
|
<a href="/ui/user/{actor_name}" class="text-gray-400 hover:text-blue-300">{actor_name}</a>
|
|
</td>
|
|
<td class="py-3 px-4 text-gray-400">{activity.get("published", "")[:10]}</td>
|
|
<td class="py-3 px-4">
|
|
<a href="/ui/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
|
|
</td>
|
|
</tr>
|
|
'''
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Activities ({len(activities)} total)</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-dark-600 text-left">
|
|
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Object</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Actor</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Published</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html("Activities", content, username))
|
|
|
|
|
|
@app.get("/ui/activity/{activity_index}", response_class=HTMLResponse)
|
|
async def ui_activity_detail(activity_index: int, request: Request):
|
|
"""Activity detail page with full content display."""
|
|
username = get_user_from_cookie(request)
|
|
activities = load_activities()
|
|
|
|
if activity_index < 0 or activity_index >= len(activities):
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Activity Not Found</h2>
|
|
<p class="text-gray-400">This activity does not exist.</p>
|
|
<p class="mt-4"><a href="/ui/activities" class="text-blue-400 hover:text-blue-300">← Back to Activities</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Activity Not Found", content, username))
|
|
|
|
activity = activities[activity_index]
|
|
activity_type = activity.get("activity_type", "")
|
|
activity_id = activity.get("activity_id", "")
|
|
actor_id = activity.get("actor_id", "")
|
|
actor_name = actor_id.split("/")[-1] if actor_id else "unknown"
|
|
published = activity.get("published", "")[:10]
|
|
obj = activity.get("object_data", {})
|
|
|
|
# Object details
|
|
obj_name = obj.get("name", "Untitled")
|
|
obj_type = obj.get("type", "")
|
|
content_hash_obj = obj.get("contentHash", {})
|
|
content_hash = content_hash_obj.get("value", "") if isinstance(content_hash_obj, dict) else ""
|
|
media_type = obj.get("mediaType", "")
|
|
description = obj.get("summary", "") or obj.get("content", "")
|
|
|
|
# Provenance from object - or fallback to registry asset
|
|
provenance = obj.get("provenance", {})
|
|
origin = obj.get("origin", {})
|
|
|
|
# Fallback: if activity doesn't have provenance, look up the asset from registry
|
|
if not provenance or not origin:
|
|
registry = load_registry()
|
|
assets = registry.get("assets", {})
|
|
# Find asset by content_hash or name
|
|
for asset_name, asset_data in assets.items():
|
|
if asset_data.get("content_hash") == content_hash or asset_data.get("name") == obj_name:
|
|
if not provenance:
|
|
provenance = asset_data.get("provenance", {})
|
|
if not origin:
|
|
origin = asset_data.get("origin", {})
|
|
break
|
|
|
|
# Type colors
|
|
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
|
|
obj_type_color = "bg-blue-600" if "Image" in obj_type else "bg-purple-600" if "Video" in obj_type else "bg-gray-600"
|
|
|
|
# Determine L1 server and asset type
|
|
l1_server = provenance.get("l1_server", L1_PUBLIC_URL).rstrip("/") if provenance else L1_PUBLIC_URL.rstrip("/")
|
|
is_video = "Video" in obj_type or "video" in media_type
|
|
|
|
# Content display
|
|
if is_video:
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
|
<video src="{l1_server}/cache/{content_hash}/mp4" controls autoplay muted loop playsinline
|
|
class="max-w-full max-h-96 mx-auto rounded-lg"></video>
|
|
<div class="mt-3 text-center">
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download Original
|
|
</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
elif "Image" in obj_type or "image" in media_type:
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
|
<img src="{l1_server}/cache/{content_hash}" alt="{obj_name}"
|
|
class="max-w-full max-h-96 mx-auto rounded-lg">
|
|
<div class="mt-3 text-center">
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download
|
|
</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
else:
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6 text-center">
|
|
<p class="text-gray-400 mb-3">Content type: {media_type or obj_type}</p>
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download
|
|
</a>
|
|
</div>
|
|
'''
|
|
|
|
# Origin display
|
|
origin_html = '<span class="text-gray-500">Not specified</span>'
|
|
if origin:
|
|
origin_type = origin.get("type", "")
|
|
if origin_type == "self":
|
|
origin_html = '<span class="text-green-400">Original content by author</span>'
|
|
elif origin_type == "external":
|
|
origin_url = origin.get("url", "")
|
|
origin_note = origin.get("note", "")
|
|
origin_html = f'<a href="{origin_url}" target="_blank" class="text-blue-400 hover:text-blue-300 break-all">{origin_url}</a>'
|
|
if origin_note:
|
|
origin_html += f'<p class="text-gray-500 text-sm mt-1">{origin_note}</p>'
|
|
|
|
# Provenance section
|
|
provenance_html = ""
|
|
if provenance and provenance.get("recipe"):
|
|
recipe = provenance.get("recipe", "")
|
|
inputs = provenance.get("inputs", [])
|
|
l1_run_id = provenance.get("l1_run_id", "")
|
|
rendered_at = provenance.get("rendered_at", "")[:10] if provenance.get("rendered_at") else ""
|
|
effects_commit = provenance.get("effects_commit", "")
|
|
effect_url = provenance.get("effect_url")
|
|
infrastructure = provenance.get("infrastructure", {})
|
|
|
|
if not effect_url:
|
|
if effects_commit and effects_commit != "unknown":
|
|
effect_url = f"{EFFECTS_REPO_URL}/src/commit/{effects_commit}/{recipe}"
|
|
else:
|
|
effect_url = f"{EFFECTS_REPO_URL}/src/branch/main/{recipe}"
|
|
|
|
# Build inputs display - show actual content as thumbnails
|
|
inputs_html = ""
|
|
for inp in inputs:
|
|
inp_hash = inp.get("content_hash", "") if isinstance(inp, dict) else inp
|
|
if inp_hash:
|
|
inputs_html += f'''
|
|
<div class="bg-dark-500 rounded-lg p-3 mb-3">
|
|
<div class="flex justify-center mb-2">
|
|
<video src="{l1_server}/cache/{inp_hash}/mp4"
|
|
class="max-h-40 rounded hidden" muted loop playsinline
|
|
onloadeddata="this.classList.remove('hidden'); this.nextElementSibling.classList.add('hidden'); this.play();">
|
|
</video>
|
|
<img src="{l1_server}/cache/{inp_hash}" alt="Input"
|
|
class="max-h-40 rounded">
|
|
</div>
|
|
<div class="text-center">
|
|
<code class="text-xs text-gray-400">{inp_hash[:16]}...</code>
|
|
<a href="{l1_server}/cache/{inp_hash}" target="_blank"
|
|
class="text-blue-400 hover:text-blue-300 text-xs ml-2">view</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
# Infrastructure display
|
|
infra_html = ""
|
|
if infrastructure:
|
|
software = infrastructure.get("software", {})
|
|
hardware = infrastructure.get("hardware", {})
|
|
if software or hardware:
|
|
infra_parts = []
|
|
if software:
|
|
infra_parts.append(f"Software: {software.get('name', 'unknown')}")
|
|
if hardware:
|
|
infra_parts.append(f"Hardware: {hardware.get('name', 'unknown')}")
|
|
infra_html = f'<p class="text-sm text-gray-400 mt-2">{" | ".join(infra_parts)}</p>'
|
|
|
|
provenance_html = f'''
|
|
<div class="border-t border-dark-500 pt-6 mt-6">
|
|
<h3 class="text-lg font-semibold text-white mb-4">Provenance</h3>
|
|
<p class="text-sm text-gray-400 mb-4">This content was created by applying an effect to input content.</p>
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Effect</h4>
|
|
<a href="{effect_url}" target="_blank"
|
|
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
{recipe}
|
|
</a>
|
|
{f'<div class="mt-2 text-xs text-gray-500">Commit: {effects_commit[:12]}...</div>' if effects_commit else ''}
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Input(s)</h4>
|
|
{inputs_html if inputs_html else '<span class="text-gray-500">No inputs recorded</span>'}
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">L1 Run</h4>
|
|
<a href="{l1_server}/ui/detail/{l1_run_id}" target="_blank"
|
|
class="text-blue-400 hover:text-blue-300 font-mono text-xs">{l1_run_id[:20]}...</a>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Rendered</h4>
|
|
<span class="text-white">{rendered_at if rendered_at else 'Unknown'}</span>
|
|
{infra_html}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
content = f'''
|
|
<p class="mb-4"><a href="/ui/activities" class="text-blue-400 hover:text-blue-300">← Back to Activities</a></p>
|
|
|
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
|
<span class="px-3 py-1 {type_color} text-white text-sm font-medium rounded">{activity_type}</span>
|
|
<h2 class="text-2xl font-bold text-white">{obj_name}</h2>
|
|
<span class="px-3 py-1 {obj_type_color} text-white text-sm rounded">{obj_type}</span>
|
|
</div>
|
|
|
|
{content_html}
|
|
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div class="space-y-4">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Actor</h3>
|
|
<a href="/ui/user/{actor_name}" class="text-blue-400 hover:text-blue-300 text-lg">{actor_name}</a>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Description</h3>
|
|
<p class="text-white">{description if description else '<span class="text-gray-500">No description</span>'}</p>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Origin</h3>
|
|
{origin_html}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Content Hash</h3>
|
|
<code class="text-green-300 text-sm break-all">{content_hash}</code>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Published</h3>
|
|
<span class="text-white">{published}</span>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Activity ID</h3>
|
|
<code class="text-gray-300 text-xs break-all">{activity_id}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{provenance_html}
|
|
|
|
<div class="border-t border-dark-500 pt-6 mt-6">
|
|
<h3 class="text-lg font-semibold text-white mb-4">ActivityPub</h3>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<p class="text-sm text-gray-300 mb-2">
|
|
<span class="text-gray-400">Object URL:</span>
|
|
<a href="https://{DOMAIN}/objects/{content_hash}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">https://{DOMAIN}/objects/{content_hash}</a>
|
|
</p>
|
|
<p class="text-sm text-gray-300">
|
|
<span class="text-gray-400">Actor:</span>
|
|
<a href="{actor_id}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">{actor_id}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html(f"Activity: {obj_name}", content, username))
|
|
|
|
|
|
@app.get("/ui/users", response_class=HTMLResponse)
|
|
async def ui_users_page(request: Request):
|
|
"""Users page showing all registered users."""
|
|
current_user = get_user_from_cookie(request)
|
|
users = load_users(DATA_DIR)
|
|
|
|
if not users:
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Users</h2>
|
|
<p class="text-gray-400">No users registered yet.</p>
|
|
'''
|
|
else:
|
|
rows = ""
|
|
for uname, user_data in sorted(users.items()):
|
|
webfinger = f"@{uname}@{DOMAIN}"
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4"><a href="/ui/user/{uname}" class="text-blue-400 hover:text-blue-300 font-medium">{uname}</a></td>
|
|
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-gray-300">{webfinger}</code></td>
|
|
<td class="py-3 px-4 text-gray-400">{user_data.get("created_at", "")[:10]}</td>
|
|
</tr>
|
|
'''
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Users ({len(users)} registered)</h2>
|
|
<p class="text-gray-400 mb-6">Each user has their own ActivityPub actor that can be followed from Mastodon and other federated platforms.</p>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-dark-600 text-left">
|
|
<th class="py-3 px-4 font-medium text-gray-300">Username</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">ActivityPub Handle</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Registered</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html("Users", content, current_user))
|
|
|
|
|
|
@app.get("/ui/asset/{name}", response_class=HTMLResponse)
|
|
async def ui_asset_detail(name: str, request: Request):
|
|
"""Asset detail page with content preview and provenance."""
|
|
username = get_user_from_cookie(request)
|
|
registry = load_registry()
|
|
assets = registry.get("assets", {})
|
|
|
|
if name not in assets:
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Asset Not Found</h2>
|
|
<p class="text-gray-400">No asset named "{name}" exists.</p>
|
|
<p class="mt-4"><a href="/ui/registry" class="text-blue-400 hover:text-blue-300">← Back to Registry</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Asset Not Found", content, username))
|
|
|
|
asset = assets[name]
|
|
owner = asset.get("owner", "unknown")
|
|
content_hash = asset.get("content_hash", "")
|
|
asset_type = asset.get("asset_type", "")
|
|
tags = asset.get("tags", [])
|
|
description = asset.get("description", "")
|
|
origin = asset.get("origin", {})
|
|
provenance = asset.get("provenance", {})
|
|
metadata = asset.get("metadata", {})
|
|
created_at = asset.get("created_at", "")[:10]
|
|
|
|
type_color = "bg-blue-600" if asset_type == "image" else "bg-purple-600" if asset_type == "video" else "bg-gray-600"
|
|
|
|
# Determine L1 server URL for content
|
|
l1_server = provenance.get("l1_server", L1_PUBLIC_URL).rstrip("/")
|
|
|
|
# Content display - image or video from L1
|
|
if asset_type == "video":
|
|
# Use iOS-compatible MP4 endpoint
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
|
<video src="{l1_server}/cache/{content_hash}/mp4" controls autoplay muted loop playsinline
|
|
class="max-w-full max-h-96 mx-auto rounded-lg"></video>
|
|
<div class="mt-3 text-center">
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download Original
|
|
</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
elif asset_type == "image":
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
|
<img src="{l1_server}/cache/{content_hash}" alt="{name}"
|
|
class="max-w-full max-h-96 mx-auto rounded-lg">
|
|
<div class="mt-3 text-center">
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download
|
|
</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
else:
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6 text-center">
|
|
<p class="text-gray-400 mb-3">Content type: {asset_type}</p>
|
|
<a href="{l1_server}/cache/{content_hash}" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download
|
|
</a>
|
|
</div>
|
|
'''
|
|
|
|
# Origin display
|
|
origin_html = '<span class="text-gray-500">Not specified</span>'
|
|
if origin:
|
|
origin_type = origin.get("type", "unknown")
|
|
if origin_type == "self":
|
|
origin_html = '<span class="text-green-400">Original content by author</span>'
|
|
elif origin_type == "external":
|
|
origin_url = origin.get("url", "")
|
|
origin_note = origin.get("note", "")
|
|
origin_html = f'<a href="{origin_url}" target="_blank" class="text-blue-400 hover:text-blue-300 break-all">{origin_url}</a>'
|
|
if origin_note:
|
|
origin_html += f'<p class="text-gray-500 text-sm mt-1">{origin_note}</p>'
|
|
|
|
# Tags display
|
|
tags_html = '<span class="text-gray-500">No tags</span>'
|
|
if tags:
|
|
tags_html = " ".join([f'<span class="px-2 py-1 bg-dark-500 text-gray-300 text-xs rounded">{t}</span>' for t in tags])
|
|
|
|
# Provenance section - for rendered outputs
|
|
provenance_html = ""
|
|
if provenance:
|
|
recipe = provenance.get("recipe", "")
|
|
inputs = provenance.get("inputs", [])
|
|
l1_run_id = provenance.get("l1_run_id", "")
|
|
rendered_at = provenance.get("rendered_at", "")[:10] if provenance.get("rendered_at") else ""
|
|
effects_commit = provenance.get("effects_commit", "")
|
|
infrastructure = provenance.get("infrastructure", {})
|
|
|
|
# Use stored effect_url or build fallback
|
|
effect_url = provenance.get("effect_url")
|
|
if not effect_url:
|
|
# Fallback for older records
|
|
if effects_commit and effects_commit != "unknown":
|
|
effect_url = f"{EFFECTS_REPO_URL}/src/commit/{effects_commit}/{recipe}"
|
|
else:
|
|
effect_url = f"{EFFECTS_REPO_URL}/src/branch/main/{recipe}"
|
|
|
|
# Build inputs display - show actual content as thumbnails
|
|
inputs_html = ""
|
|
for inp in inputs:
|
|
inp_hash = inp.get("content_hash", "") if isinstance(inp, dict) else inp
|
|
if inp_hash:
|
|
inputs_html += f'''
|
|
<div class="bg-dark-500 rounded-lg p-3 mb-3">
|
|
<div class="flex justify-center mb-2">
|
|
<video src="{l1_server}/cache/{inp_hash}/mp4"
|
|
class="max-h-40 rounded hidden" muted loop playsinline
|
|
onloadeddata="this.classList.remove('hidden'); this.nextElementSibling.classList.add('hidden'); this.play();">
|
|
</video>
|
|
<img src="{l1_server}/cache/{inp_hash}" alt="Input"
|
|
class="max-h-40 rounded">
|
|
</div>
|
|
<div class="text-center">
|
|
<code class="text-xs text-gray-400">{inp_hash[:16]}...</code>
|
|
<a href="{l1_server}/cache/{inp_hash}" target="_blank"
|
|
class="text-blue-400 hover:text-blue-300 text-xs ml-2">view</a>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
# Infrastructure display
|
|
infra_html = ""
|
|
if infrastructure:
|
|
software = infrastructure.get("software", {})
|
|
hardware = infrastructure.get("hardware", {})
|
|
if software or hardware:
|
|
infra_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 sm:col-span-2">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Infrastructure</h4>
|
|
<div class="text-sm text-gray-300">
|
|
{f"Software: {software.get('name', 'unknown')}" if software else ""}
|
|
{f" ({software.get('content_hash', '')[:16]}...)" if software.get('content_hash') else ""}
|
|
{" | " if software and hardware else ""}
|
|
{f"Hardware: {hardware.get('name', 'unknown')}" if hardware else ""}
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
provenance_html = f'''
|
|
<div class="border-t border-dark-500 pt-6 mt-6">
|
|
<h3 class="text-lg font-semibold text-white mb-4">Provenance</h3>
|
|
<p class="text-sm text-gray-400 mb-4">This asset was created by applying an effect to input content.</p>
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Effect</h4>
|
|
<a href="{effect_url}" target="_blank"
|
|
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
{recipe}
|
|
</a>
|
|
{f'<div class="mt-2 text-xs text-gray-500">Commit: {effects_commit[:12]}...</div>' if effects_commit else ''}
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Input(s)</h4>
|
|
{inputs_html if inputs_html else '<span class="text-gray-500">No inputs recorded</span>'}
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">L1 Run</h4>
|
|
<a href="{l1_server}/ui/detail/{l1_run_id}" target="_blank"
|
|
class="text-blue-400 hover:text-blue-300 font-mono text-xs">{l1_run_id[:16]}...</a>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Rendered</h4>
|
|
<span class="text-white">{rendered_at if rendered_at else 'Unknown'}</span>
|
|
</div>
|
|
{infra_html}
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
content = f'''
|
|
<p class="mb-4"><a href="/ui/registry" class="text-blue-400 hover:text-blue-300">← Back to Registry</a></p>
|
|
|
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
|
<h2 class="text-2xl font-bold text-white">{name}</h2>
|
|
<span class="px-3 py-1 {type_color} text-white text-sm rounded">{asset_type}</span>
|
|
</div>
|
|
|
|
{content_html}
|
|
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div class="space-y-4">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Owner</h3>
|
|
<a href="/ui/user/{owner}" class="text-blue-400 hover:text-blue-300 text-lg">{owner}</a>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Description</h3>
|
|
<p class="text-white">{description if description else '<span class="text-gray-500">No description</span>'}</p>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Origin</h3>
|
|
{origin_html}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Content Hash</h3>
|
|
<code class="text-green-300 text-sm break-all">{content_hash}</code>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Created</h3>
|
|
<span class="text-white">{created_at}</span>
|
|
</div>
|
|
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">Tags</h3>
|
|
<div class="flex flex-wrap gap-2">{tags_html}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{provenance_html}
|
|
|
|
<div class="border-t border-dark-500 pt-6 mt-6">
|
|
<h3 class="text-lg font-semibold text-white mb-4">ActivityPub</h3>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<p class="text-sm text-gray-300 mb-2">
|
|
<span class="text-gray-400">Object URL:</span>
|
|
<a href="https://{DOMAIN}/objects/{content_hash}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">https://{DOMAIN}/objects/{content_hash}</a>
|
|
</p>
|
|
<p class="text-sm text-gray-300">
|
|
<span class="text-gray-400">Owner Actor:</span>
|
|
<a href="https://{DOMAIN}/users/{owner}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">https://{DOMAIN}/users/{owner}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html(f"Asset: {name}", content, username))
|
|
|
|
|
|
@app.get("/ui/user/{username}", response_class=HTMLResponse)
|
|
async def ui_user_detail(username: str, request: Request):
|
|
"""User detail page showing their published assets."""
|
|
current_user = get_user_from_cookie(request)
|
|
|
|
if not user_exists(username):
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-4">User Not Found</h2>
|
|
<p class="text-gray-400">No user named "{username}" exists.</p>
|
|
<p class="mt-4"><a href="/ui/users" class="text-blue-400 hover:text-blue-300">← Back to Users</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("User Not Found", content, current_user))
|
|
|
|
# Get user's assets
|
|
registry = load_registry()
|
|
all_assets = registry.get("assets", {})
|
|
user_assets = {name: asset for name, asset in all_assets.items() if asset.get("owner") == username}
|
|
|
|
# Get user's activities
|
|
all_activities = load_activities()
|
|
actor_id = f"https://{DOMAIN}/users/{username}"
|
|
user_activities = [a for a in all_activities if a.get("actor_id") == actor_id]
|
|
|
|
webfinger = f"@{username}@{DOMAIN}"
|
|
|
|
# Assets table
|
|
if user_assets:
|
|
rows = ""
|
|
for name, asset in sorted(user_assets.items(), key=lambda x: x[1].get("created_at", ""), reverse=True):
|
|
hash_short = asset.get("content_hash", "")[:16] + "..."
|
|
asset_type = asset.get("asset_type", "")
|
|
type_color = "bg-blue-600" if asset_type == "image" else "bg-purple-600" if asset_type == "video" else "bg-gray-600"
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4">
|
|
<a href="/ui/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
|
|
</td>
|
|
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs rounded">{asset_type}</span></td>
|
|
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{hash_short}</code></td>
|
|
<td class="py-3 px-4 text-gray-400">{", ".join(asset.get("tags", []))}</td>
|
|
</tr>
|
|
'''
|
|
assets_html = f'''
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-dark-600 text-left">
|
|
<th class="py-3 px-4 font-medium text-gray-300">Name</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Tags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
else:
|
|
assets_html = '<p class="text-gray-400">No published assets yet.</p>'
|
|
|
|
content = f'''
|
|
<p class="mb-4"><a href="/ui/users" class="text-blue-400 hover:text-blue-300">← Back to Users</a></p>
|
|
|
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
|
<h2 class="text-2xl font-bold text-white">{username}</h2>
|
|
<code class="px-3 py-1 bg-dark-600 text-gray-300 text-sm rounded">{webfinger}</code>
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2 mb-8">
|
|
<div class="bg-dark-600 rounded-lg p-4 text-center">
|
|
<div class="text-2xl font-bold text-blue-400">{len(user_assets)}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Published Assets</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4 text-center">
|
|
<div class="text-2xl font-bold text-blue-400">{len(user_activities)}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Activities</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-6 p-4 bg-dark-600 rounded-lg">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2">ActivityPub</h3>
|
|
<p class="text-sm text-gray-300 mb-1">
|
|
Actor URL: <a href="https://{DOMAIN}/users/{username}" target="_blank" class="text-blue-400 hover:text-blue-300">https://{DOMAIN}/users/{username}</a>
|
|
</p>
|
|
<p class="text-sm text-gray-300">
|
|
Outbox: <a href="https://{DOMAIN}/users/{username}/outbox" target="_blank" class="text-blue-400 hover:text-blue-300">https://{DOMAIN}/users/{username}/outbox</a>
|
|
</p>
|
|
</div>
|
|
|
|
<h3 class="text-xl font-semibold text-white mb-4">Published Assets ({len(user_assets)})</h3>
|
|
{assets_html}
|
|
'''
|
|
return HTMLResponse(base_html(f"User: {username}", content, current_user))
|
|
|
|
|
|
# ============ API Endpoints ============
|
|
|
|
@app.get("/")
|
|
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)
|
|
return {
|
|
"name": "Art DAG L2 Server",
|
|
"version": "0.1.0",
|
|
"domain": DOMAIN,
|
|
"assets_count": len(registry.get("assets", {})),
|
|
"activities_count": len(activities),
|
|
"users_count": len(users)
|
|
}
|
|
|
|
|
|
# ============ Auth Endpoints ============
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def get_optional_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
) -> Optional[User]:
|
|
"""Get current user if authenticated, None otherwise."""
|
|
if not credentials:
|
|
return None
|
|
return get_current_user(DATA_DIR, credentials.credentials)
|
|
|
|
|
|
async def get_required_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
) -> User:
|
|
"""Get current user, raise 401 if not authenticated."""
|
|
if not credentials:
|
|
raise HTTPException(401, "Not authenticated")
|
|
user = get_current_user(DATA_DIR, credentials.credentials)
|
|
if not user:
|
|
raise HTTPException(401, "Invalid token")
|
|
return user
|
|
|
|
|
|
@app.post("/auth/register", response_model=Token)
|
|
async def register(req: UserCreate):
|
|
"""Register a new user."""
|
|
try:
|
|
user = create_user(DATA_DIR, req.username, req.password, req.email)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
return create_access_token(user.username)
|
|
|
|
|
|
@app.post("/auth/login", response_model=Token)
|
|
async def login(req: UserLogin):
|
|
"""Login and get access token."""
|
|
user = authenticate_user(DATA_DIR, req.username, req.password)
|
|
if not user:
|
|
raise HTTPException(401, "Invalid username or password")
|
|
|
|
return create_access_token(user.username)
|
|
|
|
|
|
@app.get("/auth/me")
|
|
async def get_me(user: User = Depends(get_required_user)):
|
|
"""Get current user info."""
|
|
return {
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"created_at": user.created_at
|
|
}
|
|
|
|
|
|
@app.post("/auth/verify")
|
|
async def verify_auth(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|
"""Verify a token and return username. Used by L1 server."""
|
|
if not credentials:
|
|
raise HTTPException(401, "No token provided")
|
|
|
|
username = verify_token(credentials.credentials)
|
|
if not username:
|
|
raise HTTPException(401, "Invalid token")
|
|
|
|
return {"username": username, "valid": True}
|
|
|
|
|
|
@app.get("/.well-known/webfinger")
|
|
async def webfinger(resource: str):
|
|
"""WebFinger endpoint for actor discovery."""
|
|
# Parse acct:username@domain
|
|
if not resource.startswith("acct:"):
|
|
raise HTTPException(400, "Resource must be acct: URI")
|
|
|
|
acct = resource[5:] # Remove "acct:"
|
|
if "@" not in acct:
|
|
raise HTTPException(400, "Invalid acct format")
|
|
|
|
username, domain = acct.split("@", 1)
|
|
|
|
if domain != DOMAIN:
|
|
raise HTTPException(404, f"Unknown domain: {domain}")
|
|
|
|
if not user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"subject": resource,
|
|
"links": [
|
|
{
|
|
"rel": "self",
|
|
"type": "application/activity+json",
|
|
"href": f"https://{DOMAIN}/users/{username}"
|
|
}
|
|
]
|
|
},
|
|
media_type="application/jrd+json"
|
|
)
|
|
|
|
|
|
@app.get("/users/{username}")
|
|
async def get_actor(username: str, request: Request):
|
|
"""Get actor profile for any registered user. Content negotiation: HTML for browsers, JSON for APIs."""
|
|
if not user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
# Check Accept header for content negotiation
|
|
accept = request.headers.get("accept", "")
|
|
wants_html = "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
|
|
|
|
if wants_html:
|
|
# Redirect to UI page for browsers
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse(url=f"/ui/user/{username}", status_code=303)
|
|
|
|
actor = load_actor(username)
|
|
|
|
# Add ActivityPub context
|
|
actor["@context"] = [
|
|
"https://www.w3.org/ns/activitystreams",
|
|
"https://w3id.org/security/v1"
|
|
]
|
|
|
|
return JSONResponse(
|
|
content=actor,
|
|
media_type="application/activity+json"
|
|
)
|
|
|
|
|
|
@app.get("/users/{username}/outbox")
|
|
async def get_outbox(username: str, page: bool = False):
|
|
"""Get actor's outbox (activities they created)."""
|
|
if not user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
# Filter activities by this user's actor_id
|
|
all_activities = load_activities()
|
|
actor_id = f"https://{DOMAIN}/users/{username}"
|
|
user_activities = [a for a in all_activities if a.get("actor_id") == actor_id]
|
|
|
|
if not page:
|
|
return JSONResponse(
|
|
content={
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": f"https://{DOMAIN}/users/{username}/outbox",
|
|
"type": "OrderedCollection",
|
|
"totalItems": len(user_activities),
|
|
"first": f"https://{DOMAIN}/users/{username}/outbox?page=true"
|
|
},
|
|
media_type="application/activity+json"
|
|
)
|
|
|
|
# Return activities page
|
|
return JSONResponse(
|
|
content={
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": f"https://{DOMAIN}/users/{username}/outbox?page=true",
|
|
"type": "OrderedCollectionPage",
|
|
"partOf": f"https://{DOMAIN}/users/{username}/outbox",
|
|
"orderedItems": user_activities
|
|
},
|
|
media_type="application/activity+json"
|
|
)
|
|
|
|
|
|
@app.post("/users/{username}/inbox")
|
|
async def post_inbox(username: str, request: Request):
|
|
"""Receive activities from other servers."""
|
|
if not user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
body = await request.json()
|
|
activity_type = body.get("type")
|
|
|
|
# Handle Follow requests
|
|
if activity_type == "Follow":
|
|
follower = body.get("actor")
|
|
# TODO: Per-user followers - for now use global followers
|
|
followers = load_followers()
|
|
if follower not in followers:
|
|
followers.append(follower)
|
|
save_followers(followers)
|
|
|
|
# Send Accept (in production, do this async)
|
|
# For now just acknowledge
|
|
return {"status": "accepted"}
|
|
|
|
# Handle other activity types
|
|
return {"status": "received"}
|
|
|
|
|
|
@app.get("/users/{username}/followers")
|
|
async def get_followers(username: str):
|
|
"""Get actor's followers."""
|
|
if not user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
# TODO: Per-user followers - for now use global followers
|
|
followers = load_followers()
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": f"https://{DOMAIN}/users/{username}/followers",
|
|
"type": "OrderedCollection",
|
|
"totalItems": len(followers),
|
|
"orderedItems": followers
|
|
},
|
|
media_type="application/activity+json"
|
|
)
|
|
|
|
|
|
# ============ Registry Endpoints ============
|
|
|
|
@app.get("/registry")
|
|
async def get_registry():
|
|
"""Get full registry."""
|
|
return load_registry()
|
|
|
|
|
|
@app.get("/registry/{name}")
|
|
async def get_asset(name: str):
|
|
"""Get a specific asset."""
|
|
registry = load_registry()
|
|
if name not in registry.get("assets", {}):
|
|
raise HTTPException(404, f"Asset not found: {name}")
|
|
return registry["assets"][name]
|
|
|
|
|
|
@app.patch("/registry/{name}")
|
|
async def update_asset(name: str, req: UpdateAssetRequest, user: User = Depends(get_required_user)):
|
|
"""Update an existing asset's metadata. Creates an Update activity."""
|
|
registry = load_registry()
|
|
if name not in registry.get("assets", {}):
|
|
raise HTTPException(404, f"Asset not found: {name}")
|
|
|
|
asset = registry["assets"][name]
|
|
|
|
# Check ownership
|
|
if asset.get("owner") != user.username:
|
|
raise HTTPException(403, f"Not authorized to update asset owned by {asset.get('owner')}")
|
|
|
|
# Update fields that were provided
|
|
if req.description is not None:
|
|
asset["description"] = req.description
|
|
if req.tags is not None:
|
|
asset["tags"] = req.tags
|
|
if req.metadata is not None:
|
|
asset["metadata"] = {**asset.get("metadata", {}), **req.metadata}
|
|
if req.origin is not None:
|
|
asset["origin"] = req.origin
|
|
|
|
asset["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Save registry
|
|
registry["assets"][name] = asset
|
|
save_registry(registry)
|
|
|
|
# Create Update activity
|
|
activity = {
|
|
"activity_id": str(uuid.uuid4()),
|
|
"activity_type": "Update",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": {
|
|
"type": asset.get("asset_type", "Object").capitalize(),
|
|
"name": name,
|
|
"id": f"https://{DOMAIN}/objects/{asset['content_hash']}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": asset["content_hash"]
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
|
|
"summary": req.description,
|
|
"tag": req.tags or asset.get("tags", [])
|
|
},
|
|
"published": asset["updated_at"]
|
|
}
|
|
|
|
# Sign activity with the user's keys
|
|
activity = sign_activity(activity, user.username)
|
|
|
|
# Save activity
|
|
activities = load_activities()
|
|
activities.append(activity)
|
|
save_activities(activities)
|
|
|
|
return {"asset": asset, "activity": activity}
|
|
|
|
|
|
def _register_asset_impl(req: RegisterRequest, owner: str):
|
|
"""Internal implementation for registering an asset."""
|
|
registry = load_registry()
|
|
|
|
# Check if name exists
|
|
if req.name in registry.get("assets", {}):
|
|
raise HTTPException(400, f"Asset already exists: {req.name}")
|
|
|
|
# Create asset
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
asset = {
|
|
"name": req.name,
|
|
"content_hash": req.content_hash,
|
|
"asset_type": req.asset_type,
|
|
"tags": req.tags,
|
|
"metadata": req.metadata,
|
|
"url": req.url,
|
|
"provenance": req.provenance,
|
|
"owner": owner,
|
|
"created_at": now
|
|
}
|
|
|
|
# Add to registry
|
|
if "assets" not in registry:
|
|
registry["assets"] = {}
|
|
registry["assets"][req.name] = asset
|
|
save_registry(registry)
|
|
|
|
# Create ownership activity
|
|
object_data = {
|
|
"type": req.asset_type.capitalize(),
|
|
"name": req.name,
|
|
"id": f"https://{DOMAIN}/objects/{req.content_hash}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": req.content_hash
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{owner}"
|
|
}
|
|
|
|
# Include provenance in activity object_data if present
|
|
if req.provenance:
|
|
object_data["provenance"] = req.provenance
|
|
|
|
activity = {
|
|
"activity_id": str(uuid.uuid4()),
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{owner}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
|
|
# Sign activity with the owner's keys
|
|
activity = sign_activity(activity, owner)
|
|
|
|
# Save activity
|
|
activities = load_activities()
|
|
activities.append(activity)
|
|
save_activities(activities)
|
|
|
|
return {"asset": asset, "activity": activity}
|
|
|
|
|
|
@app.post("/registry")
|
|
async def register_asset(req: RegisterRequest, user: User = Depends(get_required_user)):
|
|
"""Register a new asset and create ownership activity. Requires authentication."""
|
|
return _register_asset_impl(req, user.username)
|
|
|
|
|
|
@app.post("/registry/record-run")
|
|
async def record_run(req: RecordRunRequest, user: User = Depends(get_required_user)):
|
|
"""Record an L1 run and register the output. Requires authentication."""
|
|
# Fetch run from the specified L1 server
|
|
l1_url = req.l1_server.rstrip('/')
|
|
try:
|
|
resp = requests.get(f"{l1_url}/runs/{req.run_id}")
|
|
resp.raise_for_status()
|
|
run = resp.json()
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Failed to fetch run from L1 ({l1_url}): {e}")
|
|
|
|
if run.get("status") != "completed":
|
|
raise HTTPException(400, f"Run not completed: {run.get('status')}")
|
|
|
|
output_hash = run.get("output_hash")
|
|
if not output_hash:
|
|
raise HTTPException(400, "Run has no output hash")
|
|
|
|
# Build provenance from run
|
|
provenance = {
|
|
"inputs": [{"content_hash": h} for h in run.get("inputs", [])],
|
|
"recipe": run.get("recipe"),
|
|
"effect_url": run.get("effect_url"),
|
|
"effects_commit": run.get("effects_commit"),
|
|
"l1_server": l1_url,
|
|
"l1_run_id": req.run_id,
|
|
"rendered_at": run.get("completed_at"),
|
|
"infrastructure": run.get("infrastructure")
|
|
}
|
|
|
|
# Register the output under the authenticated user
|
|
return _register_asset_impl(RegisterRequest(
|
|
name=req.output_name,
|
|
content_hash=output_hash,
|
|
asset_type="video", # Could be smarter about this
|
|
tags=["rendered", "l1"],
|
|
metadata={"l1_server": l1_url, "l1_run_id": req.run_id},
|
|
provenance=provenance
|
|
), user.username)
|
|
|
|
|
|
@app.post("/registry/publish-cache")
|
|
async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_required_user)):
|
|
"""
|
|
Publish a cache item from L1 with metadata.
|
|
|
|
Requires origin to be set (self or external URL).
|
|
Creates a new asset and Create activity.
|
|
"""
|
|
# Validate origin
|
|
if not req.origin or "type" not in req.origin:
|
|
raise HTTPException(400, "Origin is required for publishing (type: 'self' or 'external')")
|
|
|
|
origin_type = req.origin.get("type")
|
|
if origin_type not in ("self", "external"):
|
|
raise HTTPException(400, "Origin type must be 'self' or 'external'")
|
|
|
|
if origin_type == "external" and not req.origin.get("url"):
|
|
raise HTTPException(400, "External origin requires a URL")
|
|
|
|
# Check if asset name already exists
|
|
registry = load_registry()
|
|
if req.asset_name in registry.get("assets", {}):
|
|
raise HTTPException(400, f"Asset name already exists: {req.asset_name}")
|
|
|
|
# Create asset
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
asset = {
|
|
"name": req.asset_name,
|
|
"content_hash": req.content_hash,
|
|
"asset_type": req.asset_type,
|
|
"tags": req.tags,
|
|
"description": req.description,
|
|
"origin": req.origin,
|
|
"metadata": req.metadata,
|
|
"owner": user.username,
|
|
"created_at": now
|
|
}
|
|
|
|
# Add to registry
|
|
if "assets" not in registry:
|
|
registry["assets"] = {}
|
|
registry["assets"][req.asset_name] = asset
|
|
save_registry(registry)
|
|
|
|
# Create ownership activity with origin info
|
|
object_data = {
|
|
"type": req.asset_type.capitalize(),
|
|
"name": req.asset_name,
|
|
"id": f"https://{DOMAIN}/objects/{req.content_hash}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": req.content_hash
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
|
|
"tag": req.tags
|
|
}
|
|
|
|
if req.description:
|
|
object_data["summary"] = req.description
|
|
|
|
# Include origin in ActivityPub object
|
|
if origin_type == "self":
|
|
object_data["generator"] = {
|
|
"type": "Application",
|
|
"name": "Art DAG",
|
|
"note": "Original content created by the author"
|
|
}
|
|
else:
|
|
object_data["source"] = {
|
|
"type": "Link",
|
|
"href": req.origin.get("url"),
|
|
"name": req.origin.get("note", "External source")
|
|
}
|
|
|
|
activity = {
|
|
"activity_id": str(uuid.uuid4()),
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
|
|
# Sign activity with the user's keys
|
|
activity = sign_activity(activity, user.username)
|
|
|
|
# Save activity
|
|
activities = load_activities()
|
|
activities.append(activity)
|
|
save_activities(activities)
|
|
|
|
return {"asset": asset, "activity": activity}
|
|
|
|
|
|
# ============ Activities Endpoints ============
|
|
|
|
@app.get("/activities")
|
|
async def get_activities():
|
|
"""Get all activities."""
|
|
return {"activities": load_activities()}
|
|
|
|
|
|
@app.get("/objects/{content_hash}")
|
|
async def get_object(content_hash: str, request: Request):
|
|
"""Get object by content hash. Content negotiation: HTML for browsers, JSON for APIs."""
|
|
registry = load_registry()
|
|
|
|
# Find asset by hash
|
|
for name, asset in registry.get("assets", {}).items():
|
|
if asset.get("content_hash") == content_hash:
|
|
# Check Accept header for content negotiation
|
|
accept = request.headers.get("accept", "")
|
|
wants_html = "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
|
|
|
|
if wants_html:
|
|
# Redirect to UI page for browsers
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse(url=f"/ui/asset/{name}", status_code=303)
|
|
|
|
owner = asset.get("owner", "unknown")
|
|
return JSONResponse(
|
|
content={
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": f"https://{DOMAIN}/objects/{content_hash}",
|
|
"type": asset.get("asset_type", "Object").capitalize(),
|
|
"name": name,
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": content_hash
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{owner}",
|
|
"published": asset.get("created_at")
|
|
},
|
|
media_type="application/activity+json"
|
|
)
|
|
|
|
raise HTTPException(404, f"Object not found: {content_hash}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8200)
|