Migrate to PostgreSQL database, consolidate routes, improve home page

- Add PostgreSQL with asyncpg for persistent storage
- Create db.py module with async database operations
- Create migrate.py script to migrate JSON data to PostgreSQL
- Update docker-compose.yml with PostgreSQL service
- Home page now shows README with styled headings
- Remove /ui prefix routes, use content negotiation on main routes
- Add /activities/{idx} as canonical route (with /activity redirect)
- Update /assets/{name} to support HTML and JSON responses
- Convert auth.py to use async database operations
- RSA keys still stored as files in $ARTDAG_DATA/keys/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-08 00:22:21 +00:00
parent cb848aacbe
commit a6e83c72bd
6 changed files with 912 additions and 405 deletions

499
server.py
View File

@@ -13,6 +13,7 @@ import hashlib
import json
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -25,10 +26,11 @@ 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_current_user, load_users
verify_token, get_current_user
)
# Configuration
@@ -47,10 +49,20 @@ 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"
version="0.1.0",
lifespan=lifespan
)
@@ -115,39 +127,17 @@ class UpdateAssetRequest(BaseModel):
origin: Optional[dict] = None
# ============ Storage ============
# ============ Storage (Database) ============
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": {}}
async def load_registry() -> dict:
"""Load registry from database."""
assets = await db.get_all_assets()
return {"version": "1.0", "assets": 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)
async def load_activities() -> list:
"""Load activities from database."""
return await db.get_all_activities()
def load_actor(username: str) -> dict:
@@ -175,26 +165,14 @@ def load_actor(username: str) -> dict:
return actor
def user_exists(username: str) -> bool:
async def user_exists(username: str) -> bool:
"""Check if a user exists."""
users = load_users(DATA_DIR)
return username in users
return await db.user_exists(username)
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)
async def load_followers() -> list:
"""Load followers list from database."""
return await db.get_all_followers()
# ============ Signing ============
@@ -300,48 +278,7 @@ def wants_html(request: Request) -> bool:
return "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
# ============ 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))
# ============ Auth UI Endpoints ============
@app.get("/login", response_class=HTMLResponse)
async def ui_login_page(request: Request):
@@ -388,7 +325,7 @@ async def ui_login_submit(request: Request):
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)
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>')
@@ -472,7 +409,7 @@ async def ui_register_submit(request: Request):
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)
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>')
@@ -500,121 +437,12 @@ async def logout():
return response
@app.get("/ui/assets", 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", {})
# ============ HTML Rendering Helpers ============
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="/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="/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">{hash_short}</code></td>
<td class="py-3 px-4 text-gray-400">{", ".join(asset.get("tags", []))}</td>
</tr>
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Registry ({len(assets)} assets)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Name</th>
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
<th class="py-3 px-4 font-medium text-gray-300">Owner</th>
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
<th class="py-3 px-4 font-medium text-gray-300">Tags</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Registry", content, username))
@app.get("/ui/activities", response_class=HTMLResponse)
async def ui_activities_page(request: Request):
"""Activities page showing all signed activities."""
username = get_user_from_cookie(request)
activities = load_activities()
if not activities:
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Activities</h2>
<p class="text-gray-400">No activities yet.</p>
'''
else:
rows = ""
for i, activity in enumerate(reversed(activities)):
# Index from end since we reversed
activity_index = len(activities) - 1 - i
obj = activity.get("object_data", {})
activity_type = activity.get("activity_type", "")
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
actor_id = activity.get("actor_id", "")
actor_name = actor_id.split("/")[-1] if actor_id else "unknown"
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs font-medium rounded">{activity_type}</span></td>
<td class="py-3 px-4 text-white">{obj.get("name", "Untitled")}</td>
<td class="py-3 px-4">
<a href="/users/{actor_name}" class="text-gray-400 hover:text-blue-300">{actor_name}</a>
</td>
<td class="py-3 px-4 text-gray-400">{activity.get("published", "")[:10]}</td>
<td class="py-3 px-4">
<a href="/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
</td>
</tr>
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Activities ({len(activities)} total)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
<th class="py-3 px-4 font-medium text-gray-300">Object</th>
<th class="py-3 px-4 font-medium text-gray-300">Actor</th>
<th class="py-3 px-4 font-medium text-gray-300">Published</th>
<th class="py-3 px-4 font-medium text-gray-300"></th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Activities", content, username))
@app.get("/ui/activity/{activity_index}", response_class=HTMLResponse)
async def ui_activity_detail(activity_index: int, request: Request):
"""Activity detail page with full content display."""
"""Activity detail page with full content display. Helper function for HTML rendering."""
username = get_user_from_cookie(request)
activities = load_activities()
activities = await load_activities()
if activity_index < 0 or activity_index >= len(activities):
content = '''
@@ -646,7 +474,7 @@ async def ui_activity_detail(activity_index: int, request: Request):
# Fallback: if activity doesn't have provenance, look up the asset from registry
if not provenance or not origin:
registry = load_registry()
registry = await load_registry()
assets = registry.get("assets", {})
# Find asset by content_hash or name
for asset_name, asset_data in assets.items():
@@ -869,54 +697,10 @@ async def ui_activity_detail(activity_index: int, request: Request):
return HTMLResponse(base_html(f"Activity: {obj_name}", content, username))
@app.get("/ui/users", response_class=HTMLResponse)
async def ui_users_page(request: Request):
"""Users page showing all registered users."""
current_user = get_user_from_cookie(request)
users = load_users(DATA_DIR)
if not users:
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Users</h2>
<p class="text-gray-400">No users registered yet.</p>
'''
else:
rows = ""
for uname, user_data in sorted(users.items()):
webfinger = f"@{uname}@{DOMAIN}"
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4"><a href="/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">{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."""
"""Asset detail page with content preview and provenance. Helper function for HTML rendering."""
username = get_user_from_cookie(request)
registry = load_registry()
registry = await load_registry()
assets = registry.get("assets", {})
if name not in assets:
@@ -1159,12 +943,11 @@ async def ui_asset_detail(name: str, request: Request):
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."""
"""User detail page showing their published assets. Helper function for HTML rendering."""
current_user = get_user_from_cookie(request)
if not user_exists(username):
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>
@@ -1173,12 +956,12 @@ async def ui_user_detail(username: str, request: Request):
return HTMLResponse(base_html("User Not Found", content, current_user))
# Get user's assets
registry = load_registry()
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 = load_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]
@@ -1194,7 +977,7 @@ async def ui_user_detail(username: str, request: Request):
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4">
<a href="/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
<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>
@@ -1261,9 +1044,9 @@ async def ui_user_detail(username: str, request: Request):
@app.get("/")
async def root(request: Request):
"""Server info. HTML shows home page with counts, JSON returns stats."""
registry = load_registry()
activities = load_activities()
users = load_users(DATA_DIR)
registry = await load_registry()
activities = await load_activities()
users = await db.get_all_users()
assets_count = len(registry.get("assets", {}))
activities_count = len(activities)
@@ -1271,21 +1054,34 @@ async def root(request: Request):
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-6 md:grid-cols-3">
<a href="/assets" class="block bg-dark-600 rounded-lg p-6 hover:bg-dark-500 transition-colors">
<div class="text-4xl font-bold text-white mb-2">{assets_count}</div>
<div class="text-gray-400">Assets</div>
<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 hover:bg-dark-500 transition-colors">
<div class="text-4xl font-bold text-white mb-2">{activities_count}</div>
<div class="text-gray-400">Activities</div>
<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 hover:bg-dark-500 transition-colors">
<div class="text-4xl font-bold text-white mb-2">{users_count}</div>
<div class="text-gray-400">Users</div>
<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))
@@ -1310,7 +1106,7 @@ async def get_optional_user(
"""Get current user if authenticated, None otherwise."""
if not credentials:
return None
return get_current_user(DATA_DIR, credentials.credentials)
return await get_current_user(DATA_DIR, credentials.credentials)
async def get_required_user(
@@ -1319,7 +1115,7 @@ async def get_required_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)
user = await get_current_user(DATA_DIR, credentials.credentials)
if not user:
raise HTTPException(401, "Invalid token")
return user
@@ -1329,7 +1125,7 @@ async def get_required_user(
async def register(req: UserCreate):
"""Register a new user."""
try:
user = create_user(DATA_DIR, req.username, req.password, req.email)
user = await create_user(DATA_DIR, req.username, req.password, req.email)
except ValueError as e:
raise HTTPException(400, str(e))
@@ -1339,7 +1135,7 @@ async def register(req: UserCreate):
@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)
user = await authenticate_user(DATA_DIR, req.username, req.password)
if not user:
raise HTTPException(401, "Invalid username or password")
@@ -1385,7 +1181,7 @@ async def webfinger(resource: str):
if domain != DOMAIN:
raise HTTPException(404, f"Unknown domain: {domain}")
if not user_exists(username):
if not await user_exists(username):
raise HTTPException(404, f"Unknown user: {username}")
return JSONResponse(
@@ -1406,7 +1202,7 @@ async def webfinger(resource: str):
@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(load_users(DATA_DIR).items())
all_users = list((await db.get_all_users()).items())
total = len(all_users)
# Sort by username
@@ -1498,7 +1294,7 @@ async def get_users_list(request: Request, page: int = 1, limit: int = 20):
@app.get("/users/{username}")
async def get_actor(username: str, request: Request):
"""Get actor profile for any registered user. Content negotiation: HTML for browsers, JSON for APIs."""
if not user_exists(username):
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>
@@ -1529,11 +1325,11 @@ async def get_actor(username: str, request: Request):
@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):
if not await user_exists(username):
raise HTTPException(404, f"Unknown user: {username}")
# Filter activities by this user's actor_id
all_activities = load_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]
@@ -1565,7 +1361,7 @@ async def get_outbox(username: str, page: bool = False):
@app.post("/users/{username}/inbox")
async def post_inbox(username: str, request: Request):
"""Receive activities from other servers."""
if not user_exists(username):
if not await user_exists(username):
raise HTTPException(404, f"Unknown user: {username}")
body = await request.json()
@@ -1573,12 +1369,9 @@ async def post_inbox(username: str, request: Request):
# 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)
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
@@ -1591,11 +1384,11 @@ async def post_inbox(username: str, request: Request):
@app.get("/users/{username}/followers")
async def get_followers(username: str):
"""Get actor's followers."""
if not user_exists(username):
if not await user_exists(username):
raise HTTPException(404, f"Unknown user: {username}")
# TODO: Per-user followers - for now use global followers
followers = load_followers()
followers = await load_followers()
return JSONResponse(
content={
@@ -1614,7 +1407,7 @@ async def get_followers(username: str):
@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 = load_registry()
registry = await load_registry()
all_assets = list(registry.get("assets", {}).items())
total = len(all_assets)
@@ -1654,7 +1447,7 @@ async def get_registry(request: Request, page: int = 1, limit: int = 20):
</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="/asset/{name}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
<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>
'''
@@ -1714,9 +1507,15 @@ async def get_registry(request: Request, page: int = 1, limit: int = 20):
@app.get("/asset/{name}")
async def get_asset_by_name(name: str, request: Request):
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, JSON for APIs."""
registry = load_registry()
registry = await load_registry()
if name not in registry.get("assets", {}):
if wants_html(request):
content = f'''
@@ -1733,43 +1532,30 @@ async def get_asset_by_name(name: str, request: Request):
return registry["assets"][name]
@app.get("/assets/{name}")
async def get_asset(name: str):
"""Get a specific asset (API only, use /asset/{name} for content negotiation)."""
registry = load_registry()
if name not in registry.get("assets", {}):
raise HTTPException(404, f"Asset not found: {name}")
return registry["assets"][name]
@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."""
registry = load_registry()
if name not in registry.get("assets", {}):
asset = await db.get_asset(name)
if not asset:
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
# Build updates dict
updates = {}
if req.description is not None:
asset["description"] = req.description
updates["description"] = req.description
if req.tags is not None:
asset["tags"] = req.tags
updates["tags"] = req.tags
if req.metadata is not None:
asset["metadata"] = {**asset.get("metadata", {}), **req.metadata}
updates["metadata"] = {**asset.get("metadata", {}), **req.metadata}
if req.origin is not None:
asset["origin"] = req.origin
updates["origin"] = req.origin
asset["updated_at"] = datetime.now(timezone.utc).isoformat()
# Save registry
registry["assets"][name] = asset
save_registry(registry)
# Update asset in database
updated_asset = await db.update_asset(name, updates)
# Create Update activity
activity = {
@@ -1777,37 +1563,33 @@ async def update_asset(name: str, req: UpdateAssetRequest, user: User = Depends(
"activity_type": "Update",
"actor_id": f"https://{DOMAIN}/users/{user.username}",
"object_data": {
"type": asset.get("asset_type", "Object").capitalize(),
"type": updated_asset.get("asset_type", "Object").capitalize(),
"name": name,
"id": f"https://{DOMAIN}/objects/{asset['content_hash']}",
"id": f"https://{DOMAIN}/objects/{updated_asset['content_hash']}",
"contentHash": {
"algorithm": "sha3-256",
"value": asset["content_hash"]
"value": updated_asset["content_hash"]
},
"attributedTo": f"https://{DOMAIN}/users/{user.username}",
"summary": req.description,
"tag": req.tags or asset.get("tags", [])
"tag": req.tags or updated_asset.get("tags", [])
},
"published": asset["updated_at"]
"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
activities = load_activities()
activities.append(activity)
save_activities(activities)
# Save activity to database
await db.create_activity(activity)
return {"asset": asset, "activity": activity}
return {"asset": updated_asset, "activity": activity}
def _register_asset_impl(req: RegisterRequest, owner: str):
async 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", {}):
if await db.asset_exists(req.name):
raise HTTPException(400, f"Asset already exists: {req.name}")
# Create asset
@@ -1824,11 +1606,8 @@ def _register_asset_impl(req: RegisterRequest, owner: str):
"created_at": now
}
# Add to registry
if "assets" not in registry:
registry["assets"] = {}
registry["assets"][req.name] = asset
save_registry(registry)
# Save asset to database
created_asset = await db.create_asset(asset)
# Create ownership activity
object_data = {
@@ -1857,18 +1636,16 @@ def _register_asset_impl(req: RegisterRequest, owner: str):
# Sign activity with the owner's keys
activity = sign_activity(activity, owner)
# Save activity
activities = load_activities()
activities.append(activity)
save_activities(activities)
# Save activity to database
await db.create_activity(activity)
return {"asset": asset, "activity": activity}
return {"asset": created_asset, "activity": 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 _register_asset_impl(req, user.username)
return await _register_asset_impl(req, user.username)
@app.post("/assets/record-run")
@@ -1903,7 +1680,7 @@ async def record_run(req: RecordRunRequest, user: User = Depends(get_required_us
}
# Register the output under the authenticated user
return _register_asset_impl(RegisterRequest(
return await _register_asset_impl(RegisterRequest(
name=req.output_name,
content_hash=output_hash,
asset_type="video", # Could be smarter about this
@@ -1933,8 +1710,7 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
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", {}):
if await db.asset_exists(req.asset_name):
raise HTTPException(400, f"Asset name already exists: {req.asset_name}")
# Create asset
@@ -1951,11 +1727,8 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
"created_at": now
}
# Add to registry
if "assets" not in registry:
registry["assets"] = {}
registry["assets"][req.asset_name] = asset
save_registry(registry)
# Save asset to database
created_asset = await db.create_asset(asset)
# Create ownership activity with origin info
object_data = {
@@ -1998,12 +1771,10 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
# Sign activity with the user's keys
activity = sign_activity(activity, user.username)
# Save activity
activities = load_activities()
activities.append(activity)
save_activities(activities)
# Save activity to database
await db.create_activity(activity)
return {"asset": asset, "activity": activity}
return {"asset": created_asset, "activity": activity}
# ============ Activities Endpoints ============
@@ -2011,7 +1782,7 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
@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 = load_activities()
all_activities = await load_activities()
total = len(all_activities)
# Reverse for newest first
@@ -2052,7 +1823,7 @@ async def get_activities(request: Request, page: int = 1, limit: int = 20):
</td>
<td class="py-3 px-4 text-gray-400">{activity.get("published", "")[:10]}</td>
<td class="py-3 px-4">
<a href="/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
<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>
'''
@@ -2111,10 +1882,10 @@ async def get_activities(request: Request, page: int = 1, limit: int = 20):
}
@app.get("/activity/{activity_index}")
async def get_activity(activity_index: int, request: Request):
@app.get("/activities/{activity_index}")
async def get_activity_detail(activity_index: int, request: Request):
"""Get single activity. HTML for browsers, JSON for APIs."""
activities = load_activities()
activities = await load_activities()
if activity_index < 0 or activity_index >= len(activities):
if wants_html(request):
@@ -2135,10 +1906,16 @@ async def get_activity(activity_index: int, request: Request):
return activity
@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 = load_registry()
registry = await load_registry()
# Find asset by hash
for name, asset in registry.get("assets", {}).items():
@@ -2149,7 +1926,7 @@ async def get_object(content_hash: str, request: Request):
if wants_html:
# Redirect to detail page for browsers
return RedirectResponse(url=f"/asset/{name}", status_code=303)
return RedirectResponse(url=f"/assets/{name}", status_code=303)
owner = asset.get("owner", "unknown")
return JSONResponse(