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:
499
server.py
499
server.py
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user