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:
90
auth.py
90
auth.py
@@ -4,7 +4,6 @@ Authentication for Art DAG L2 Server.
|
||||
User registration, login, and JWT tokens.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -15,6 +14,8 @@ import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
import db
|
||||
|
||||
# JWT settings
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
||||
@@ -68,25 +69,8 @@ class Token(BaseModel):
|
||||
expires_at: str
|
||||
|
||||
|
||||
def get_users_path(data_dir: Path) -> Path:
|
||||
"""Get users file path."""
|
||||
return data_dir / "users.json"
|
||||
|
||||
|
||||
def load_users(data_dir: Path) -> dict[str, dict]:
|
||||
"""Load users from disk."""
|
||||
path = get_users_path(data_dir)
|
||||
if path.exists():
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_users(data_dir: Path, users: dict[str, dict]):
|
||||
"""Save users to disk."""
|
||||
path = get_users_path(data_dir)
|
||||
with open(path, "w") as f:
|
||||
json.dump(users, f, indent=2)
|
||||
# Keep DATA_DIR for keys (RSA keys still stored as files)
|
||||
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -102,43 +86,53 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(pw_bytes, hashed_password.encode('utf-8'))
|
||||
|
||||
|
||||
def create_user(data_dir: Path, username: str, password: str, email: Optional[str] = None) -> User:
|
||||
async def create_user(data_dir: Path, username: str, password: str, email: Optional[str] = None) -> User:
|
||||
"""Create a new user with ActivityPub keys."""
|
||||
from keys import generate_keypair
|
||||
|
||||
users = load_users(data_dir)
|
||||
|
||||
if username in users:
|
||||
if await db.user_exists(username):
|
||||
raise ValueError(f"Username already exists: {username}")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
email=email
|
||||
)
|
||||
|
||||
users[username] = user.model_dump()
|
||||
save_users(data_dir, users)
|
||||
password_hash = hash_password(password)
|
||||
user_data = await db.create_user(username, password_hash, email)
|
||||
|
||||
# Generate ActivityPub keys for this user
|
||||
generate_keypair(data_dir, username)
|
||||
|
||||
return user
|
||||
# Convert datetime to ISO string if needed
|
||||
created_at = user_data.get("created_at")
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
|
||||
return User(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
created_at=created_at,
|
||||
email=email
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(data_dir: Path, username: str, password: str) -> Optional[User]:
|
||||
async def authenticate_user(data_dir: Path, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user by username and password."""
|
||||
users = load_users(data_dir)
|
||||
user_data = await db.get_user(username)
|
||||
|
||||
if username not in users:
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
user_data = users[username]
|
||||
if not verify_password(password, user_data["password_hash"]):
|
||||
return None
|
||||
|
||||
return User(**user_data)
|
||||
# Convert datetime to ISO string if needed
|
||||
created_at = user_data.get("created_at")
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
|
||||
return User(
|
||||
username=user_data["username"],
|
||||
password_hash=user_data["password_hash"],
|
||||
created_at=created_at,
|
||||
email=user_data.get("email")
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(username: str) -> Token:
|
||||
@@ -170,14 +164,24 @@ def verify_token(token: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(data_dir: Path, token: str) -> Optional[User]:
|
||||
async def get_current_user(data_dir: Path, token: str) -> Optional[User]:
|
||||
"""Get current user from token."""
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
return None
|
||||
|
||||
users = load_users(data_dir)
|
||||
if username not in users:
|
||||
user_data = await db.get_user(username)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
return User(**users[username])
|
||||
# Convert datetime to ISO string if needed
|
||||
created_at = user_data.get("created_at")
|
||||
if hasattr(created_at, 'isoformat'):
|
||||
created_at = created_at.isoformat()
|
||||
|
||||
return User(
|
||||
username=user_data["username"],
|
||||
password_hash=user_data["password_hash"],
|
||||
created_at=created_at,
|
||||
email=user_data.get("email")
|
||||
)
|
||||
|
||||
456
db.py
Normal file
456
db.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Database module for Art DAG L2 Server.
|
||||
|
||||
Uses asyncpg for async PostgreSQL access with connection pooling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
# Connection pool (initialized on startup)
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
# Configuration from environment
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
||||
)
|
||||
|
||||
# Schema for database initialization
|
||||
SCHEMA = """
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username VARCHAR(255) PRIMARY KEY,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Assets table
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
name VARCHAR(255) PRIMARY KEY,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
asset_type VARCHAR(50) NOT NULL,
|
||||
tags JSONB DEFAULT '[]'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
url TEXT,
|
||||
provenance JSONB,
|
||||
description TEXT,
|
||||
origin JSONB,
|
||||
owner VARCHAR(255) NOT NULL REFERENCES users(username),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Activities table
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
activity_id UUID PRIMARY KEY,
|
||||
activity_type VARCHAR(50) NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
object_data JSONB NOT NULL,
|
||||
published TIMESTAMPTZ NOT NULL,
|
||||
signature JSONB
|
||||
);
|
||||
|
||||
-- Followers table
|
||||
CREATE TABLE IF NOT EXISTS followers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL REFERENCES users(username),
|
||||
acct VARCHAR(255) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(username, acct)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_content_hash ON assets(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_created_at ON assets(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_tags ON assets USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_actor_id ON activities(actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_published ON activities(published DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username);
|
||||
"""
|
||||
|
||||
|
||||
async def init_pool():
|
||||
"""Initialize the connection pool and create tables. Call on app startup."""
|
||||
global _pool
|
||||
_pool = await asyncpg.create_pool(
|
||||
DATABASE_URL,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
command_timeout=60
|
||||
)
|
||||
# Create tables if they don't exist
|
||||
async with _pool.acquire() as conn:
|
||||
await conn.execute(SCHEMA)
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""Close the connection pool. Call on app shutdown."""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_pool() -> asyncpg.Pool:
|
||||
"""Get the connection pool."""
|
||||
if _pool is None:
|
||||
raise RuntimeError("Database pool not initialized")
|
||||
return _pool
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection():
|
||||
"""Get a connection from the pool."""
|
||||
async with get_pool().acquire() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
# ============ Users ============
|
||||
|
||||
async def get_user(username: str) -> Optional[dict]:
|
||||
"""Get user by username."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT username, password_hash, email, created_at FROM users WHERE username = $1",
|
||||
username
|
||||
)
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_users() -> dict[str, dict]:
|
||||
"""Get all users as a dict indexed by username."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT username, password_hash, email, created_at FROM users ORDER BY username"
|
||||
)
|
||||
return {row["username"]: dict(row) for row in rows}
|
||||
|
||||
|
||||
async def create_user(username: str, password_hash: str, email: Optional[str] = None) -> dict:
|
||||
"""Create a new user."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO users (username, password_hash, email)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING username, password_hash, email, created_at""",
|
||||
username, password_hash, email
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def user_exists(username: str) -> bool:
|
||||
"""Check if user exists."""
|
||||
async with get_connection() as conn:
|
||||
result = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",
|
||||
username
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============ Assets ============
|
||||
|
||||
async def get_asset(name: str) -> Optional[dict]:
|
||||
"""Get asset by name."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT name, content_hash, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets WHERE name = $1""",
|
||||
name
|
||||
)
|
||||
if row:
|
||||
return _parse_asset_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def get_asset_by_hash(content_hash: str) -> Optional[dict]:
|
||||
"""Get asset by content hash."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT name, content_hash, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets WHERE content_hash = $1""",
|
||||
content_hash
|
||||
)
|
||||
if row:
|
||||
return _parse_asset_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_assets() -> dict[str, dict]:
|
||||
"""Get all assets as a dict indexed by name."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT name, content_hash, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets ORDER BY created_at DESC"""
|
||||
)
|
||||
return {row["name"]: _parse_asset_row(row) for row in rows}
|
||||
|
||||
|
||||
async def get_assets_paginated(limit: int = 100, offset: int = 0) -> tuple[list[tuple[str, dict]], int]:
|
||||
"""Get paginated assets, returns (list of (name, asset) tuples, total_count)."""
|
||||
async with get_connection() as conn:
|
||||
total = await conn.fetchval("SELECT COUNT(*) FROM assets")
|
||||
rows = await conn.fetch(
|
||||
"""SELECT name, content_hash, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets ORDER BY created_at DESC LIMIT $1 OFFSET $2""",
|
||||
limit, offset
|
||||
)
|
||||
return [(row["name"], _parse_asset_row(row)) for row in rows], total
|
||||
|
||||
|
||||
async def get_assets_by_owner(owner: str) -> dict[str, dict]:
|
||||
"""Get all assets owned by a user."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT name, content_hash, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets WHERE owner = $1 ORDER BY created_at DESC""",
|
||||
owner
|
||||
)
|
||||
return {row["name"]: _parse_asset_row(row) for row in rows}
|
||||
|
||||
|
||||
async def create_asset(asset: dict) -> dict:
|
||||
"""Create a new asset."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO assets (name, content_hash, asset_type, tags, metadata,
|
||||
url, provenance, description, origin, owner, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *""",
|
||||
asset["name"],
|
||||
asset["content_hash"],
|
||||
asset["asset_type"],
|
||||
json.dumps(asset.get("tags", [])),
|
||||
json.dumps(asset.get("metadata", {})),
|
||||
asset.get("url"),
|
||||
json.dumps(asset.get("provenance")) if asset.get("provenance") else None,
|
||||
asset.get("description"),
|
||||
json.dumps(asset.get("origin")) if asset.get("origin") else None,
|
||||
asset["owner"],
|
||||
asset.get("created_at") or datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
return _parse_asset_row(row)
|
||||
|
||||
|
||||
async def update_asset(name: str, updates: dict) -> Optional[dict]:
|
||||
"""Update an existing asset."""
|
||||
# Build dynamic UPDATE query
|
||||
set_clauses = []
|
||||
values = []
|
||||
idx = 1
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in ("tags", "metadata", "provenance", "origin"):
|
||||
set_clauses.append(f"{key} = ${idx}")
|
||||
values.append(json.dumps(value) if value is not None else None)
|
||||
else:
|
||||
set_clauses.append(f"{key} = ${idx}")
|
||||
values.append(value)
|
||||
idx += 1
|
||||
|
||||
set_clauses.append(f"updated_at = ${idx}")
|
||||
values.append(datetime.now(timezone.utc))
|
||||
idx += 1
|
||||
|
||||
values.append(name) # WHERE clause
|
||||
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""UPDATE assets SET {', '.join(set_clauses)}
|
||||
WHERE name = ${idx} RETURNING *""",
|
||||
*values
|
||||
)
|
||||
if row:
|
||||
return _parse_asset_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def asset_exists(name: str) -> bool:
|
||||
"""Check if asset exists."""
|
||||
async with get_connection() as conn:
|
||||
return await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM assets WHERE name = $1)",
|
||||
name
|
||||
)
|
||||
|
||||
|
||||
def _parse_asset_row(row) -> dict:
|
||||
"""Parse a database row into an asset dict, handling JSONB fields."""
|
||||
asset = dict(row)
|
||||
# Convert datetime to ISO string
|
||||
if asset.get("created_at"):
|
||||
asset["created_at"] = asset["created_at"].isoformat()
|
||||
if asset.get("updated_at"):
|
||||
asset["updated_at"] = asset["updated_at"].isoformat()
|
||||
return asset
|
||||
|
||||
|
||||
# ============ Activities ============
|
||||
|
||||
async def get_activity(activity_id: str) -> Optional[dict]:
|
||||
"""Get activity by ID."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities WHERE activity_id = $1""",
|
||||
UUID(activity_id)
|
||||
)
|
||||
if row:
|
||||
return _parse_activity_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def get_activity_by_index(index: int) -> Optional[dict]:
|
||||
"""Get activity by index (for backward compatibility with URL scheme)."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities ORDER BY published ASC LIMIT 1 OFFSET $1""",
|
||||
index
|
||||
)
|
||||
if row:
|
||||
return _parse_activity_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_activities() -> list[dict]:
|
||||
"""Get all activities ordered by published date (oldest first for index compatibility)."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities ORDER BY published ASC"""
|
||||
)
|
||||
return [_parse_activity_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_activities_paginated(limit: int = 100, offset: int = 0) -> tuple[list[dict], int]:
|
||||
"""Get paginated activities (newest first), returns (activities, total_count)."""
|
||||
async with get_connection() as conn:
|
||||
total = await conn.fetchval("SELECT COUNT(*) FROM activities")
|
||||
rows = await conn.fetch(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities ORDER BY published DESC LIMIT $1 OFFSET $2""",
|
||||
limit, offset
|
||||
)
|
||||
return [_parse_activity_row(row) for row in rows], total
|
||||
|
||||
|
||||
async def get_activities_by_actor(actor_id: str) -> list[dict]:
|
||||
"""Get all activities by an actor."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities WHERE actor_id = $1 ORDER BY published DESC""",
|
||||
actor_id
|
||||
)
|
||||
return [_parse_activity_row(row) for row in rows]
|
||||
|
||||
|
||||
async def create_activity(activity: dict) -> dict:
|
||||
"""Create a new activity."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO activities (activity_id, activity_type, actor_id, object_data, published, signature)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *""",
|
||||
UUID(activity["activity_id"]),
|
||||
activity["activity_type"],
|
||||
activity["actor_id"],
|
||||
json.dumps(activity["object_data"]),
|
||||
activity["published"],
|
||||
json.dumps(activity.get("signature")) if activity.get("signature") else None
|
||||
)
|
||||
return _parse_activity_row(row)
|
||||
|
||||
|
||||
async def count_activities() -> int:
|
||||
"""Get total activity count."""
|
||||
async with get_connection() as conn:
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM activities")
|
||||
|
||||
|
||||
def _parse_activity_row(row) -> dict:
|
||||
"""Parse a database row into an activity dict, handling JSONB fields."""
|
||||
activity = dict(row)
|
||||
# Convert UUID to string
|
||||
if activity.get("activity_id"):
|
||||
activity["activity_id"] = str(activity["activity_id"])
|
||||
# Convert datetime to ISO string
|
||||
if activity.get("published"):
|
||||
activity["published"] = activity["published"].isoformat()
|
||||
return activity
|
||||
|
||||
|
||||
# ============ Followers ============
|
||||
|
||||
async def get_followers(username: str) -> list[dict]:
|
||||
"""Get followers for a user."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, username, acct, url, public_key, created_at
|
||||
FROM followers WHERE username = $1""",
|
||||
username
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_followers() -> list:
|
||||
"""Get all followers (for backward compatibility with old global list)."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT DISTINCT url FROM followers"""
|
||||
)
|
||||
return [row["url"] for row in rows]
|
||||
|
||||
|
||||
async def add_follower(username: str, acct: str, url: str, public_key: Optional[str] = None) -> dict:
|
||||
"""Add a follower."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO followers (username, acct, url, public_key)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (username, acct) DO UPDATE SET url = $3, public_key = $4
|
||||
RETURNING *""",
|
||||
username, acct, url, public_key
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def remove_follower(username: str, acct: str) -> bool:
|
||||
"""Remove a follower."""
|
||||
async with get_connection() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM followers WHERE username = $1 AND acct = $2",
|
||||
username, acct
|
||||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
|
||||
# ============ Stats ============
|
||||
|
||||
async def get_stats() -> dict:
|
||||
"""Get counts for dashboard."""
|
||||
async with get_connection() as conn:
|
||||
assets = await conn.fetchval("SELECT COUNT(*) FROM assets")
|
||||
activities = await conn.fetchval("SELECT COUNT(*) FROM activities")
|
||||
users = await conn.fetchval("SELECT COUNT(*) FROM users")
|
||||
return {"assets": assets, "activities": activities, "users": users}
|
||||
@@ -1,17 +1,38 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: artdag
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artdag}
|
||||
POSTGRES_DB: artdag
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U artdag"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
l2-server:
|
||||
image: git.rose-ash.com/art-dag/l2-server:latest
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ARTDAG_DATA=/data/l2
|
||||
- DATABASE_URL=postgresql://artdag:${POSTGRES_PASSWORD:-artdag}@postgres:5432/artdag
|
||||
# ARTDAG_DOMAIN, ARTDAG_USER, ARTDAG_L1, JWT_SECRET from .env file
|
||||
volumes:
|
||||
- l2_data:/data/l2
|
||||
- l2_data:/data/l2 # Still needed for RSA keys
|
||||
networks:
|
||||
- internal
|
||||
- externalnet
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
@@ -19,7 +40,9 @@ services:
|
||||
|
||||
volumes:
|
||||
l2_data:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
externalnet:
|
||||
external: true
|
||||
|
||||
246
migrate.py
Executable file
246
migrate.py
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script: JSON files to PostgreSQL.
|
||||
|
||||
Usage:
|
||||
python migrate.py [--dry-run]
|
||||
|
||||
Migrates:
|
||||
- users.json -> users table
|
||||
- registry.json -> assets table
|
||||
- activities.json -> activities table
|
||||
- followers.json -> followers table
|
||||
|
||||
Does NOT migrate:
|
||||
- keys/ directory (stays as files)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
# Configuration
|
||||
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
||||
)
|
||||
|
||||
SCHEMA = """
|
||||
-- Drop existing tables (careful in production!)
|
||||
DROP TABLE IF EXISTS followers CASCADE;
|
||||
DROP TABLE IF EXISTS activities CASCADE;
|
||||
DROP TABLE IF EXISTS assets CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
username VARCHAR(255) PRIMARY KEY,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Assets table
|
||||
CREATE TABLE assets (
|
||||
name VARCHAR(255) PRIMARY KEY,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
asset_type VARCHAR(50) NOT NULL,
|
||||
tags JSONB DEFAULT '[]'::jsonb,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
url TEXT,
|
||||
provenance JSONB,
|
||||
description TEXT,
|
||||
origin JSONB,
|
||||
owner VARCHAR(255) NOT NULL REFERENCES users(username),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Activities table
|
||||
CREATE TABLE activities (
|
||||
activity_id UUID PRIMARY KEY,
|
||||
activity_type VARCHAR(50) NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
object_data JSONB NOT NULL,
|
||||
published TIMESTAMPTZ NOT NULL,
|
||||
signature JSONB
|
||||
);
|
||||
|
||||
-- Followers table
|
||||
CREATE TABLE followers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL REFERENCES users(username),
|
||||
acct VARCHAR(255) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(username, acct)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
CREATE INDEX idx_assets_content_hash ON assets(content_hash);
|
||||
CREATE INDEX idx_assets_owner ON assets(owner);
|
||||
CREATE INDEX idx_assets_created_at ON assets(created_at DESC);
|
||||
CREATE INDEX idx_assets_tags ON assets USING GIN(tags);
|
||||
CREATE INDEX idx_activities_actor_id ON activities(actor_id);
|
||||
CREATE INDEX idx_activities_published ON activities(published DESC);
|
||||
CREATE INDEX idx_followers_username ON followers(username);
|
||||
"""
|
||||
|
||||
|
||||
async def migrate(dry_run: bool = False):
|
||||
"""Run the migration."""
|
||||
print(f"Migrating from {DATA_DIR} to PostgreSQL")
|
||||
print(f"Database: {DATABASE_URL}")
|
||||
print(f"Dry run: {dry_run}")
|
||||
print()
|
||||
|
||||
# Load JSON files
|
||||
users = load_json(DATA_DIR / "users.json") or {}
|
||||
registry = load_json(DATA_DIR / "registry.json") or {"assets": {}}
|
||||
activities_data = load_json(DATA_DIR / "activities.json") or {"activities": []}
|
||||
followers = load_json(DATA_DIR / "followers.json") or []
|
||||
|
||||
assets = registry.get("assets", {})
|
||||
activities = activities_data.get("activities", [])
|
||||
|
||||
print(f"Found {len(users)} users")
|
||||
print(f"Found {len(assets)} assets")
|
||||
print(f"Found {len(activities)} activities")
|
||||
print(f"Found {len(followers)} followers")
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
print("DRY RUN - no changes made")
|
||||
return
|
||||
|
||||
# Connect and migrate
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
# Create schema
|
||||
print("Creating schema...")
|
||||
await conn.execute(SCHEMA)
|
||||
|
||||
# Migrate users
|
||||
print("Migrating users...")
|
||||
for username, user_data in users.items():
|
||||
await conn.execute(
|
||||
"""INSERT INTO users (username, password_hash, email, created_at)
|
||||
VALUES ($1, $2, $3, $4)""",
|
||||
username,
|
||||
user_data["password_hash"],
|
||||
user_data.get("email"),
|
||||
parse_timestamp(user_data.get("created_at"))
|
||||
)
|
||||
print(f" Migrated {len(users)} users")
|
||||
|
||||
# Migrate assets
|
||||
print("Migrating assets...")
|
||||
for name, asset in assets.items():
|
||||
await conn.execute(
|
||||
"""INSERT INTO assets (name, content_hash, asset_type, tags, metadata,
|
||||
url, provenance, description, origin, owner,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
|
||||
name,
|
||||
asset["content_hash"],
|
||||
asset["asset_type"],
|
||||
json.dumps(asset.get("tags", [])),
|
||||
json.dumps(asset.get("metadata", {})),
|
||||
asset.get("url"),
|
||||
json.dumps(asset.get("provenance")) if asset.get("provenance") else None,
|
||||
asset.get("description"),
|
||||
json.dumps(asset.get("origin")) if asset.get("origin") else None,
|
||||
asset["owner"],
|
||||
parse_timestamp(asset.get("created_at")),
|
||||
parse_timestamp(asset.get("updated_at"))
|
||||
)
|
||||
print(f" Migrated {len(assets)} assets")
|
||||
|
||||
# Migrate activities
|
||||
print("Migrating activities...")
|
||||
for activity in activities:
|
||||
await conn.execute(
|
||||
"""INSERT INTO activities (activity_id, activity_type, actor_id,
|
||||
object_data, published, signature)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
UUID(activity["activity_id"]),
|
||||
activity["activity_type"],
|
||||
activity["actor_id"],
|
||||
json.dumps(activity["object_data"]),
|
||||
parse_timestamp(activity["published"]),
|
||||
json.dumps(activity.get("signature")) if activity.get("signature") else None
|
||||
)
|
||||
print(f" Migrated {len(activities)} activities")
|
||||
|
||||
# Migrate followers
|
||||
print("Migrating followers...")
|
||||
if followers and users:
|
||||
first_user = list(users.keys())[0]
|
||||
migrated = 0
|
||||
for follower in followers:
|
||||
if isinstance(follower, str):
|
||||
# Old format: just URL string
|
||||
await conn.execute(
|
||||
"""INSERT INTO followers (username, acct, url)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
first_user,
|
||||
follower,
|
||||
follower
|
||||
)
|
||||
migrated += 1
|
||||
elif isinstance(follower, dict):
|
||||
await conn.execute(
|
||||
"""INSERT INTO followers (username, acct, url, public_key)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
follower.get("username", first_user),
|
||||
follower.get("acct", follower.get("url", "")),
|
||||
follower["url"],
|
||||
follower.get("public_key")
|
||||
)
|
||||
migrated += 1
|
||||
print(f" Migrated {migrated} followers")
|
||||
else:
|
||||
print(" No followers to migrate")
|
||||
|
||||
print()
|
||||
print("Migration complete!")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict | list | None:
|
||||
"""Load JSON file if it exists."""
|
||||
if path.exists():
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
def parse_timestamp(ts: str | None) -> datetime | None:
|
||||
"""Parse ISO timestamp string to datetime."""
|
||||
if not ts:
|
||||
return datetime.now(timezone.utc)
|
||||
try:
|
||||
# Handle various ISO formats
|
||||
if ts.endswith('Z'):
|
||||
ts = ts[:-1] + '+00:00'
|
||||
return datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
asyncio.run(migrate(dry_run))
|
||||
@@ -6,3 +6,4 @@ bcrypt>=4.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
markdown>=3.5.0
|
||||
python-multipart>=0.0.6
|
||||
asyncpg>=0.29.0
|
||||
|
||||
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