Files
activity-pub/server.py
gilesb edc216c81f Store and display full provenance including effect_url
- record-run now stores effect_url, effects_commit, infrastructure in provenance
- Asset detail uses stored effect_url (with fallback for older records)
- Shows effects commit hash under effect button
- Shows infrastructure info (software/hardware) if available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:29:40 +00:00

1521 lines
58 KiB
Python

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