Provides /help index and /help/{doc_name} routes to view
L1 server and Common library READMEs in the web UI.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3766 lines
156 KiB
Python
3766 lines
156 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 logging
|
|
import os
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s %(levelname)s %(name)s: %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Response, Depends, Cookie, Form
|
|
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse, FileResponse
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from pydantic import BaseModel
|
|
import requests
|
|
import markdown
|
|
|
|
import db
|
|
from auth import (
|
|
UserCreate, UserLogin, Token, User,
|
|
create_user, authenticate_user, create_access_token,
|
|
verify_token, get_token_claims, get_current_user
|
|
)
|
|
|
|
# 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")
|
|
IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "")
|
|
|
|
# Known L1 renderers (comma-separated URLs)
|
|
L1_SERVERS_STR = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com")
|
|
L1_SERVERS = [s.strip() for s in L1_SERVERS_STR.split(",") if s.strip()]
|
|
|
|
# Cookie domain for sharing auth across subdomains (e.g., ".rose-ash.com")
|
|
# If not set, derives from DOMAIN (strips first subdomain, adds leading dot)
|
|
def _get_cookie_domain():
|
|
env_val = os.environ.get("COOKIE_DOMAIN")
|
|
if env_val:
|
|
return env_val
|
|
# Derive from DOMAIN: artdag.rose-ash.com -> .rose-ash.com
|
|
parts = DOMAIN.split(".")
|
|
if len(parts) >= 2:
|
|
return "." + ".".join(parts[-2:])
|
|
return None
|
|
|
|
COOKIE_DOMAIN = _get_cookie_domain()
|
|
|
|
# Ensure data directory exists
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
(DATA_DIR / "assets").mkdir(exist_ok=True)
|
|
|
|
|
|
def compute_run_id(input_hashes: list[str], recipe: str, recipe_hash: str = None) -> str:
|
|
"""
|
|
Compute a deterministic run_id from inputs and recipe.
|
|
|
|
The run_id is a SHA3-256 hash of:
|
|
- Sorted input content hashes
|
|
- Recipe identifier (recipe_hash if provided, else "effect:{recipe}")
|
|
|
|
This makes runs content-addressable: same inputs + recipe = same run_id.
|
|
Must match the L1 implementation exactly.
|
|
"""
|
|
data = {
|
|
"inputs": sorted(input_hashes),
|
|
"recipe": recipe_hash or f"effect:{recipe}",
|
|
"version": "1", # For future schema changes
|
|
}
|
|
json_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha3_256(json_str.encode()).hexdigest()
|
|
|
|
# Load README
|
|
README_PATH = Path(__file__).parent / "README.md"
|
|
README_CONTENT = ""
|
|
if README_PATH.exists():
|
|
README_CONTENT = README_PATH.read_text()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Manage database connection pool lifecycle."""
|
|
await db.init_pool()
|
|
yield
|
|
await db.close_pool()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Art DAG L2 Server",
|
|
description="ActivityPub server for Art DAG ownership and federation",
|
|
version="0.1.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
|
|
@app.exception_handler(404)
|
|
async def not_found_handler(request: Request, exc):
|
|
"""Custom 404 page."""
|
|
accept = request.headers.get("accept", "")
|
|
if "text/html" in accept and "application/json" not in accept:
|
|
content = '''
|
|
<div class="text-center py-16">
|
|
<h2 class="text-6xl font-bold text-gray-600 mb-4">404</h2>
|
|
<p class="text-xl text-gray-400 mb-8">Page not found</p>
|
|
<a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a>
|
|
</div>
|
|
'''
|
|
username = get_user_from_cookie(request)
|
|
return HTMLResponse(base_html("Not Found", content, username), status_code=404)
|
|
return JSONResponse({"detail": "Not found"}, status_code=404)
|
|
|
|
|
|
# ============ Data Models ============
|
|
|
|
class Asset(BaseModel):
|
|
"""An owned asset."""
|
|
name: str
|
|
content_hash: str
|
|
ipfs_cid: Optional[str] = None # IPFS content identifier
|
|
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
|
|
ipfs_cid: Optional[str] = None # IPFS content identifier
|
|
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
|
|
l1_server: str # URL of the L1 server that has this run
|
|
output_name: Optional[str] = None # Deprecated - assets now named by content_hash
|
|
|
|
|
|
class PublishCacheRequest(BaseModel):
|
|
"""Request to publish a cache item from L1."""
|
|
content_hash: str
|
|
ipfs_cid: Optional[str] = None # IPFS content identifier
|
|
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
|
|
ipfs_cid: Optional[str] = None # IPFS content identifier
|
|
|
|
|
|
class AddStorageRequest(BaseModel):
|
|
"""Request to add a storage provider."""
|
|
provider_type: str # 'pinata', 'web3storage', 'local'
|
|
provider_name: Optional[str] = None # User-friendly name
|
|
config: dict # Provider-specific config (api_key, path, etc.)
|
|
capacity_gb: int # Storage capacity in GB
|
|
|
|
|
|
class UpdateStorageRequest(BaseModel):
|
|
"""Request to update a storage provider."""
|
|
config: Optional[dict] = None
|
|
capacity_gb: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class SetAssetSourceRequest(BaseModel):
|
|
"""Request to set source URL for an asset."""
|
|
source_url: str
|
|
source_type: str # 'youtube', 'local', 'url'
|
|
|
|
|
|
# ============ Storage (Database) ============
|
|
|
|
async def load_registry() -> dict:
|
|
"""Load registry from database."""
|
|
assets = await db.get_all_assets()
|
|
return {"version": "1.0", "assets": assets}
|
|
|
|
|
|
async def load_activities() -> list:
|
|
"""Load activities from database."""
|
|
return await db.get_all_activities()
|
|
|
|
|
|
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
|
|
|
|
|
|
async def user_exists(username: str) -> bool:
|
|
"""Check if a user exists."""
|
|
return await db.user_exists(username)
|
|
|
|
|
|
async def load_followers() -> list:
|
|
"""Load followers list from database."""
|
|
return await db.get_all_followers()
|
|
|
|
|
|
# ============ 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?plugins=typography"></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>
|
|
<a href="/logout" class="px-3 py-1 bg-dark-500 hover:bg-dark-600 rounded text-blue-400 hover:text-blue-300 transition-colors">
|
|
Logout
|
|
</a>
|
|
</div>
|
|
''' if username else '''
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<a href="/login" class="text-blue-400 hover:text-blue-300">Login</a>
|
|
<span class="text-gray-500">|</span>
|
|
<a href="/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="/" 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="/assets" class="text-gray-400 hover:text-white transition-colors">Assets</a>
|
|
<a href="/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
|
|
<a href="/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
|
|
<a href="/anchors/ui" class="text-gray-400 hover:text-white transition-colors">Anchors</a>
|
|
<a href="/renderers" class="text-gray-400 hover:text-white transition-colors">Renderers</a>
|
|
<a href="/storage" class="text-gray-400 hover:text-white transition-colors">Storage</a>
|
|
<a href="/download/client" class="text-gray-400 hover:text-white transition-colors ml-auto" title="Download CLI client">Download Client</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
|
|
|
|
|
|
def wants_html(request: Request) -> bool:
|
|
"""Check if request wants HTML (browser) vs JSON (API)."""
|
|
accept = request.headers.get("accept", "")
|
|
return "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
|
|
|
|
|
|
def format_date(value, length: int = 10) -> str:
|
|
"""Format a date value (datetime or string) to a string, sliced to length."""
|
|
if value is None:
|
|
return ""
|
|
if hasattr(value, 'isoformat'):
|
|
return value.isoformat()[:length]
|
|
if isinstance(value, str):
|
|
return value[:length]
|
|
return ""
|
|
|
|
|
|
# ============ Auth UI Endpoints ============
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
async def ui_login_page(request: Request, return_to: str = None):
|
|
"""Login page. Accepts optional return_to URL for redirect after login."""
|
|
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="/" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
|
|
''', username))
|
|
|
|
# Hidden field for return_to URL
|
|
return_to_field = f'<input type="hidden" name="return_to" value="{return_to}">' if return_to else ''
|
|
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Login</h2>
|
|
<div id="login-result"></div>
|
|
<form hx-post="/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4 max-w-md">
|
|
{return_to_field}
|
|
<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="/register" class="text-blue-400 hover:text-blue-300">Register</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Login", content))
|
|
|
|
|
|
@app.post("/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", "")
|
|
return_to = form.get("return_to", "").strip()
|
|
|
|
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 = await 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, l2_server=f"https://{DOMAIN}")
|
|
|
|
# If return_to is specified, redirect there with token for the other site to set its own cookie
|
|
if return_to and return_to.startswith("http"):
|
|
# Append token to return_to URL for the target site to set its own cookie
|
|
separator = "&" if "?" in return_to else "?"
|
|
redirect_url = f"{return_to}{separator}auth_token={token.access_token}"
|
|
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 = "{redirect_url}";</script>
|
|
''')
|
|
else:
|
|
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 = "/";</script>
|
|
''')
|
|
|
|
# Set cookie for L2 only (L1 servers set their own cookies via /auth endpoint)
|
|
response.set_cookie(
|
|
key="auth_token",
|
|
value=token.access_token,
|
|
httponly=True,
|
|
max_age=60 * 60 * 24 * 30, # 30 days
|
|
samesite="lax",
|
|
secure=True
|
|
)
|
|
return response
|
|
|
|
|
|
@app.get("/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="/" 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="/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="/login" class="text-blue-400 hover:text-blue-300">Login</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Register", content))
|
|
|
|
|
|
@app.post("/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 = await 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, l2_server=f"https://{DOMAIN}")
|
|
|
|
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 = "/";</script>
|
|
''')
|
|
response.set_cookie(
|
|
key="auth_token",
|
|
value=token.access_token,
|
|
httponly=True,
|
|
max_age=60 * 60 * 24 * 30, # 30 days
|
|
samesite="lax",
|
|
secure=True
|
|
)
|
|
return response
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout(request: Request):
|
|
"""Handle logout - clear cookie, revoke token on L2 and attached L1s, and redirect to home."""
|
|
token = request.cookies.get("auth_token")
|
|
claims = get_token_claims(token) if token else None
|
|
username = claims.get("sub") if claims else None
|
|
|
|
if username and token and claims:
|
|
# Revoke token in L2 database (so even if L1 ignores revoke, token won't verify)
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc)
|
|
await db.revoke_token(token_hash, username, expires_at)
|
|
|
|
# Revoke ALL tokens for this user on attached L1 renderers
|
|
# (L1 may have scoped tokens different from L2's token)
|
|
attached = await db.get_user_renderers(username)
|
|
for l1_url in attached:
|
|
try:
|
|
requests.post(
|
|
f"{l1_url}/auth/revoke-user",
|
|
json={"username": username, "l2_server": f"https://{DOMAIN}"},
|
|
timeout=5
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to revoke user tokens on {l1_url}: {e}")
|
|
|
|
# Remove all attachments for this user
|
|
for l1_url in attached:
|
|
await db.detach_renderer(username, l1_url)
|
|
|
|
response = RedirectResponse(url="/", status_code=302)
|
|
# Delete both legacy (no domain) and new (shared domain) cookies
|
|
response.delete_cookie("auth_token")
|
|
if COOKIE_DOMAIN:
|
|
response.delete_cookie("auth_token", domain=COOKIE_DOMAIN)
|
|
return response
|
|
|
|
|
|
# ============ HTML Rendering Helpers ============
|
|
|
|
async def ui_activity_detail(activity_index: int, request: Request):
|
|
"""Activity detail page with full content display. Helper function for HTML rendering."""
|
|
username = get_user_from_cookie(request)
|
|
activities = await 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="/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]
|
|
return await _render_activity_detail(activity, request)
|
|
|
|
|
|
async def ui_activity_detail_by_data(activity: dict, request: Request):
|
|
"""Activity detail page taking activity data directly."""
|
|
return await _render_activity_detail(activity, request)
|
|
|
|
|
|
async def _render_activity_detail(activity: dict, request: Request):
|
|
"""Core activity detail rendering logic."""
|
|
username = get_user_from_cookie(request)
|
|
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 = format_date(activity.get("published"))
|
|
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 = await 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}/raw" 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}/raw" 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}/raw" 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}/raw" 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 = format_date(provenance.get("rendered_at"))
|
|
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}/raw" 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="/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="/users/{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))
|
|
|
|
|
|
async def ui_asset_detail(name: str, request: Request):
|
|
"""Asset detail page with content preview and provenance. Helper function for HTML rendering."""
|
|
username = get_user_from_cookie(request)
|
|
registry = await 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="/assets" class="text-blue-400 hover:text-blue-300">← Back to Assets</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", "")
|
|
ipfs_cid = asset.get("ipfs_cid", "")
|
|
asset_type = asset.get("asset_type", "")
|
|
tags = asset.get("tags", [])
|
|
description = asset.get("description", "")
|
|
origin = asset.get("origin") or {}
|
|
provenance = asset.get("provenance") or {}
|
|
metadata = asset.get("metadata") or {}
|
|
created_at = format_date(asset.get("created_at"))
|
|
|
|
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}/raw" 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}/raw" 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}/raw" 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>
|
|
'''
|
|
elif asset_type == "recipe":
|
|
# Fetch recipe source from L1 or IPFS
|
|
recipe_source = ""
|
|
try:
|
|
resp = requests.get(f"{l1_server}/cache/{content_hash}", timeout=10, headers={"Accept": "text/plain"})
|
|
if resp.status_code == 200:
|
|
recipe_source = resp.text
|
|
except Exception:
|
|
pass
|
|
|
|
if not recipe_source and ipfs_cid:
|
|
# Try IPFS
|
|
try:
|
|
import ipfs_client
|
|
recipe_bytes = ipfs_client.get_bytes(ipfs_cid)
|
|
if recipe_bytes:
|
|
recipe_source = recipe_bytes.decode('utf-8')
|
|
except Exception:
|
|
pass
|
|
|
|
import html as html_module
|
|
recipe_source_escaped = html_module.escape(recipe_source) if recipe_source else "(Could not load recipe source)"
|
|
|
|
content_html = f'''
|
|
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
|
<h3 class="text-lg font-semibold text-white mb-3">Recipe Source</h3>
|
|
<pre class="bg-dark-900 p-4 rounded-lg overflow-x-auto text-sm text-gray-300 font-mono whitespace-pre-wrap"><code>{recipe_source_escaped}</code></pre>
|
|
<div class="mt-3 text-center">
|
|
<a href="{l1_server}/cache/{content_hash}/raw" download
|
|
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
|
Download YAML
|
|
</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}/raw" 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])
|
|
|
|
# IPFS display
|
|
if ipfs_cid:
|
|
local_gateway = f'<a href="{IPFS_GATEWAY_URL}/ipfs/{ipfs_cid}" target="_blank" class="px-2 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded">Local</a>' if IPFS_GATEWAY_URL else ''
|
|
ipfs_html = f'''<code class="text-purple-300 text-sm break-all mb-2 block">{ipfs_cid}</code>
|
|
<div class="flex flex-wrap gap-1 mt-2">
|
|
{local_gateway}
|
|
<a href="https://ipfs.io/ipfs/{ipfs_cid}" target="_blank" class="px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs rounded">ipfs.io</a>
|
|
<a href="https://dweb.link/ipfs/{ipfs_cid}" target="_blank" class="px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs rounded">dweb.link</a>
|
|
</div>'''
|
|
else:
|
|
ipfs_html = '<span class="text-gray-500">Not on IPFS</span>'
|
|
|
|
# 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 = format_date(provenance.get("rendered_at"))
|
|
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}/raw" 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="/assets" class="text-blue-400 hover:text-blue-300">← Back to Assets</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="/users/{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">IPFS</h3>
|
|
{ipfs_html}
|
|
</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))
|
|
|
|
|
|
async def ui_user_detail(username: str, request: Request):
|
|
"""User detail page showing their published assets. Helper function for HTML rendering."""
|
|
current_user = get_user_from_cookie(request)
|
|
|
|
if not await 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="/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 = await 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 = await 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="/assets/{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="/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. HTML shows home page with counts, JSON returns stats."""
|
|
registry = await load_registry()
|
|
activities = await load_activities()
|
|
users = await db.get_all_users()
|
|
|
|
assets_count = len(registry.get("assets", {}))
|
|
activities_count = len(activities)
|
|
users_count = len(users)
|
|
|
|
if wants_html(request):
|
|
username = get_user_from_cookie(request)
|
|
readme_html = markdown.markdown(README_CONTENT, extensions=['tables', 'fenced_code'])
|
|
content = f'''
|
|
<div class="grid gap-4 sm:grid-cols-3 mb-8">
|
|
<a href="/assets" class="block bg-dark-600 rounded-lg p-6 text-center hover:bg-dark-500 transition-colors">
|
|
<div class="text-3xl font-bold text-blue-400">{assets_count}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Assets</div>
|
|
</a>
|
|
<a href="/activities" class="block bg-dark-600 rounded-lg p-6 text-center hover:bg-dark-500 transition-colors">
|
|
<div class="text-3xl font-bold text-blue-400">{activities_count}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Activities</div>
|
|
</a>
|
|
<a href="/users" class="block bg-dark-600 rounded-lg p-6 text-center hover:bg-dark-500 transition-colors">
|
|
<div class="text-3xl font-bold text-blue-400">{users_count}</div>
|
|
<div class="text-sm text-gray-400 mt-1">Users</div>
|
|
</a>
|
|
</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))
|
|
|
|
return {
|
|
"name": "Art DAG L2 Server",
|
|
"version": "0.1.0",
|
|
"domain": DOMAIN,
|
|
"assets_count": assets_count,
|
|
"activities_count": activities_count,
|
|
"users_count": users_count
|
|
}
|
|
|
|
|
|
# ============ 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 await 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 = await 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 = await 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, l2_server=f"https://{DOMAIN}")
|
|
|
|
|
|
@app.post("/auth/login", response_model=Token)
|
|
async def login(req: UserLogin):
|
|
"""Login and get access token."""
|
|
user = await authenticate_user(DATA_DIR, req.username, req.password)
|
|
if not user:
|
|
raise HTTPException(401, "Invalid username or password")
|
|
|
|
return create_access_token(user.username, l2_server=f"https://{DOMAIN}")
|
|
|
|
|
|
@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
|
|
}
|
|
|
|
|
|
class VerifyRequest(BaseModel):
|
|
l1_server: str # URL of the L1 server requesting verification
|
|
|
|
|
|
@app.post("/auth/verify")
|
|
async def verify_auth(
|
|
request: VerifyRequest,
|
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
):
|
|
"""Verify a token and return username. Only authorized L1 servers can call this."""
|
|
if not credentials:
|
|
raise HTTPException(401, "No token provided")
|
|
|
|
token = credentials.credentials
|
|
|
|
# Check L1 is authorized
|
|
l1_normalized = request.l1_server.rstrip("/")
|
|
authorized = any(l1_normalized == s.rstrip("/") for s in L1_SERVERS)
|
|
if not authorized:
|
|
raise HTTPException(403, f"L1 server not authorized: {request.l1_server}")
|
|
|
|
# Check if token is revoked (L2-side revocation)
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
if await db.is_token_revoked(token_hash):
|
|
raise HTTPException(401, "Token has been revoked")
|
|
|
|
# Verify token and get claims
|
|
claims = get_token_claims(token)
|
|
if not claims:
|
|
raise HTTPException(401, "Invalid token")
|
|
|
|
username = claims.get("sub")
|
|
if not username:
|
|
raise HTTPException(401, "Invalid token")
|
|
|
|
# Check token scope - if token is scoped to an L1, it must match
|
|
token_l1_server = claims.get("l1_server")
|
|
if token_l1_server:
|
|
token_l1_normalized = token_l1_server.rstrip("/")
|
|
if token_l1_normalized != l1_normalized:
|
|
raise HTTPException(403, f"Token is scoped to {token_l1_server}, not {request.l1_server}")
|
|
|
|
# Record the attachment (L1 successfully verified user's token)
|
|
await db.attach_renderer(username, l1_normalized)
|
|
|
|
return {"username": username, "valid": True, "l1_server": request.l1_server}
|
|
|
|
|
|
@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 await 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")
|
|
async def get_users_list(request: Request, page: int = 1, limit: int = 20):
|
|
"""Get all users. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
|
|
all_users = list((await db.get_all_users()).items())
|
|
total = len(all_users)
|
|
|
|
# Sort by username
|
|
all_users.sort(key=lambda x: x[0])
|
|
|
|
# Pagination
|
|
start = (page - 1) * limit
|
|
end = start + limit
|
|
users_page = all_users[start:end]
|
|
has_more = end < total
|
|
|
|
if wants_html(request):
|
|
username = get_user_from_cookie(request)
|
|
|
|
if not users_page:
|
|
if page == 1:
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Users</h2>
|
|
<p class="text-gray-400">No users registered yet.</p>
|
|
'''
|
|
else:
|
|
return HTMLResponse("") # Empty for infinite scroll
|
|
else:
|
|
rows = ""
|
|
for uname, user_data in users_page:
|
|
webfinger = f"@{uname}@{DOMAIN}"
|
|
created_at = format_date(user_data.get("created_at"))
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4">
|
|
<a href="/users/{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">{created_at}</td>
|
|
</tr>
|
|
'''
|
|
|
|
# For infinite scroll, just return rows if not first page
|
|
if page > 1:
|
|
if has_more:
|
|
rows += f'''
|
|
<tr hx-get="/users?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="3" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
return HTMLResponse(rows)
|
|
|
|
# First page - full content
|
|
infinite_scroll_trigger = ""
|
|
if has_more:
|
|
infinite_scroll_trigger = f'''
|
|
<tr hx-get="/users?page=2" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="3" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Users ({total} 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">Username</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">WebFinger</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
{infinite_scroll_trigger}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html("Users", content, username))
|
|
|
|
# JSON response for APIs
|
|
return {
|
|
"users": [{"username": uname, **data} for uname, data in users_page],
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"has_more": has_more
|
|
}
|
|
}
|
|
|
|
|
|
@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 await user_exists(username):
|
|
if wants_html(request):
|
|
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="/users" class="text-blue-400 hover:text-blue-300">← Back to Users</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("User Not Found", content, get_user_from_cookie(request)))
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
if wants_html(request):
|
|
# Render user detail page
|
|
return await ui_user_detail(username, request)
|
|
|
|
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 await user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
# Filter activities by this user's actor_id
|
|
all_activities = await 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 await 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_url = body.get("actor")
|
|
# Add follower to database
|
|
await db.add_follower(username, follower_url, follower_url)
|
|
|
|
# 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 await user_exists(username):
|
|
raise HTTPException(404, f"Unknown user: {username}")
|
|
|
|
# TODO: Per-user followers - for now use global followers
|
|
followers = await 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"
|
|
)
|
|
|
|
|
|
# ============ Assets Endpoints ============
|
|
|
|
@app.get("/assets")
|
|
async def get_registry(request: Request, page: int = 1, limit: int = 20):
|
|
"""Get registry. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
|
|
registry = await load_registry()
|
|
all_assets = list(registry.get("assets", {}).items())
|
|
total = len(all_assets)
|
|
|
|
# Sort by created_at descending
|
|
all_assets.sort(key=lambda x: x[1].get("created_at", ""), reverse=True)
|
|
|
|
# Pagination
|
|
start = (page - 1) * limit
|
|
end = start + limit
|
|
assets_page = all_assets[start:end]
|
|
has_more = end < total
|
|
|
|
if wants_html(request):
|
|
username = get_user_from_cookie(request)
|
|
|
|
if not assets_page:
|
|
if page == 1:
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Registry</h2>
|
|
<p class="text-gray-400">No assets registered yet.</p>
|
|
'''
|
|
else:
|
|
return HTMLResponse("") # Empty for infinite scroll
|
|
else:
|
|
rows = ""
|
|
for name, asset in assets_page:
|
|
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"
|
|
owner = asset.get("owner", "unknown")
|
|
content_hash = asset.get("content_hash", "")[:16] + "..."
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
|
<td class="py-3 px-4 text-white font-medium">{name}</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="/users/{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">{content_hash}</code></td>
|
|
<td class="py-3 px-4">
|
|
<a href="/assets/{name}" 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>
|
|
'''
|
|
|
|
# For infinite scroll, just return rows if not first page
|
|
if page > 1:
|
|
if has_more:
|
|
rows += f'''
|
|
<tr hx-get="/assets?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
return HTMLResponse(rows)
|
|
|
|
# First page - full content
|
|
infinite_scroll_trigger = ""
|
|
if has_more:
|
|
infinite_scroll_trigger = f'''
|
|
<tr hx-get="/assets?page=2" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Registry ({total} 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">Hash</th>
|
|
<th class="py-3 px-4 font-medium text-gray-300"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
{infinite_scroll_trigger}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html("Registry", content, username))
|
|
|
|
# JSON response for APIs
|
|
return {
|
|
"assets": {name: asset for name, asset in assets_page},
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"has_more": has_more
|
|
}
|
|
}
|
|
|
|
|
|
@app.get("/asset/{name}")
|
|
async def get_asset_by_name_legacy(name: str):
|
|
"""Legacy route - redirect to /assets/{name}."""
|
|
return RedirectResponse(url=f"/assets/{name}", status_code=301)
|
|
|
|
|
|
@app.get("/assets/{name}")
|
|
async def get_asset(name: str, request: Request):
|
|
"""Get asset by name. HTML for browsers (default), JSON only if explicitly requested."""
|
|
registry = await load_registry()
|
|
|
|
# Check if JSON explicitly requested
|
|
accept = request.headers.get("accept", "")
|
|
wants_json = "application/json" in accept and "text/html" not in accept
|
|
|
|
if name not in registry.get("assets", {}):
|
|
if wants_json:
|
|
raise HTTPException(404, f"Asset not found: {name}")
|
|
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="/assets" class="text-blue-400 hover:text-blue-300">← Back to Assets</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Asset Not Found", content, get_user_from_cookie(request)))
|
|
|
|
if wants_json:
|
|
return registry["assets"][name]
|
|
|
|
# Default to HTML for browsers
|
|
return await ui_asset_detail(name, request)
|
|
|
|
|
|
@app.get("/assets/by-run-id/{run_id}")
|
|
async def get_asset_by_run_id(run_id: str):
|
|
"""
|
|
Get asset by content-addressable run_id.
|
|
|
|
Returns the asset info including output_hash and ipfs_cid for L1 recovery.
|
|
The run_id is stored in the asset's provenance when the run is recorded.
|
|
"""
|
|
asset = await db.get_asset_by_run_id(run_id)
|
|
if not asset:
|
|
raise HTTPException(404, f"No asset found for run_id: {run_id}")
|
|
|
|
return {
|
|
"run_id": run_id,
|
|
"asset_name": asset.get("name"),
|
|
"output_hash": asset.get("content_hash"),
|
|
"ipfs_cid": asset.get("ipfs_cid"),
|
|
"provenance_cid": asset.get("provenance", {}).get("provenance_cid") if asset.get("provenance") else None,
|
|
}
|
|
|
|
|
|
@app.patch("/assets/{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."""
|
|
asset = await db.get_asset(name)
|
|
if not asset:
|
|
raise HTTPException(404, f"Asset not found: {name}")
|
|
|
|
# Check ownership
|
|
if asset.get("owner") != user.username:
|
|
raise HTTPException(403, f"Not authorized to update asset owned by {asset.get('owner')}")
|
|
|
|
# Build updates dict
|
|
updates = {}
|
|
if req.description is not None:
|
|
updates["description"] = req.description
|
|
if req.tags is not None:
|
|
updates["tags"] = req.tags
|
|
if req.metadata is not None:
|
|
updates["metadata"] = {**asset.get("metadata", {}), **req.metadata}
|
|
if req.origin is not None:
|
|
updates["origin"] = req.origin
|
|
if req.ipfs_cid is not None:
|
|
updates["ipfs_cid"] = req.ipfs_cid
|
|
# Pin on IPFS (fire-and-forget, don't block)
|
|
import threading
|
|
threading.Thread(target=_pin_ipfs_async, args=(req.ipfs_cid,), daemon=True).start()
|
|
|
|
# Update asset in database
|
|
updated_asset = await db.update_asset(name, updates)
|
|
|
|
# Create Update activity
|
|
activity = {
|
|
"activity_id": str(uuid.uuid4()),
|
|
"activity_type": "Update",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": {
|
|
"type": updated_asset.get("asset_type", "Object").capitalize(),
|
|
"name": name,
|
|
"id": f"https://{DOMAIN}/objects/{updated_asset['content_hash']}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": updated_asset["content_hash"]
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
|
|
"summary": req.description,
|
|
"tag": req.tags or updated_asset.get("tags", [])
|
|
},
|
|
"published": updated_asset.get("updated_at", datetime.now(timezone.utc).isoformat())
|
|
}
|
|
|
|
# Sign activity with the user's keys
|
|
activity = sign_activity(activity, user.username)
|
|
|
|
# Save activity to database
|
|
await db.create_activity(activity)
|
|
|
|
return {"asset": updated_asset, "activity": activity}
|
|
|
|
|
|
def _pin_ipfs_async(cid: str):
|
|
"""Pin IPFS content in background thread."""
|
|
try:
|
|
import ipfs_client
|
|
if ipfs_client.is_available():
|
|
ipfs_client.pin(cid)
|
|
logger.info(f"Pinned IPFS content: {cid}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to pin IPFS content {cid}: {e}")
|
|
|
|
|
|
async def _register_asset_impl(req: RegisterRequest, owner: str):
|
|
"""
|
|
Internal implementation for registering an asset atomically.
|
|
|
|
Requires IPFS CID - content must be on IPFS before registering.
|
|
Uses a transaction for all DB operations.
|
|
"""
|
|
import ipfs_client
|
|
from ipfs_client import IPFSError
|
|
|
|
logger.info(f"register_asset: Starting for {req.name} (hash={req.content_hash[:16]}...)")
|
|
|
|
# ===== PHASE 1: VALIDATION =====
|
|
# IPFS CID is required
|
|
if not req.ipfs_cid:
|
|
raise HTTPException(400, "IPFS CID is required for registration")
|
|
|
|
# Check if name exists - return existing asset if so
|
|
existing = await db.get_asset(req.name)
|
|
if existing:
|
|
logger.info(f"register_asset: Asset {req.name} already exists, returning existing")
|
|
return {"asset": existing, "activity": None, "existing": True}
|
|
|
|
# ===== PHASE 2: IPFS OPERATIONS (non-blocking) =====
|
|
import asyncio
|
|
logger.info(f"register_asset: Pinning CID {req.ipfs_cid[:16]}... on IPFS")
|
|
try:
|
|
await asyncio.to_thread(ipfs_client.pin_or_raise, req.ipfs_cid)
|
|
logger.info("register_asset: CID pinned successfully")
|
|
except IPFSError as e:
|
|
logger.error(f"register_asset: IPFS pin failed: {e}")
|
|
raise HTTPException(500, f"IPFS operation failed: {e}")
|
|
|
|
# ===== PHASE 3: DB TRANSACTION =====
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
try:
|
|
async with db.transaction() as conn:
|
|
# Check name again inside transaction (race condition protection)
|
|
if await db.asset_exists_by_name_tx(conn, req.name):
|
|
# Race condition - another request created it first, return existing
|
|
existing = await db.get_asset(req.name)
|
|
logger.info(f"register_asset: Asset {req.name} created by concurrent request")
|
|
return {"asset": existing, "activity": None, "existing": True}
|
|
|
|
# Create asset
|
|
asset = {
|
|
"name": req.name,
|
|
"content_hash": req.content_hash,
|
|
"ipfs_cid": req.ipfs_cid,
|
|
"asset_type": req.asset_type,
|
|
"tags": req.tags,
|
|
"metadata": req.metadata,
|
|
"url": req.url,
|
|
"provenance": req.provenance,
|
|
"owner": owner,
|
|
"created_at": now
|
|
}
|
|
created_asset = await db.create_asset_tx(conn, asset)
|
|
|
|
# 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": req.content_hash, # Content-addressable by content hash
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{owner}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
activity = sign_activity(activity, owner)
|
|
created_activity = await db.create_activity_tx(conn, activity)
|
|
|
|
# Transaction commits here on successful exit
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"register_asset: Database transaction failed: {e}")
|
|
raise HTTPException(500, f"Failed to register asset: {e}")
|
|
|
|
logger.info(f"register_asset: Successfully registered {req.name}")
|
|
return {"asset": created_asset, "activity": created_activity}
|
|
|
|
|
|
@app.post("/assets")
|
|
async def register_asset(req: RegisterRequest, user: User = Depends(get_required_user)):
|
|
"""Register a new asset and create ownership activity. Requires authentication."""
|
|
return await _register_asset_impl(req, user.username)
|
|
|
|
|
|
@app.post("/assets/record-run")
|
|
@app.post("/registry/record-run") # Legacy route
|
|
async def record_run(req: RecordRunRequest, user: User = Depends(get_required_user)):
|
|
"""
|
|
Record an L1 run and register the output atomically.
|
|
|
|
Ensures all operations succeed or none do:
|
|
1. All input assets registered (if not already on L2) + pinned on IPFS
|
|
2. Output asset registered + pinned on IPFS
|
|
3. Recipe serialized to JSON, stored on IPFS, CID saved in provenance
|
|
"""
|
|
import ipfs_client
|
|
from ipfs_client import IPFSError
|
|
|
|
# ===== PHASE 1: PREPARATION (read-only, non-blocking) =====
|
|
import asyncio
|
|
l1_url = req.l1_server.rstrip('/')
|
|
|
|
logger.info(f"record_run: Starting for run_id={req.run_id} from {l1_url}")
|
|
|
|
# Helper to fetch from L1 without blocking event loop
|
|
def fetch_l1_run():
|
|
import time as _time
|
|
url = f"{l1_url}/runs/{req.run_id}"
|
|
logger.info(f"record_run: Fetching run from L1: {url}")
|
|
t0 = _time.time()
|
|
resp = requests.get(url, timeout=30)
|
|
logger.info(f"record_run: L1 request took {_time.time()-t0:.3f}s, status={resp.status_code}")
|
|
if resp.status_code == 404:
|
|
raise ValueError(f"Run not found on L1: {req.run_id}")
|
|
resp.raise_for_status()
|
|
try:
|
|
return resp.json()
|
|
except Exception:
|
|
body_preview = resp.text[:200] if resp.text else "(empty)"
|
|
logger.error(f"L1 returned non-JSON for {url}: status={resp.status_code}, body={body_preview}")
|
|
raise ValueError(f"L1 returned invalid response: {body_preview[:100]}")
|
|
|
|
def fetch_l1_cache(content_hash):
|
|
logger.debug(f"record_run: Fetching cache {content_hash[:16]}... from L1")
|
|
url = f"{l1_url}/cache/{content_hash}"
|
|
resp = requests.get(url, headers={"Accept": "application/json"}, timeout=10)
|
|
if resp.status_code == 404:
|
|
raise ValueError(f"Cache item not found on L1: {content_hash[:16]}...")
|
|
resp.raise_for_status()
|
|
try:
|
|
return resp.json()
|
|
except Exception as e:
|
|
# Log what we actually got back
|
|
body_preview = resp.text[:200] if resp.text else "(empty)"
|
|
logger.error(f"L1 returned non-JSON for {url}: status={resp.status_code}, body={body_preview}")
|
|
raise ValueError(f"L1 returned invalid response (status={resp.status_code}): {body_preview[:100]}")
|
|
|
|
# Fetch run from L1
|
|
try:
|
|
run = await asyncio.to_thread(fetch_l1_run)
|
|
logger.info(f"record_run: Fetched run, status={run.get('status')}, inputs={len(run.get('inputs', []))}")
|
|
except Exception as e:
|
|
logger.error(f"record_run: Failed to fetch run from L1: {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")
|
|
|
|
# Fetch output cache info from L1 (must exist - it's new)
|
|
logger.info(f"record_run: Fetching output cache {output_hash[:16]}... from L1")
|
|
try:
|
|
cache_info = await asyncio.to_thread(fetch_l1_cache, output_hash)
|
|
output_media_type = cache_info.get("media_type", "image")
|
|
output_ipfs_cid = cache_info.get("ipfs_cid")
|
|
logger.info(f"record_run: Output has IPFS CID: {output_ipfs_cid[:16] if output_ipfs_cid else 'None'}...")
|
|
except Exception as e:
|
|
logger.error(f"record_run: Failed to fetch output cache info: {e}")
|
|
raise HTTPException(400, f"Failed to fetch output cache info: {e}")
|
|
|
|
if not output_ipfs_cid:
|
|
logger.error("record_run: Output has no IPFS CID")
|
|
raise HTTPException(400, "Output has no IPFS CID - cannot publish")
|
|
|
|
# Gather input info: check L2 first, then fall back to L1
|
|
input_hashes = run.get("inputs", [])
|
|
input_infos = [] # List of {content_hash, ipfs_cid, media_type, existing_asset}
|
|
logger.info(f"record_run: Gathering info for {len(input_hashes)} inputs")
|
|
|
|
for input_hash in input_hashes:
|
|
# Check if already on L2
|
|
existing = await db.get_asset_by_hash(input_hash)
|
|
if existing and existing.get("ipfs_cid"):
|
|
logger.info(f"record_run: Input {input_hash[:16]}... found on L2")
|
|
input_infos.append({
|
|
"content_hash": input_hash,
|
|
"ipfs_cid": existing["ipfs_cid"],
|
|
"media_type": existing.get("asset_type", "image"),
|
|
"existing_asset": existing
|
|
})
|
|
else:
|
|
# Not on L2, try L1
|
|
logger.info(f"record_run: Input {input_hash[:16]}... not on L2, fetching from L1")
|
|
try:
|
|
inp_info = await asyncio.to_thread(fetch_l1_cache, input_hash)
|
|
ipfs_cid = inp_info.get("ipfs_cid")
|
|
if not ipfs_cid:
|
|
logger.error(f"record_run: Input {input_hash[:16]}... has no IPFS CID")
|
|
raise HTTPException(400, f"Input {input_hash[:16]}... has no IPFS CID (not on L2 or L1)")
|
|
input_infos.append({
|
|
"content_hash": input_hash,
|
|
"ipfs_cid": ipfs_cid,
|
|
"media_type": inp_info.get("media_type", "image"),
|
|
"existing_asset": None
|
|
})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"record_run: Failed to fetch input {input_hash[:16]}... from L1: {e}")
|
|
raise HTTPException(400, f"Input {input_hash[:16]}... not on L2 and failed to fetch from L1: {e}")
|
|
|
|
# Prepare recipe data
|
|
recipe_data = run.get("recipe")
|
|
if not recipe_data:
|
|
recipe_data = {
|
|
"name": run.get("recipe_name", "unknown"),
|
|
"effect_url": run.get("effect_url"),
|
|
"effects_commit": run.get("effects_commit"),
|
|
}
|
|
|
|
# Build registered_inputs list - all referenced by content_hash
|
|
registered_inputs = []
|
|
for inp in input_infos:
|
|
registered_inputs.append({
|
|
"content_hash": inp["content_hash"],
|
|
"ipfs_cid": inp["ipfs_cid"]
|
|
})
|
|
|
|
# ===== PHASE 2: IPFS OPERATIONS (non-blocking for event loop) =====
|
|
def do_ipfs_operations():
|
|
"""Run IPFS operations in thread pool to not block event loop."""
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
# Collect all CIDs to pin (inputs + output)
|
|
cids_to_pin = [inp["ipfs_cid"] for inp in input_infos] + [output_ipfs_cid]
|
|
logger.info(f"record_run: Pinning {len(cids_to_pin)} CIDs on IPFS")
|
|
|
|
# Pin all in parallel
|
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
futures = {executor.submit(ipfs_client.pin_or_raise, cid): cid for cid in cids_to_pin}
|
|
for future in as_completed(futures):
|
|
future.result() # Raises IPFSError if failed
|
|
logger.info("record_run: All CIDs pinned successfully")
|
|
|
|
# Store recipe on IPFS
|
|
logger.info("record_run: Storing recipe on IPFS")
|
|
recipe_cid = ipfs_client.add_json(recipe_data)
|
|
|
|
# Build and store full provenance on IPFS
|
|
# Compute content-addressable run_id from inputs + recipe
|
|
recipe_name = recipe_data.get("name", "unknown") if isinstance(recipe_data, dict) else str(recipe_data)
|
|
run_id = compute_run_id(input_hashes, recipe_name)
|
|
provenance = {
|
|
"run_id": run_id, # Content-addressable run identifier
|
|
"inputs": registered_inputs,
|
|
"output": {
|
|
"content_hash": output_hash,
|
|
"ipfs_cid": output_ipfs_cid
|
|
},
|
|
"recipe": recipe_data,
|
|
"recipe_cid": recipe_cid,
|
|
"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")
|
|
}
|
|
logger.info("record_run: Storing provenance on IPFS")
|
|
provenance_cid = ipfs_client.add_json(provenance)
|
|
|
|
return recipe_cid, provenance_cid, provenance
|
|
|
|
try:
|
|
import asyncio
|
|
recipe_cid, provenance_cid, provenance = await asyncio.to_thread(do_ipfs_operations)
|
|
logger.info(f"record_run: Recipe CID: {recipe_cid[:16]}..., Provenance CID: {provenance_cid[:16]}...")
|
|
except IPFSError as e:
|
|
logger.error(f"record_run: IPFS operation failed: {e}")
|
|
raise HTTPException(500, f"IPFS operation failed: {e}")
|
|
|
|
# ===== PHASE 3: DB TRANSACTION (all-or-nothing) =====
|
|
logger.info("record_run: Starting DB transaction")
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Add provenance_cid to provenance for storage in DB
|
|
provenance["provenance_cid"] = provenance_cid
|
|
|
|
try:
|
|
async with db.transaction() as conn:
|
|
# Register input assets (if not already on L2) - named by content_hash
|
|
for inp in input_infos:
|
|
if not inp["existing_asset"]:
|
|
media_type = inp["media_type"]
|
|
tags = ["auto-registered", "input"]
|
|
if media_type == "recipe":
|
|
tags.append("recipe")
|
|
input_asset = {
|
|
"name": inp["content_hash"], # Use content_hash as name
|
|
"content_hash": inp["content_hash"],
|
|
"ipfs_cid": inp["ipfs_cid"],
|
|
"asset_type": media_type,
|
|
"tags": tags,
|
|
"metadata": {"auto_registered_from_run": req.run_id},
|
|
"owner": user.username,
|
|
"created_at": now
|
|
}
|
|
await db.create_asset_tx(conn, input_asset)
|
|
|
|
# Check if output already exists (by content_hash) - return existing if so
|
|
existing = await db.get_asset_by_name_tx(conn, output_hash)
|
|
if existing:
|
|
logger.info(f"record_run: Output {output_hash[:16]}... already exists")
|
|
# Check if activity already exists for this run
|
|
existing_activity = await db.get_activity(provenance["run_id"])
|
|
if existing_activity:
|
|
logger.info(f"record_run: Activity {provenance['run_id'][:16]}... also exists")
|
|
return {"asset": existing, "activity": existing_activity, "existing": True}
|
|
# Asset exists but no activity - create one
|
|
logger.info(f"record_run: Creating activity for existing asset")
|
|
object_data = {
|
|
"type": existing.get("asset_type", "image").capitalize(),
|
|
"name": output_hash,
|
|
"id": f"https://{DOMAIN}/objects/{output_hash}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": output_hash
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
|
|
"provenance": provenance
|
|
}
|
|
activity = {
|
|
"activity_id": provenance["run_id"],
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
activity = sign_activity(activity, user.username)
|
|
created_activity = await db.create_activity_tx(conn, activity)
|
|
return {"asset": existing, "activity": created_activity, "existing": True}
|
|
|
|
# Create output asset with provenance - named by content_hash
|
|
output_asset = {
|
|
"name": output_hash, # Use content_hash as name
|
|
"content_hash": output_hash,
|
|
"ipfs_cid": output_ipfs_cid,
|
|
"asset_type": output_media_type,
|
|
"tags": ["rendered", "l1"],
|
|
"metadata": {"l1_server": l1_url, "l1_run_id": req.run_id},
|
|
"provenance": provenance,
|
|
"owner": user.username,
|
|
"created_at": now
|
|
}
|
|
created_asset = await db.create_asset_tx(conn, output_asset)
|
|
|
|
# Create activity - all referenced by content_hash
|
|
object_data = {
|
|
"type": output_media_type.capitalize(),
|
|
"name": output_hash, # Use content_hash as name
|
|
"id": f"https://{DOMAIN}/objects/{output_hash}",
|
|
"contentHash": {
|
|
"algorithm": "sha3-256",
|
|
"value": output_hash
|
|
},
|
|
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
|
|
"provenance": provenance
|
|
}
|
|
|
|
activity = {
|
|
"activity_id": provenance["run_id"], # Content-addressable run_id
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
activity = sign_activity(activity, user.username)
|
|
created_activity = await db.create_activity_tx(conn, activity)
|
|
|
|
# Transaction commits here on successful exit
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"record_run: Database transaction failed: {e}")
|
|
raise HTTPException(500, f"Failed to record run: {e}")
|
|
|
|
logger.info(f"record_run: Successfully published {output_hash[:16]}... with {len(registered_inputs)} inputs")
|
|
return {"asset": created_asset, "activity": created_activity}
|
|
|
|
|
|
@app.post("/assets/publish-cache")
|
|
async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_required_user)):
|
|
"""
|
|
Publish a cache item from L1 with metadata atomically.
|
|
|
|
Requires origin to be set (self or external URL).
|
|
Requires IPFS CID - content must be on IPFS before publishing.
|
|
Creates a new asset and Create activity in a single transaction.
|
|
"""
|
|
import ipfs_client
|
|
from ipfs_client import IPFSError
|
|
|
|
logger.info(f"publish_cache: Starting for {req.asset_name} (hash={req.content_hash[:16]}...)")
|
|
|
|
# ===== PHASE 1: VALIDATION =====
|
|
# 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")
|
|
|
|
# IPFS CID is now required
|
|
if not req.ipfs_cid:
|
|
raise HTTPException(400, "IPFS CID is required for publishing")
|
|
|
|
# Check if asset name already exists
|
|
if await db.asset_exists(req.asset_name):
|
|
raise HTTPException(400, f"Asset name already exists: {req.asset_name}")
|
|
|
|
# ===== PHASE 2: IPFS OPERATIONS (non-blocking) =====
|
|
import asyncio
|
|
logger.info(f"publish_cache: Pinning CID {req.ipfs_cid[:16]}... on IPFS")
|
|
try:
|
|
await asyncio.to_thread(ipfs_client.pin_or_raise, req.ipfs_cid)
|
|
logger.info("publish_cache: CID pinned successfully")
|
|
except IPFSError as e:
|
|
logger.error(f"publish_cache: IPFS pin failed: {e}")
|
|
raise HTTPException(500, f"IPFS operation failed: {e}")
|
|
|
|
# ===== PHASE 3: DB TRANSACTION =====
|
|
logger.info("publish_cache: Starting DB transaction")
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
try:
|
|
async with db.transaction() as conn:
|
|
# Check name again inside transaction (race condition protection)
|
|
if await db.asset_exists_by_name_tx(conn, req.asset_name):
|
|
raise HTTPException(400, f"Asset name already exists: {req.asset_name}")
|
|
|
|
# Create asset
|
|
asset = {
|
|
"name": req.asset_name,
|
|
"content_hash": req.content_hash,
|
|
"ipfs_cid": req.ipfs_cid,
|
|
"asset_type": req.asset_type,
|
|
"tags": req.tags,
|
|
"description": req.description,
|
|
"origin": req.origin,
|
|
"metadata": req.metadata,
|
|
"owner": user.username,
|
|
"created_at": now
|
|
}
|
|
created_asset = await db.create_asset_tx(conn, asset)
|
|
|
|
# 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": req.content_hash, # Content-addressable by content hash
|
|
"activity_type": "Create",
|
|
"actor_id": f"https://{DOMAIN}/users/{user.username}",
|
|
"object_data": object_data,
|
|
"published": now
|
|
}
|
|
activity = sign_activity(activity, user.username)
|
|
created_activity = await db.create_activity_tx(conn, activity)
|
|
|
|
# Transaction commits here on successful exit
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"publish_cache: Database transaction failed: {e}")
|
|
raise HTTPException(500, f"Failed to publish cache item: {e}")
|
|
|
|
logger.info(f"publish_cache: Successfully published {req.asset_name}")
|
|
return {"asset": created_asset, "activity": created_activity}
|
|
|
|
|
|
# ============ Activities Endpoints ============
|
|
|
|
@app.get("/activities")
|
|
async def get_activities(request: Request, page: int = 1, limit: int = 20):
|
|
"""Get activities. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
|
|
all_activities = await load_activities()
|
|
total = len(all_activities)
|
|
|
|
# Reverse for newest first
|
|
all_activities = list(reversed(all_activities))
|
|
|
|
# Pagination
|
|
start = (page - 1) * limit
|
|
end = start + limit
|
|
activities_page = all_activities[start:end]
|
|
has_more = end < total
|
|
|
|
if wants_html(request):
|
|
username = get_user_from_cookie(request)
|
|
|
|
if not activities_page:
|
|
if page == 1:
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Activities</h2>
|
|
<p class="text-gray-400">No activities yet.</p>
|
|
'''
|
|
else:
|
|
return HTMLResponse("") # Empty for infinite scroll
|
|
else:
|
|
rows = ""
|
|
for i, activity in enumerate(activities_page):
|
|
activity_index = total - 1 - (start + i) # Original index
|
|
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="/users/{actor_name}" class="text-gray-400 hover:text-blue-300">{actor_name}</a>
|
|
</td>
|
|
<td class="py-3 px-4 text-gray-400">{format_date(activity.get("published"))}</td>
|
|
<td class="py-3 px-4">
|
|
<a href="/activities/{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>
|
|
'''
|
|
|
|
# For infinite scroll, just return rows if not first page
|
|
if page > 1:
|
|
if has_more:
|
|
rows += f'''
|
|
<tr hx-get="/activities?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
return HTMLResponse(rows)
|
|
|
|
# First page - full content with header
|
|
infinite_scroll_trigger = ""
|
|
if has_more:
|
|
infinite_scroll_trigger = f'''
|
|
<tr hx-get="/activities?page=2" hx-trigger="revealed" hx-swap="afterend">
|
|
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
|
|
</tr>
|
|
'''
|
|
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-6">Activities ({total} 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}
|
|
{infinite_scroll_trigger}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html("Activities", content, username))
|
|
|
|
# JSON response for APIs
|
|
return {
|
|
"activities": activities_page,
|
|
"pagination": {
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"has_more": has_more
|
|
}
|
|
}
|
|
|
|
|
|
@app.get("/activities/{activity_ref}")
|
|
async def get_activity_detail(activity_ref: str, request: Request):
|
|
"""Get single activity by index or activity_id. HTML for browsers (default), JSON only if explicitly requested."""
|
|
|
|
# Check if JSON explicitly requested
|
|
accept = request.headers.get("accept", "")
|
|
wants_json = ("application/json" in accept or "application/activity+json" in accept) and "text/html" not in accept
|
|
|
|
activity = None
|
|
activity_index = None
|
|
|
|
# Check if it's a numeric index or an activity_id (hash)
|
|
if activity_ref.isdigit():
|
|
# Numeric index (legacy)
|
|
activity_index = int(activity_ref)
|
|
activities = await load_activities()
|
|
if 0 <= activity_index < len(activities):
|
|
activity = activities[activity_index]
|
|
else:
|
|
# Activity ID (hash) - look up directly
|
|
activity = await db.get_activity(activity_ref)
|
|
if activity:
|
|
# Find index for UI rendering
|
|
activities = await load_activities()
|
|
for i, a in enumerate(activities):
|
|
if a.get("activity_id") == activity_ref:
|
|
activity_index = i
|
|
break
|
|
|
|
if not activity:
|
|
if wants_json:
|
|
raise HTTPException(404, "Activity not found")
|
|
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="/activities" class="text-blue-400 hover:text-blue-300">← Back to Activities</a></p>
|
|
'''
|
|
return HTMLResponse(base_html("Activity Not Found", content, get_user_from_cookie(request)))
|
|
|
|
if wants_json:
|
|
return activity
|
|
|
|
# Default to HTML for browsers
|
|
if activity_index is not None:
|
|
return await ui_activity_detail(activity_index, request)
|
|
else:
|
|
# Render activity directly if no index found
|
|
return await ui_activity_detail_by_data(activity, request)
|
|
|
|
|
|
@app.get("/activity/{activity_index}")
|
|
async def get_activity_legacy(activity_index: int):
|
|
"""Legacy route - redirect to /activities/{activity_index}."""
|
|
return RedirectResponse(url=f"/activities/{activity_index}", status_code=301)
|
|
|
|
|
|
@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 = await load_registry()
|
|
|
|
# Find asset by hash
|
|
for name, asset in registry.get("assets", {}).items():
|
|
if asset.get("content_hash") == content_hash:
|
|
# Check Accept header - only return JSON if explicitly requested
|
|
accept = request.headers.get("accept", "")
|
|
wants_json = ("application/json" in accept or "application/activity+json" in accept) and "text/html" not in accept
|
|
|
|
if not wants_json:
|
|
# Default: redirect to detail page for browsers
|
|
return RedirectResponse(url=f"/assets/{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}")
|
|
|
|
|
|
# ============ Anchoring (Bitcoin timestamps) ============
|
|
|
|
@app.post("/anchors/create")
|
|
async def create_anchor_endpoint(request: Request):
|
|
"""
|
|
Create a new anchor for all unanchored activities.
|
|
|
|
Builds a merkle tree, stores it on IPFS, and submits to OpenTimestamps
|
|
for Bitcoin anchoring. The anchor proof is backed up to persistent storage.
|
|
"""
|
|
import anchoring
|
|
import ipfs_client
|
|
|
|
# Check auth (cookie or header)
|
|
username = get_user_from_cookie(request)
|
|
if not username:
|
|
if wants_html(request):
|
|
return HTMLResponse('''
|
|
<div class="p-3 bg-red-900/50 border border-red-700 rounded text-red-300">
|
|
<strong>Error:</strong> Login required
|
|
</div>
|
|
''')
|
|
raise HTTPException(401, "Authentication required")
|
|
|
|
# Get unanchored activities
|
|
unanchored = await db.get_unanchored_activities()
|
|
if not unanchored:
|
|
if wants_html(request):
|
|
return HTMLResponse('''
|
|
<div class="p-3 bg-yellow-900/50 border border-yellow-700 rounded text-yellow-300">
|
|
No unanchored activities to anchor.
|
|
</div>
|
|
''')
|
|
return {"message": "No unanchored activities", "anchored": 0}
|
|
|
|
activity_ids = [a["activity_id"] for a in unanchored]
|
|
|
|
# Create anchor
|
|
anchor = await anchoring.create_anchor(activity_ids, db, ipfs_client)
|
|
|
|
if anchor:
|
|
if wants_html(request):
|
|
return HTMLResponse(f'''
|
|
<div class="p-3 bg-green-900/50 border border-green-700 rounded text-green-300">
|
|
<strong>Success!</strong> Anchored {len(activity_ids)} activities.<br>
|
|
<span class="text-sm">Merkle root: {anchor["merkle_root"][:32]}...</span><br>
|
|
<span class="text-sm">Refresh page to see the new anchor.</span>
|
|
</div>
|
|
''')
|
|
return {
|
|
"message": f"Anchored {len(activity_ids)} activities",
|
|
"merkle_root": anchor["merkle_root"],
|
|
"tree_ipfs_cid": anchor.get("tree_ipfs_cid"),
|
|
"activity_count": anchor["activity_count"]
|
|
}
|
|
else:
|
|
if wants_html(request):
|
|
return HTMLResponse('''
|
|
<div class="p-3 bg-red-900/50 border border-red-700 rounded text-red-300">
|
|
<strong>Failed!</strong> Could not create anchor.
|
|
</div>
|
|
''')
|
|
raise HTTPException(500, "Failed to create anchor")
|
|
|
|
|
|
@app.get("/anchors")
|
|
async def list_anchors():
|
|
"""List all anchors."""
|
|
anchors = await db.get_all_anchors()
|
|
stats = await db.get_anchor_stats()
|
|
return {
|
|
"anchors": anchors,
|
|
"stats": stats
|
|
}
|
|
|
|
|
|
@app.get("/anchors/{merkle_root}")
|
|
async def get_anchor_endpoint(merkle_root: str):
|
|
"""Get anchor details by merkle root."""
|
|
anchor = await db.get_anchor(merkle_root)
|
|
if not anchor:
|
|
raise HTTPException(404, f"Anchor not found: {merkle_root}")
|
|
return anchor
|
|
|
|
|
|
@app.get("/anchors/{merkle_root}/tree")
|
|
async def get_anchor_tree(merkle_root: str):
|
|
"""Get the full merkle tree from IPFS."""
|
|
import asyncio
|
|
import ipfs_client
|
|
|
|
anchor = await db.get_anchor(merkle_root)
|
|
if not anchor:
|
|
raise HTTPException(404, f"Anchor not found: {merkle_root}")
|
|
|
|
tree_cid = anchor.get("tree_ipfs_cid")
|
|
if not tree_cid:
|
|
raise HTTPException(404, "Anchor has no tree on IPFS")
|
|
|
|
try:
|
|
tree_bytes = await asyncio.to_thread(ipfs_client.get_bytes, tree_cid)
|
|
if tree_bytes:
|
|
return json.loads(tree_bytes)
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to fetch tree from IPFS: {e}")
|
|
|
|
|
|
@app.get("/anchors/verify/{activity_id}")
|
|
async def verify_activity_anchor(activity_id: str):
|
|
"""
|
|
Verify an activity's anchor proof.
|
|
|
|
Returns the merkle proof showing this activity is included in an anchored batch.
|
|
"""
|
|
import anchoring
|
|
import ipfs_client
|
|
|
|
# Get activity
|
|
activity = await db.get_activity(activity_id)
|
|
if not activity:
|
|
raise HTTPException(404, f"Activity not found: {activity_id}")
|
|
|
|
anchor_root = activity.get("anchor_root")
|
|
if not anchor_root:
|
|
return {"verified": False, "reason": "Activity not yet anchored"}
|
|
|
|
# Get anchor
|
|
anchor = await db.get_anchor(anchor_root)
|
|
if not anchor:
|
|
return {"verified": False, "reason": "Anchor record not found"}
|
|
|
|
# Get tree from IPFS (non-blocking)
|
|
import asyncio
|
|
tree_cid = anchor.get("tree_ipfs_cid")
|
|
if not tree_cid:
|
|
return {"verified": False, "reason": "Merkle tree not on IPFS"}
|
|
|
|
try:
|
|
tree_bytes = await asyncio.to_thread(ipfs_client.get_bytes, tree_cid)
|
|
tree = json.loads(tree_bytes) if tree_bytes else None
|
|
except Exception:
|
|
return {"verified": False, "reason": "Failed to fetch tree from IPFS"}
|
|
|
|
if not tree:
|
|
return {"verified": False, "reason": "Could not load merkle tree"}
|
|
|
|
# Get proof
|
|
proof = anchoring.get_merkle_proof(tree, activity_id)
|
|
if not proof:
|
|
return {"verified": False, "reason": "Activity not in merkle tree"}
|
|
|
|
# Verify proof
|
|
valid = anchoring.verify_merkle_proof(activity_id, proof, anchor_root)
|
|
|
|
return {
|
|
"verified": valid,
|
|
"activity_id": activity_id,
|
|
"merkle_root": anchor_root,
|
|
"tree_ipfs_cid": tree_cid,
|
|
"proof": proof,
|
|
"bitcoin_txid": anchor.get("bitcoin_txid"),
|
|
"confirmed_at": anchor.get("confirmed_at")
|
|
}
|
|
|
|
|
|
@app.post("/anchors/{merkle_root}/upgrade")
|
|
async def upgrade_anchor_proof(merkle_root: str):
|
|
"""
|
|
Try to upgrade an OTS proof from pending to confirmed.
|
|
|
|
Bitcoin confirmation typically takes 1-2 hours. Call this periodically
|
|
to check if the proof has been included in a Bitcoin block.
|
|
"""
|
|
import anchoring
|
|
import ipfs_client
|
|
import asyncio
|
|
|
|
anchor = await db.get_anchor(merkle_root)
|
|
if not anchor:
|
|
raise HTTPException(404, f"Anchor not found: {merkle_root}")
|
|
|
|
if anchor.get("confirmed_at"):
|
|
return {"status": "already_confirmed", "bitcoin_txid": anchor.get("bitcoin_txid")}
|
|
|
|
# Get current OTS proof from IPFS
|
|
ots_cid = anchor.get("ots_proof_cid")
|
|
if not ots_cid:
|
|
return {"status": "no_proof", "message": "No OTS proof stored"}
|
|
|
|
try:
|
|
ots_proof = await asyncio.to_thread(ipfs_client.get_bytes, ots_cid)
|
|
if not ots_proof:
|
|
return {"status": "error", "message": "Could not fetch OTS proof from IPFS"}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"IPFS error: {e}"}
|
|
|
|
# Try to upgrade
|
|
upgraded = await asyncio.to_thread(anchoring.upgrade_ots_proof, ots_proof)
|
|
|
|
if upgraded and len(upgraded) > len(ots_proof):
|
|
# Store upgraded proof on IPFS
|
|
try:
|
|
new_cid = await asyncio.to_thread(ipfs_client.add_bytes, upgraded)
|
|
# TODO: Update anchor record with new CID and confirmed status
|
|
return {
|
|
"status": "upgraded",
|
|
"message": "Proof upgraded - Bitcoin confirmation received",
|
|
"new_ots_cid": new_cid,
|
|
"proof_size": len(upgraded)
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"Failed to store upgraded proof: {e}"}
|
|
else:
|
|
return {
|
|
"status": "pending",
|
|
"message": "Not yet confirmed on Bitcoin. Try again in ~1 hour.",
|
|
"proof_size": len(ots_proof) if ots_proof else 0
|
|
}
|
|
|
|
|
|
@app.get("/anchors/ui", response_class=HTMLResponse)
|
|
async def anchors_ui(request: Request):
|
|
"""Anchors UI page - view and test OpenTimestamps anchoring."""
|
|
username = get_user_from_cookie(request)
|
|
|
|
anchors = await db.get_all_anchors()
|
|
stats = await db.get_anchor_stats()
|
|
|
|
# Build anchors table rows
|
|
rows = ""
|
|
for anchor in anchors:
|
|
status = "confirmed" if anchor.get("confirmed_at") else "pending"
|
|
status_class = "text-green-400" if status == "confirmed" else "text-yellow-400"
|
|
merkle_root = anchor.get("merkle_root", "")[:16] + "..."
|
|
|
|
rows += f'''
|
|
<tr class="border-b border-dark-500 hover:bg-dark-600">
|
|
<td class="py-3 px-2 font-mono text-sm">{merkle_root}</td>
|
|
<td class="py-3 px-2">{anchor.get("activity_count", 0)}</td>
|
|
<td class="py-3 px-2 {status_class}">{status}</td>
|
|
<td class="py-3 px-2 text-sm">{format_date(anchor.get("created_at"), 16)}</td>
|
|
<td class="py-3 px-2">
|
|
<button
|
|
hx-post="/anchors/{anchor.get("merkle_root")}/upgrade"
|
|
hx-target="#upgrade-result-{anchor.get("merkle_root")[:8]}"
|
|
hx-swap="innerHTML"
|
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-sm">
|
|
Check Status
|
|
</button>
|
|
<span id="upgrade-result-{anchor.get("merkle_root")[:8]}" class="ml-2 text-sm"></span>
|
|
</td>
|
|
</tr>
|
|
'''
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="5" class="py-8 text-center text-gray-500">No anchors yet</td></tr>'
|
|
|
|
content = f'''
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
|
|
<h2 class="text-xl font-semibold mb-4">Bitcoin Anchoring via OpenTimestamps</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<div class="text-2xl font-bold text-blue-400">{stats.get("total_anchors", 0)}</div>
|
|
<div class="text-gray-400 text-sm">Total Anchors</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<div class="text-2xl font-bold text-green-400">{stats.get("confirmed_anchors", 0)}</div>
|
|
<div class="text-gray-400 text-sm">Confirmed</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-4">
|
|
<div class="text-2xl font-bold text-yellow-400">{stats.get("pending_anchors", 0)}</div>
|
|
<div class="text-gray-400 text-sm">Pending</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-6 p-4 bg-dark-600 rounded-lg">
|
|
<h3 class="font-semibold mb-2">Test Anchoring</h3>
|
|
<p class="text-sm text-gray-400 mb-3">Create a test anchor for unanchored activities, or test the OTS connection.</p>
|
|
<div class="flex gap-4">
|
|
<button
|
|
hx-post="/anchors/create"
|
|
hx-target="#test-result"
|
|
hx-swap="innerHTML"
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded">
|
|
Create Anchor
|
|
</button>
|
|
<button
|
|
hx-post="/anchors/test-ots"
|
|
hx-target="#test-result"
|
|
hx-swap="innerHTML"
|
|
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded">
|
|
Test OTS Connection
|
|
</button>
|
|
</div>
|
|
<div id="test-result" class="mt-4 text-sm"></div>
|
|
</div>
|
|
|
|
<h3 class="font-semibold mb-3">Anchors</h3>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-gray-400 border-b border-dark-500">
|
|
<th class="py-2 px-2">Merkle Root</th>
|
|
<th class="py-2 px-2">Activities</th>
|
|
<th class="py-2 px-2">Status</th>
|
|
<th class="py-2 px-2">Created</th>
|
|
<th class="py-2 px-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="mt-6 p-4 bg-dark-800 rounded-lg text-sm text-gray-400">
|
|
<h4 class="font-semibold text-gray-300 mb-2">How it works:</h4>
|
|
<ol class="list-decimal list-inside space-y-1">
|
|
<li>Activities are batched and hashed into a merkle tree</li>
|
|
<li>The merkle root is submitted to OpenTimestamps</li>
|
|
<li>OTS aggregates hashes and anchors to Bitcoin (~1-2 hours)</li>
|
|
<li>Once confirmed, anyone can verify the timestamp</li>
|
|
</ol>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html("Anchors", content, username))
|
|
|
|
|
|
@app.post("/anchors/test-ots", response_class=HTMLResponse)
|
|
async def test_ots_connection():
|
|
"""Test OpenTimestamps connection by submitting a test hash."""
|
|
import anchoring
|
|
import hashlib
|
|
import asyncio
|
|
|
|
# Create a test hash
|
|
test_data = f"test-{datetime.now(timezone.utc).isoformat()}"
|
|
test_hash = hashlib.sha256(test_data.encode()).hexdigest()
|
|
|
|
# Try to submit
|
|
try:
|
|
ots_proof = await asyncio.to_thread(anchoring.submit_to_opentimestamps, test_hash)
|
|
if ots_proof:
|
|
return HTMLResponse(f'''
|
|
<div class="p-3 bg-green-900/50 border border-green-700 rounded text-green-300">
|
|
<strong>Success!</strong> OpenTimestamps is working.<br>
|
|
<span class="text-sm">Test hash: {test_hash[:32]}...</span><br>
|
|
<span class="text-sm">Proof size: {len(ots_proof)} bytes</span>
|
|
</div>
|
|
''')
|
|
else:
|
|
return HTMLResponse('''
|
|
<div class="p-3 bg-red-900/50 border border-red-700 rounded text-red-300">
|
|
<strong>Failed!</strong> Could not reach OpenTimestamps servers.
|
|
</div>
|
|
''')
|
|
except Exception as e:
|
|
return HTMLResponse(f'''
|
|
<div class="p-3 bg-red-900/50 border border-red-700 rounded text-red-300">
|
|
<strong>Error:</strong> {str(e)}
|
|
</div>
|
|
''')
|
|
|
|
|
|
# ============ Renderers (L1 servers) ============
|
|
|
|
@app.get("/renderers", response_class=HTMLResponse)
|
|
async def renderers_page(request: Request):
|
|
"""Page to manage L1 renderer attachments."""
|
|
username = get_user_from_cookie(request)
|
|
|
|
if not username:
|
|
content = '''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Renderers</h2>
|
|
<p class="text-gray-400">Log in to manage your renderer connections.</p>
|
|
'''
|
|
return HTMLResponse(base_html("Renderers", content))
|
|
|
|
# Get user's attached renderers
|
|
attached = await db.get_user_renderers(username)
|
|
from urllib.parse import quote
|
|
|
|
# Build renderer list
|
|
rows = []
|
|
for l1_url in L1_SERVERS:
|
|
is_attached = l1_url in attached
|
|
# Extract display name from URL
|
|
display_name = l1_url.replace("https://", "").replace("http://", "")
|
|
|
|
if is_attached:
|
|
status = '<span class="px-2 py-1 bg-green-600 text-white text-xs font-medium rounded-full">Attached</span>'
|
|
action = f'''
|
|
<a href="{l1_url}" target="_blank" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors">
|
|
Open
|
|
</a>
|
|
<button hx-post="/renderers/detach" hx-vals='{{"l1_url": "{l1_url}"}}' hx-target="#renderer-{l1_url.replace("://", "-").replace("/", "-").replace(".", "-")}" hx-swap="outerHTML"
|
|
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded transition-colors ml-2">
|
|
Detach
|
|
</button>
|
|
'''
|
|
else:
|
|
status = '<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>'
|
|
# Attach via endpoint that creates scoped token (not raw token in URL)
|
|
attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
|
|
action = f'''
|
|
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
|
|
Attach
|
|
</a>
|
|
'''
|
|
|
|
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
|
|
rows.append(f'''
|
|
<div id="renderer-{row_id}" class="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
|
|
<div>
|
|
<div class="font-medium text-white">{display_name}</div>
|
|
<div class="text-sm text-gray-400">{l1_url}</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{status}
|
|
{action}
|
|
</div>
|
|
</div>
|
|
''')
|
|
|
|
content = f'''
|
|
<h2 class="text-xl font-semibold text-white mb-4">Renderers</h2>
|
|
<p class="text-gray-400 mb-6">Connect to L1 rendering servers. After attaching, you can run effects and manage media on that renderer.</p>
|
|
<div class="space-y-3">
|
|
{"".join(rows) if rows else '<p class="text-gray-500">No renderers configured.</p>'}
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html("Renderers", content, username))
|
|
|
|
|
|
@app.get("/renderers/attach")
|
|
async def attach_renderer_redirect(request: Request, l1_url: str):
|
|
"""Create a scoped token and redirect to L1 for attachment."""
|
|
username = get_user_from_cookie(request)
|
|
if not username:
|
|
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
# Verify L1 is in our allowed list
|
|
l1_normalized = l1_url.rstrip("/")
|
|
if not any(l1_normalized == s.rstrip("/") for s in L1_SERVERS):
|
|
raise HTTPException(403, f"L1 server not authorized: {l1_url}")
|
|
|
|
# Create a scoped token that only works for this specific L1
|
|
scoped_token = create_access_token(
|
|
username,
|
|
l2_server=f"https://{DOMAIN}",
|
|
l1_server=l1_normalized
|
|
)
|
|
|
|
# Redirect to L1 with scoped token
|
|
redirect_url = f"{l1_normalized}/auth?auth_token={scoped_token.access_token}"
|
|
return RedirectResponse(url=redirect_url, status_code=302)
|
|
|
|
|
|
@app.post("/renderers/detach", response_class=HTMLResponse)
|
|
async def detach_renderer(request: Request):
|
|
"""Detach from an L1 renderer."""
|
|
username = get_user_from_cookie(request)
|
|
if not username:
|
|
return HTMLResponse('<div class="text-red-400">Not logged in</div>')
|
|
|
|
form = await request.form()
|
|
l1_url = form.get("l1_url", "")
|
|
|
|
await db.detach_renderer(username, l1_url)
|
|
|
|
# Return updated row with link to attach endpoint (not raw token)
|
|
display_name = l1_url.replace("https://", "").replace("http://", "")
|
|
from urllib.parse import quote
|
|
attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
|
|
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
|
|
|
|
return HTMLResponse(f'''
|
|
<div id="renderer-{row_id}" class="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
|
|
<div>
|
|
<div class="font-medium text-white">{display_name}</div>
|
|
<div class="text-sm text-gray-400">{l1_url}</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>
|
|
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
|
|
Attach
|
|
</a>
|
|
</div>
|
|
</div>
|
|
''')
|
|
|
|
|
|
# ============ User Storage ============
|
|
|
|
import storage_providers
|
|
|
|
|
|
@app.get("/storage")
|
|
async def list_storage(request: Request, user: User = Depends(get_optional_user)):
|
|
"""List user's storage providers. HTML for browsers (default), JSON only if explicitly requested."""
|
|
# Check if JSON explicitly requested
|
|
accept = request.headers.get("accept", "")
|
|
wants_json = "application/json" in accept and "text/html" not in accept
|
|
|
|
# For browser sessions, also check cookie authentication
|
|
username = user.username if user else get_user_from_cookie(request)
|
|
|
|
if not username:
|
|
if wants_json:
|
|
raise HTTPException(401, "Authentication required")
|
|
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
storages = await db.get_user_storage(username)
|
|
|
|
# Add usage stats to each storage
|
|
for storage in storages:
|
|
usage = await db.get_storage_usage(storage["id"])
|
|
storage["used_bytes"] = usage["used_bytes"]
|
|
storage["pin_count"] = usage["pin_count"]
|
|
storage["donated_gb"] = storage["capacity_gb"] // 2
|
|
# Mask sensitive config keys for display
|
|
if storage.get("config"):
|
|
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
|
masked = {}
|
|
for k, v in config.items():
|
|
if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower():
|
|
masked[k] = v[:4] + "..." + v[-4:] if len(str(v)) > 8 else "****"
|
|
else:
|
|
masked[k] = v
|
|
storage["config_display"] = masked
|
|
|
|
if wants_json:
|
|
return {"storages": storages}
|
|
|
|
# Default to HTML for browsers
|
|
return await ui_storage_page(username, storages, request)
|
|
|
|
|
|
@app.post("/storage")
|
|
async def add_storage(req: AddStorageRequest, user: User = Depends(get_required_user)):
|
|
"""Add a storage provider."""
|
|
# Validate provider type
|
|
valid_types = ["pinata", "web3storage", "nftstorage", "infura", "filebase", "storj", "local"]
|
|
if req.provider_type not in valid_types:
|
|
raise HTTPException(400, f"Invalid provider type: {req.provider_type}")
|
|
|
|
# Test the provider connection before saving
|
|
provider = storage_providers.create_provider(req.provider_type, {
|
|
**req.config,
|
|
"capacity_gb": req.capacity_gb
|
|
})
|
|
if not provider:
|
|
raise HTTPException(400, "Failed to create provider with given config")
|
|
|
|
success, message = await provider.test_connection()
|
|
if not success:
|
|
raise HTTPException(400, f"Provider connection failed: {message}")
|
|
|
|
# Save to database
|
|
provider_name = req.provider_name or f"{req.provider_type}-{user.username}"
|
|
storage_id = await db.add_user_storage(
|
|
username=user.username,
|
|
provider_type=req.provider_type,
|
|
provider_name=provider_name,
|
|
config=req.config,
|
|
capacity_gb=req.capacity_gb
|
|
)
|
|
|
|
if not storage_id:
|
|
raise HTTPException(500, "Failed to save storage provider")
|
|
|
|
return {"id": storage_id, "message": f"Storage provider added: {provider_name}"}
|
|
|
|
|
|
@app.post("/storage/add")
|
|
async def add_storage_form(
|
|
request: Request,
|
|
provider_type: str = Form(...),
|
|
provider_name: Optional[str] = Form(None),
|
|
description: Optional[str] = Form(None),
|
|
capacity_gb: int = Form(5),
|
|
api_key: Optional[str] = Form(None),
|
|
secret_key: Optional[str] = Form(None),
|
|
api_token: Optional[str] = Form(None),
|
|
project_id: Optional[str] = Form(None),
|
|
project_secret: Optional[str] = Form(None),
|
|
access_key: Optional[str] = Form(None),
|
|
bucket: Optional[str] = Form(None),
|
|
path: Optional[str] = Form(None),
|
|
):
|
|
"""Add a storage provider via HTML form (cookie auth)."""
|
|
username = get_user_from_cookie(request)
|
|
if not username:
|
|
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
|
|
|
|
# Validate provider type
|
|
valid_types = ["pinata", "web3storage", "nftstorage", "infura", "filebase", "storj", "local"]
|
|
if provider_type not in valid_types:
|
|
return HTMLResponse(f'<div class="text-red-400">Invalid provider type: {provider_type}</div>')
|
|
|
|
# Build config based on provider type
|
|
config = {}
|
|
if provider_type == "pinata":
|
|
if not api_key or not secret_key:
|
|
return HTMLResponse('<div class="text-red-400">Pinata requires API Key and Secret Key</div>')
|
|
config = {"api_key": api_key, "secret_key": secret_key}
|
|
elif provider_type == "web3storage":
|
|
if not api_token:
|
|
return HTMLResponse('<div class="text-red-400">web3.storage requires API Token</div>')
|
|
config = {"api_token": api_token}
|
|
elif provider_type == "nftstorage":
|
|
if not api_token:
|
|
return HTMLResponse('<div class="text-red-400">NFT.Storage requires API Token</div>')
|
|
config = {"api_token": api_token}
|
|
elif provider_type == "infura":
|
|
if not project_id or not project_secret:
|
|
return HTMLResponse('<div class="text-red-400">Infura requires Project ID and Project Secret</div>')
|
|
config = {"project_id": project_id, "project_secret": project_secret}
|
|
elif provider_type == "filebase":
|
|
if not access_key or not secret_key or not bucket:
|
|
return HTMLResponse('<div class="text-red-400">Filebase requires Access Key, Secret Key, and Bucket</div>')
|
|
config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}
|
|
elif provider_type == "storj":
|
|
if not access_key or not secret_key or not bucket:
|
|
return HTMLResponse('<div class="text-red-400">Storj requires Access Key, Secret Key, and Bucket</div>')
|
|
config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}
|
|
elif provider_type == "local":
|
|
if not path:
|
|
return HTMLResponse('<div class="text-red-400">Local storage requires a path</div>')
|
|
config = {"path": path}
|
|
|
|
# Test the provider connection before saving
|
|
provider = storage_providers.create_provider(provider_type, {
|
|
**config,
|
|
"capacity_gb": capacity_gb
|
|
})
|
|
if not provider:
|
|
return HTMLResponse('<div class="text-red-400">Failed to create provider with given config</div>')
|
|
|
|
success, message = await provider.test_connection()
|
|
if not success:
|
|
return HTMLResponse(f'<div class="text-red-400">Provider connection failed: {message}</div>')
|
|
|
|
# Save to database
|
|
name = provider_name or f"{provider_type}-{username}-{len(await db.get_user_storage_by_type(username, provider_type)) + 1}"
|
|
storage_id = await db.add_user_storage(
|
|
username=username,
|
|
provider_type=provider_type,
|
|
provider_name=name,
|
|
config=config,
|
|
capacity_gb=capacity_gb,
|
|
description=description
|
|
)
|
|
|
|
if not storage_id:
|
|
return HTMLResponse('<div class="text-red-400">Failed to save storage provider</div>')
|
|
|
|
return HTMLResponse(f'''
|
|
<div class="text-green-400 mb-2">Storage provider "{name}" added successfully!</div>
|
|
<script>setTimeout(() => window.location.href = '/storage/type/{provider_type}', 1500);</script>
|
|
''')
|
|
|
|
|
|
@app.get("/storage/{storage_id}")
|
|
async def get_storage(storage_id: int, user: User = Depends(get_required_user)):
|
|
"""Get a specific storage provider."""
|
|
storage = await db.get_storage_by_id(storage_id)
|
|
if not storage:
|
|
raise HTTPException(404, "Storage provider not found")
|
|
if storage["username"] != user.username:
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
usage = await db.get_storage_usage(storage_id)
|
|
storage["used_bytes"] = usage["used_bytes"]
|
|
storage["pin_count"] = usage["pin_count"]
|
|
storage["donated_gb"] = storage["capacity_gb"] // 2
|
|
|
|
return storage
|
|
|
|
|
|
@app.patch("/storage/{storage_id}")
|
|
async def update_storage(storage_id: int, req: UpdateStorageRequest, user: User = Depends(get_required_user)):
|
|
"""Update a storage provider."""
|
|
storage = await db.get_storage_by_id(storage_id)
|
|
if not storage:
|
|
raise HTTPException(404, "Storage provider not found")
|
|
if storage["username"] != user.username:
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
# If updating config, test the new connection
|
|
if req.config:
|
|
existing_config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
|
new_config = {**existing_config, **req.config}
|
|
provider = storage_providers.create_provider(storage["provider_type"], {
|
|
**new_config,
|
|
"capacity_gb": req.capacity_gb or storage["capacity_gb"]
|
|
})
|
|
if provider:
|
|
success, message = await provider.test_connection()
|
|
if not success:
|
|
raise HTTPException(400, f"Provider connection failed: {message}")
|
|
|
|
success = await db.update_user_storage(
|
|
storage_id,
|
|
config=req.config,
|
|
capacity_gb=req.capacity_gb,
|
|
is_active=req.is_active
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(500, "Failed to update storage provider")
|
|
|
|
return {"message": "Storage provider updated"}
|
|
|
|
|
|
@app.delete("/storage/{storage_id}")
|
|
async def remove_storage(storage_id: int, request: Request, user: User = Depends(get_optional_user)):
|
|
"""Remove a storage provider."""
|
|
# Support both Bearer token and cookie auth
|
|
username = user.username if user else get_user_from_cookie(request)
|
|
if not username:
|
|
raise HTTPException(401, "Not authenticated")
|
|
|
|
storage = await db.get_storage_by_id(storage_id)
|
|
if not storage:
|
|
raise HTTPException(404, "Storage provider not found")
|
|
if storage["username"] != username:
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
success = await db.remove_user_storage(storage_id)
|
|
if not success:
|
|
raise HTTPException(500, "Failed to remove storage provider")
|
|
|
|
# Return empty string for HTMX to remove the element
|
|
if wants_html(request):
|
|
return HTMLResponse("")
|
|
|
|
return {"message": "Storage provider removed"}
|
|
|
|
|
|
@app.post("/storage/{storage_id}/test")
|
|
async def test_storage(storage_id: int, request: Request, user: User = Depends(get_optional_user)):
|
|
"""Test storage provider connectivity."""
|
|
# Support both Bearer token and cookie auth
|
|
username = user.username if user else get_user_from_cookie(request)
|
|
if not username:
|
|
if wants_html(request):
|
|
return HTMLResponse('<span class="text-red-400">Not authenticated</span>', status_code=401)
|
|
raise HTTPException(401, "Not authenticated")
|
|
|
|
storage = await db.get_storage_by_id(storage_id)
|
|
if not storage:
|
|
if wants_html(request):
|
|
return HTMLResponse('<span class="text-red-400">Storage not found</span>', status_code=404)
|
|
raise HTTPException(404, "Storage provider not found")
|
|
if storage["username"] != username:
|
|
if wants_html(request):
|
|
return HTMLResponse('<span class="text-red-400">Not authorized</span>', status_code=403)
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
|
provider = storage_providers.create_provider(storage["provider_type"], {
|
|
**config,
|
|
"capacity_gb": storage["capacity_gb"]
|
|
})
|
|
|
|
if not provider:
|
|
if wants_html(request):
|
|
return HTMLResponse('<span class="text-red-400">Failed to create provider</span>')
|
|
raise HTTPException(500, "Failed to create provider")
|
|
|
|
success, message = await provider.test_connection()
|
|
|
|
if wants_html(request):
|
|
if success:
|
|
return HTMLResponse(f'<span class="text-green-400">{message}</span>')
|
|
return HTMLResponse(f'<span class="text-red-400">{message}</span>')
|
|
|
|
return {"success": success, "message": message}
|
|
|
|
|
|
STORAGE_PROVIDERS_INFO = {
|
|
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
|
|
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
|
|
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
|
|
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
|
|
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
|
|
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
|
|
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
|
|
}
|
|
|
|
|
|
async def ui_storage_page(username: str, storages: list, request: Request) -> HTMLResponse:
|
|
"""Render main storage settings page showing provider types."""
|
|
# Count configs per type
|
|
type_counts = {}
|
|
for s in storages:
|
|
pt = s["provider_type"]
|
|
type_counts[pt] = type_counts.get(pt, 0) + 1
|
|
|
|
# Build provider type cards
|
|
cards = ""
|
|
for ptype, info in STORAGE_PROVIDERS_INFO.items():
|
|
count = type_counts.get(ptype, 0)
|
|
count_badge = f'<span class="ml-2 px-2 py-0.5 bg-{info["color"]}-600 text-white text-xs rounded-full">{count}</span>' if count > 0 else ""
|
|
cards += f'''
|
|
<a href="/storage/type/{ptype}"
|
|
class="p-4 bg-dark-800 hover:bg-dark-600 border-2 border-{info["color"]}-600 hover:border-{info["color"]}-400 rounded-lg text-left transition-colors block">
|
|
<div class="flex items-center">
|
|
<span class="font-semibold text-white">{info["name"]}</span>
|
|
{count_badge}
|
|
</div>
|
|
<div class="text-sm text-gray-400 mt-1">{info["desc"]}</div>
|
|
</a>
|
|
'''
|
|
|
|
# Total stats
|
|
total_capacity = sum(s["capacity_gb"] for s in storages)
|
|
total_used = sum(s.get("used_bytes", 0) for s in storages)
|
|
total_pins = sum(s.get("pin_count", 0) for s in storages)
|
|
|
|
content = f'''
|
|
<div class="max-w-4xl mx-auto">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-bold text-white">Storage Providers</h1>
|
|
</div>
|
|
|
|
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
|
<p class="text-gray-400 mb-4">
|
|
Attach your own storage to help power the network. 50% of your capacity is donated to store
|
|
shared content, making popular assets more resilient.
|
|
</p>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 text-center">
|
|
<div class="bg-dark-600 rounded-lg p-3">
|
|
<div class="text-2xl font-bold text-white">{len(storages)}</div>
|
|
<div class="text-sm text-gray-400">Total Configs</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-3">
|
|
<div class="text-2xl font-bold text-white">{total_capacity} GB</div>
|
|
<div class="text-sm text-gray-400">Total Capacity</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-3">
|
|
<div class="text-2xl font-bold text-white">{total_used / (1024**3):.1f} GB</div>
|
|
<div class="text-sm text-gray-400">Used</div>
|
|
</div>
|
|
<div class="bg-dark-600 rounded-lg p-3">
|
|
<div class="text-2xl font-bold text-white">{total_pins}</div>
|
|
<div class="text-sm text-gray-400">Total Pins</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-white mb-4">Select Provider Type</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{cards}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html("Storage", content, username))
|
|
|
|
|
|
@app.get("/storage/type/{provider_type}")
|
|
async def storage_type_page(provider_type: str, request: Request, user: User = Depends(get_optional_user)):
|
|
"""Page for managing storage configs of a specific type."""
|
|
username = user.username if user else get_user_from_cookie(request)
|
|
if not username:
|
|
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
if provider_type not in STORAGE_PROVIDERS_INFO:
|
|
raise HTTPException(404, "Invalid provider type")
|
|
|
|
storages = await db.get_user_storage_by_type(username, provider_type)
|
|
|
|
# Add usage stats
|
|
for storage in storages:
|
|
usage = await db.get_storage_usage(storage["id"])
|
|
storage["used_bytes"] = usage["used_bytes"]
|
|
storage["pin_count"] = usage["pin_count"]
|
|
# Mask sensitive config keys
|
|
if storage.get("config"):
|
|
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
|
masked = {}
|
|
for k, v in config.items():
|
|
if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower():
|
|
masked[k] = v[:4] + "..." + v[-4:] if len(str(v)) > 8 else "****"
|
|
else:
|
|
masked[k] = v
|
|
storage["config_display"] = masked
|
|
|
|
info = STORAGE_PROVIDERS_INFO[provider_type]
|
|
return await ui_storage_type_page(username, provider_type, info, storages, request)
|
|
|
|
|
|
async def ui_storage_type_page(username: str, provider_type: str, info: dict, storages: list, request: Request) -> HTMLResponse:
|
|
"""Render per-type storage management page."""
|
|
|
|
def format_bytes(b):
|
|
if b > 1024**3:
|
|
return f"{b / 1024**3:.1f} GB"
|
|
if b > 1024**2:
|
|
return f"{b / 1024**2:.1f} MB"
|
|
if b > 1024:
|
|
return f"{b / 1024:.1f} KB"
|
|
return f"{b} bytes"
|
|
|
|
# Build storage rows
|
|
storage_rows = ""
|
|
for s in storages:
|
|
status_class = "bg-green-600" if s["is_active"] else "bg-gray-600"
|
|
status_text = "Active" if s["is_active"] else "Inactive"
|
|
config_display = s.get("config_display", {})
|
|
config_html = ", ".join(f"{k}: {v}" for k, v in config_display.items() if k != "path")
|
|
desc = s.get("description") or ""
|
|
desc_html = f'<div class="text-sm text-gray-300 mt-1">{desc}</div>' if desc else ""
|
|
|
|
storage_rows += f'''
|
|
<div class="p-4 bg-dark-600 rounded-lg mb-4" id="storage-{s["id"]}">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<div>
|
|
<h3 class="font-semibold text-white">{s["provider_name"] or provider_type}</h3>
|
|
{desc_html}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="px-2 py-1 {status_class} text-white text-xs rounded-full">{status_text}</span>
|
|
<button hx-post="/storage/{s["id"]}/test" hx-target="#test-result-{s["id"]}" hx-swap="innerHTML"
|
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded">Test</button>
|
|
<button hx-delete="/storage/{s["id"]}" hx-confirm="Remove this storage config?"
|
|
hx-target="#storage-{s["id"]}" hx-swap="outerHTML"
|
|
class="px-2 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded">Remove</button>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mb-3">
|
|
<div>
|
|
<div class="text-gray-400">Capacity</div>
|
|
<div class="text-white">{s["capacity_gb"]} GB</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-gray-400">Donated</div>
|
|
<div class="text-white">{s["capacity_gb"] // 2} GB</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-gray-400">Used</div>
|
|
<div class="text-white">{format_bytes(s["used_bytes"])}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-gray-400">Pins</div>
|
|
<div class="text-white">{s["pin_count"]}</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-gray-500">{config_html}</div>
|
|
<div id="test-result-{s["id"]}" class="mt-2 text-sm"></div>
|
|
</div>
|
|
'''
|
|
|
|
if not storages:
|
|
storage_rows = f'<p class="text-gray-400 text-center py-8">No {info["name"]} configs yet. Add one below.</p>'
|
|
|
|
# Build form fields based on provider type
|
|
form_fields = ""
|
|
if provider_type == "pinata":
|
|
form_fields = '''
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">API Key *</label>
|
|
<input type="text" name="api_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Secret Key *</label>
|
|
<input type="password" name="secret_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
'''
|
|
elif provider_type in ("web3storage", "nftstorage"):
|
|
form_fields = '''
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">API Token *</label>
|
|
<input type="password" name="api_token" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
'''
|
|
elif provider_type == "infura":
|
|
form_fields = '''
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Project ID *</label>
|
|
<input type="text" name="project_id" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Project Secret *</label>
|
|
<input type="password" name="project_secret" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
'''
|
|
elif provider_type in ("filebase", "storj"):
|
|
form_fields = '''
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Access Key *</label>
|
|
<input type="text" name="access_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Secret Key *</label>
|
|
<input type="password" name="secret_key" required class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Bucket Name *</label>
|
|
<input type="text" name="bucket" required placeholder="my-bucket" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
'''
|
|
elif provider_type == "local":
|
|
form_fields = '''
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Storage Path *</label>
|
|
<input type="text" name="path" required placeholder="/path/to/storage" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
'''
|
|
|
|
content = f'''
|
|
<div class="max-w-4xl mx-auto">
|
|
<div class="flex items-center gap-4 mb-6">
|
|
<a href="/storage" class="text-gray-400 hover:text-white">← Back</a>
|
|
<h1 class="text-2xl font-bold text-white">{info["name"]} Storage</h1>
|
|
</div>
|
|
|
|
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">Your {info["name"]} Configs</h2>
|
|
<div id="storage-list">
|
|
{storage_rows}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-dark-700 rounded-lg p-6">
|
|
<h2 class="text-lg font-semibold text-white mb-4">Add New {info["name"]} Config</h2>
|
|
<form hx-post="/storage/add" hx-target="#add-result" hx-swap="innerHTML" class="space-y-4">
|
|
<input type="hidden" name="provider_type" value="{provider_type}">
|
|
|
|
{form_fields}
|
|
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Name (optional)</label>
|
|
<input type="text" name="provider_name" placeholder="My {info["name"]}" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Description (optional)</label>
|
|
<input type="text" name="description" placeholder="e.g., Work account, Personal, etc." class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Capacity (GB)</label>
|
|
<input type="number" name="capacity_gb" value="5" min="1" class="w-full px-3 py-2 bg-dark-700 border border-dark-500 rounded text-white">
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="px-4 py-2 bg-{info["color"]}-600 hover:bg-{info["color"]}-700 text-white rounded">Add {info["name"]}</button>
|
|
</div>
|
|
</form>
|
|
<div id="add-result" class="mt-4"></div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
return HTMLResponse(base_html(f"{info['name']} Storage", content, username))
|
|
|
|
|
|
# ============ Client Download ============
|
|
|
|
CLIENT_TARBALL = Path(__file__).parent / "artdag-client.tar.gz"
|
|
|
|
@app.get("/download/client")
|
|
async def download_client():
|
|
"""Download the Art DAG CLI client."""
|
|
if not CLIENT_TARBALL.exists():
|
|
raise HTTPException(404, "Client package not found")
|
|
return FileResponse(
|
|
CLIENT_TARBALL,
|
|
media_type="application/gzip",
|
|
filename="artdag-client.tar.gz"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Help / Documentation Routes
|
|
# ============================================================================
|
|
|
|
L2_DOCS_DIR = Path(__file__).parent
|
|
COMMON_DOCS_DIR = Path(__file__).parent.parent / "common"
|
|
|
|
L2_DOCS_MAP = {
|
|
"l2": ("L2 Server (ActivityPub)", L2_DOCS_DIR / "README.md"),
|
|
"common": ("Common Library", COMMON_DOCS_DIR / "README.md"),
|
|
}
|
|
|
|
|
|
@app.get("/help", response_class=HTMLResponse)
|
|
async def help_index(request: Request):
|
|
"""Documentation index page."""
|
|
username = get_user_from_cookie(request)
|
|
|
|
# Build doc links
|
|
doc_links = ""
|
|
for key, (title, path) in L2_DOCS_MAP.items():
|
|
if path.exists():
|
|
doc_links += f'''
|
|
<a href="/help/{key}" class="block p-6 bg-dark-600 rounded-lg hover:bg-dark-500 transition-colors">
|
|
<h2 class="text-xl font-semibold text-white mb-2">{title}</h2>
|
|
<p class="text-gray-400">View documentation</p>
|
|
</a>
|
|
'''
|
|
|
|
content = f'''
|
|
<div class="max-w-4xl mx-auto">
|
|
<h1 class="text-2xl font-bold text-white mb-6">Documentation</h1>
|
|
<div class="grid gap-4">
|
|
{doc_links}
|
|
</div>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html("Help", content, username))
|
|
|
|
|
|
@app.get("/help/{doc_name}", response_class=HTMLResponse)
|
|
async def help_page(doc_name: str, request: Request):
|
|
"""Render a README as HTML."""
|
|
if doc_name not in L2_DOCS_MAP:
|
|
raise HTTPException(404, f"Documentation '{doc_name}' not found")
|
|
|
|
title, doc_path = L2_DOCS_MAP[doc_name]
|
|
if not doc_path.exists():
|
|
raise HTTPException(404, f"Documentation file not found")
|
|
|
|
username = get_user_from_cookie(request)
|
|
|
|
# Read and render markdown
|
|
md_content = doc_path.read_text()
|
|
html_content = markdown.markdown(md_content, extensions=['tables', 'fenced_code'])
|
|
|
|
content = f'''
|
|
<div class="max-w-4xl mx-auto">
|
|
<div class="mb-4">
|
|
<a href="/help" class="text-blue-400 hover:underline">← Back to Help</a>
|
|
</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">
|
|
{html_content}
|
|
</div>
|
|
</div>
|
|
'''
|
|
return HTMLResponse(base_html(title, content, username))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run("server:app", host="0.0.0.0", port=8200, workers=4)
|