Squashed 'l2/' content from commit 79caa24

git-subtree-dir: l2
git-subtree-split: 79caa24e2129bf6e2cee819327d5622425306b67
This commit is contained in:
giles
2026-02-24 23:07:31 +00:00
commit f54b0fb5da
43 changed files with 10021 additions and 0 deletions

116
app/__init__.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Art-DAG L2 Server Application Factory.
Creates and configures the FastAPI application with all routers and middleware.
"""
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, HTMLResponse
from artdag_common import create_jinja_env
from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage database connection pool lifecycle."""
import db
await db.init_pool()
yield
await db.close_pool()
def create_app() -> FastAPI:
"""
Create and configure the L2 FastAPI application.
Returns:
Configured FastAPI instance
"""
app = FastAPI(
title="Art-DAG L2 Server",
description="ActivityPub server for Art-DAG ownership and federation",
version="1.0.0",
lifespan=lifespan,
)
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini
_FRAG_SKIP = ("/auth/", "/.well-known/", "/health",
"/internal/", "/static/", "/inbox")
@app.middleware("http")
async def coop_fragments_middleware(request: Request, call_next):
path = request.url.path
if (
request.method != "GET"
or any(path.startswith(p) for p in _FRAG_SKIP)
or request.headers.get("hx-request")
):
request.state.nav_tree_html = ""
request.state.auth_menu_html = ""
request.state.cart_mini_html = ""
return await call_next(request)
from artdag_common.fragments import fetch_fragments as _fetch_frags
user = get_user_from_cookie(request)
auth_params = {"email": user.email} if user and user.email else {}
nav_params = {"app_name": "artdag", "path": path}
try:
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
("blog", "nav-tree", nav_params),
("account", "auth-menu", auth_params or None),
("cart", "cart-mini", None),
])
except Exception:
nav_tree_html = auth_menu_html = cart_mini_html = ""
request.state.nav_tree_html = nav_tree_html
request.state.auth_menu_html = auth_menu_html
request.state.cart_mini_html = cart_mini_html
return await call_next(request)
# Initialize Jinja2 templates
template_dir = Path(__file__).parent / "templates"
app.state.templates = create_jinja_env(template_dir)
# Custom 404 handler
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
from artdag_common.middleware import wants_html
if wants_html(request):
from artdag_common import render
return render(app.state.templates, "404.html", request,
user=None,
)
return JSONResponse({"detail": "Not found"}, status_code=404)
# Include routers
from .routers import auth, assets, activities, anchors, storage, users, renderers
# Root routes
app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(users.router, tags=["users"])
# Feature routers
app.include_router(assets.router, prefix="/assets", tags=["assets"])
app.include_router(activities.router, prefix="/activities", tags=["activities"])
app.include_router(anchors.router, prefix="/anchors", tags=["anchors"])
app.include_router(storage.router, prefix="/storage", tags=["storage"])
app.include_router(renderers.router, prefix="/renderers", tags=["renderers"])
# WebFinger and ActivityPub discovery
from .routers import federation
app.include_router(federation.router, tags=["federation"])
return app
# Create the default app instance
app = create_app()

56
app/config.py Normal file
View File

@@ -0,0 +1,56 @@
"""
L2 Server Configuration.
Environment-based settings for the ActivityPub server.
"""
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Settings:
"""L2 Server configuration."""
# Domain and URLs
domain: str = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com")
l1_public_url: str = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash.com")
effects_repo_url: str = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
ipfs_gateway_url: str = os.environ.get("IPFS_GATEWAY_URL", "")
# L1 servers
l1_servers: list = None
# Cookie domain for cross-subdomain auth
cookie_domain: str = None
# Data directory
data_dir: Path = None
# JWT settings
jwt_secret: str = os.environ.get("JWT_SECRET", "")
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 30 # 30 days
def __post_init__(self):
# Parse L1 servers
l1_str = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com")
self.l1_servers = [s.strip() for s in l1_str.split(",") if s.strip()]
# Cookie domain
env_cookie = os.environ.get("COOKIE_DOMAIN")
if env_cookie:
self.cookie_domain = env_cookie
else:
parts = self.domain.split(".")
if len(parts) >= 2:
self.cookie_domain = "." + ".".join(parts[-2:])
# Data directory
self.data_dir = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
self.data_dir.mkdir(parents=True, exist_ok=True)
(self.data_dir / "assets").mkdir(exist_ok=True)
settings = Settings()

80
app/dependencies.py Normal file
View File

@@ -0,0 +1,80 @@
"""
L2 Server Dependency Injection.
Provides common dependencies for routes.
"""
from typing import Optional
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import settings
security = HTTPBearer(auto_error=False)
def get_templates(request: Request):
"""Get Jinja2 templates from app state."""
return request.app.state.templates
async def get_current_user(request: Request) -> Optional[dict]:
"""
Get current user from cookie or header.
Returns user dict or None if not authenticated.
"""
from auth import verify_token, get_token_claims
# Try cookie first
token = request.cookies.get("auth_token")
# Try Authorization header
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
return None
# Verify token
username = verify_token(token)
if not username:
return None
# Get full claims
claims = get_token_claims(token)
if not claims:
return None
return {
"username": username,
"actor_id": f"https://{settings.domain}/users/{username}",
"token": token,
**claims,
}
async def require_auth(request: Request) -> dict:
"""
Require authentication.
Raises HTTPException 401 if not authenticated.
"""
user = await get_current_user(request)
if not user:
raise HTTPException(401, "Authentication required")
return user
def get_user_from_cookie(request: Request) -> Optional[str]:
"""Get username from cookie (for HTML pages)."""
from auth import verify_token
token = request.cookies.get("auth_token")
if not token:
return None
return verify_token(token)

25
app/routers/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""
L2 Server Routers.
Each router handles a specific domain of functionality.
"""
from . import auth
from . import assets
from . import activities
from . import anchors
from . import storage
from . import users
from . import renderers
from . import federation
__all__ = [
"auth",
"assets",
"activities",
"anchors",
"storage",
"users",
"renderers",
"federation",
]

99
app/routers/activities.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Activity routes for L2 server.
Handles ActivityPub activities and outbox.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("")
async def list_activities(
request: Request,
offset: int = 0,
limit: int = 20,
):
"""List recent activities."""
import db
username = get_user_from_cookie(request)
activities, total = await db.get_activities_paginated(limit=limit, offset=offset)
has_more = offset + len(activities) < total
if wants_json(request):
return {"activities": activities, "offset": offset, "limit": limit}
templates = get_templates(request)
return render(templates, "activities/list.html", request,
activities=activities,
user={"username": username} if username else None,
offset=offset,
limit=limit,
has_more=has_more,
active_tab="activities",
)
@router.get("/{activity_id}")
async def get_activity(
activity_id: str,
request: Request,
):
"""Get activity details."""
import db
activity = await db.get_activity(activity_id)
if not activity:
raise HTTPException(404, "Activity not found")
# ActivityPub response
if "application/activity+json" in request.headers.get("accept", ""):
return JSONResponse(
content=activity.get("activity_json", activity),
media_type="application/activity+json",
)
if wants_json(request):
return activity
username = get_user_from_cookie(request)
templates = get_templates(request)
return render(templates, "activities/detail.html", request,
activity=activity,
user={"username": username} if username else None,
active_tab="activities",
)
@router.post("")
async def create_activity(
request: Request,
user: dict = Depends(require_auth),
):
"""Create a new activity (internal use)."""
import db
import json
body = await request.json()
activity_id = await db.create_activity(
actor=user["actor_id"],
activity_type=body.get("type", "Create"),
object_data=body.get("object"),
)
return {"activity_id": activity_id, "created": True}

203
app/routers/anchors.py Normal file
View File

@@ -0,0 +1,203 @@
"""
Anchor routes for L2 server.
Handles OpenTimestamps anchoring and verification.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("")
async def list_anchors(
request: Request,
offset: int = 0,
limit: int = 20,
):
"""List user's anchors."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
anchors = await db.get_anchors_paginated(offset=offset, limit=limit)
has_more = len(anchors) >= limit
if wants_json(request):
return {"anchors": anchors, "offset": offset, "limit": limit}
templates = get_templates(request)
return render(templates, "anchors/list.html", request,
anchors=anchors,
user={"username": username},
offset=offset,
limit=limit,
has_more=has_more,
active_tab="anchors",
)
@router.post("")
async def create_anchor(
request: Request,
user: dict = Depends(require_auth),
):
"""Create a new timestamp anchor."""
import db
import anchoring
body = await request.json()
content_hash = body.get("content_hash")
if not content_hash:
raise HTTPException(400, "content_hash required")
# Create OTS timestamp
try:
ots_data = await anchoring.create_timestamp(content_hash)
except Exception as e:
logger.error(f"Failed to create timestamp: {e}")
raise HTTPException(500, f"Timestamping failed: {e}")
# Save anchor
anchor_id = await db.create_anchor(
username=user["username"],
content_hash=content_hash,
ots_data=ots_data,
)
return {
"anchor_id": anchor_id,
"content_hash": content_hash,
"status": "pending",
"message": "Anchor created, pending Bitcoin confirmation",
}
@router.get("/{anchor_id}")
async def get_anchor(
anchor_id: str,
request: Request,
):
"""Get anchor details."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
if wants_json(request):
return anchor
username = get_user_from_cookie(request)
templates = get_templates(request)
return render(templates, "anchors/detail.html", request,
anchor=anchor,
user={"username": username} if username else None,
active_tab="anchors",
)
@router.get("/{anchor_id}/ots")
async def download_ots(anchor_id: str):
"""Download OTS proof file."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
ots_data = anchor.get("ots_data")
if not ots_data:
raise HTTPException(404, "OTS data not available")
# Return as file download
from fastapi.responses import Response
return Response(
content=ots_data,
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename={anchor['content_hash']}.ots"
},
)
@router.post("/{anchor_id}/verify")
async def verify_anchor(
anchor_id: str,
request: Request,
user: dict = Depends(require_auth),
):
"""Verify anchor status (check Bitcoin confirmation)."""
import db
import anchoring
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
try:
result = await anchoring.verify_timestamp(
anchor["content_hash"],
anchor["ots_data"],
)
# Update anchor status
if result.get("confirmed"):
await db.update_anchor(
anchor_id,
status="confirmed",
bitcoin_block=result.get("block_height"),
confirmed_at=result.get("confirmed_at"),
)
if wants_html(request):
if result.get("confirmed"):
return HTMLResponse(
f'<span class="text-green-400">Confirmed in block {result["block_height"]}</span>'
)
return HTMLResponse('<span class="text-yellow-400">Pending confirmation</span>')
return result
except Exception as e:
logger.error(f"Verification failed: {e}")
raise HTTPException(500, f"Verification failed: {e}")
@router.delete("/{anchor_id}")
async def delete_anchor(
anchor_id: str,
user: dict = Depends(require_auth),
):
"""Delete an anchor."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
if anchor.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.delete_anchor(anchor_id)
if not success:
raise HTTPException(400, "Failed to delete anchor")
return {"deleted": True}

244
app/routers/assets.py Normal file
View File

@@ -0,0 +1,244 @@
"""
Asset management routes for L2 server.
Handles asset registration, listing, and publishing.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
class AssetCreate(BaseModel):
name: str
content_hash: str
ipfs_cid: Optional[str] = None
asset_type: str # image, video, effect, recipe
tags: List[str] = []
metadata: dict = {}
provenance: Optional[dict] = None
class RecordRunRequest(BaseModel):
run_id: str
recipe: str
inputs: List[str]
output_hash: str
ipfs_cid: Optional[str] = None
provenance: Optional[dict] = None
@router.get("")
async def list_assets(
request: Request,
offset: int = 0,
limit: int = 20,
asset_type: Optional[str] = None,
):
"""List user's assets."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
assets = await db.get_user_assets(username, offset=offset, limit=limit, asset_type=asset_type)
has_more = len(assets) >= limit
if wants_json(request):
return {"assets": assets, "offset": offset, "limit": limit, "has_more": has_more}
templates = get_templates(request)
return render(templates, "assets/list.html", request,
assets=assets,
user={"username": username},
offset=offset,
limit=limit,
has_more=has_more,
active_tab="assets",
)
@router.post("")
async def create_asset(
req: AssetCreate,
user: dict = Depends(require_auth),
):
"""Register a new asset."""
import db
asset = await db.create_asset({
"owner": user["username"],
"name": req.name,
"content_hash": req.content_hash,
"ipfs_cid": req.ipfs_cid,
"asset_type": req.asset_type,
"tags": req.tags or [],
"metadata": req.metadata or {},
"provenance": req.provenance,
})
if not asset:
raise HTTPException(400, "Failed to create asset")
return {"asset_id": asset.get("name"), "message": "Asset registered"}
@router.get("/{asset_id}")
async def get_asset(
asset_id: str,
request: Request,
):
"""Get asset details."""
import db
username = get_user_from_cookie(request)
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if wants_json(request):
return asset
templates = get_templates(request)
return render(templates, "assets/detail.html", request,
asset=asset,
user={"username": username} if username else None,
active_tab="assets",
)
@router.delete("/{asset_id}")
async def delete_asset(
asset_id: str,
user: dict = Depends(require_auth),
):
"""Delete an asset."""
import db
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if asset.get("owner") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.delete_asset(asset_id)
if not success:
raise HTTPException(400, "Failed to delete asset")
return {"deleted": True}
@router.post("/record-run")
async def record_run(
req: RecordRunRequest,
user: dict = Depends(require_auth),
):
"""Record a run completion and register output as asset."""
import db
# Create asset for output
asset = await db.create_asset({
"owner": user["username"],
"name": f"{req.recipe}-{req.run_id[:8]}",
"content_hash": req.output_hash,
"ipfs_cid": req.ipfs_cid,
"asset_type": "render",
"metadata": {
"run_id": req.run_id,
"recipe": req.recipe,
"inputs": req.inputs,
},
"provenance": req.provenance,
})
asset_id = asset.get("name") if asset else None
# Record run
await db.record_run(
run_id=req.run_id,
username=user["username"],
recipe=req.recipe,
inputs=req.inputs or [],
output_hash=req.output_hash,
ipfs_cid=req.ipfs_cid,
asset_id=asset_id,
)
return {
"run_id": req.run_id,
"asset_id": asset_id,
"recorded": True,
}
@router.get("/by-run-id/{run_id}")
async def get_asset_by_run_id(run_id: str):
"""Get asset by run ID (for L1 cache lookup)."""
import db
run = await db.get_run(run_id)
if not run:
raise HTTPException(404, "Run not found")
return {
"run_id": run_id,
"output_hash": run.get("output_hash"),
"ipfs_cid": run.get("ipfs_cid"),
"provenance_cid": run.get("provenance_cid"),
}
@router.post("/{asset_id}/publish")
async def publish_asset(
asset_id: str,
request: Request,
user: dict = Depends(require_auth),
):
"""Publish asset to IPFS."""
import db
import ipfs_client
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if asset.get("owner") != user["username"]:
raise HTTPException(403, "Not authorized")
# Already published?
if asset.get("ipfs_cid"):
return {"ipfs_cid": asset["ipfs_cid"], "already_published": True}
# Get content from L1
content_hash = asset.get("content_hash")
for l1_url in settings.l1_servers:
try:
import requests
resp = requests.get(f"{l1_url}/cache/{content_hash}/raw", timeout=30)
if resp.status_code == 200:
# Pin to IPFS
cid = await ipfs_client.add_bytes(resp.content)
if cid:
await db.update_asset(asset_id, {"ipfs_cid": cid})
return {"ipfs_cid": cid, "published": True}
except Exception as e:
logger.warning(f"Failed to fetch from {l1_url}: {e}")
raise HTTPException(400, "Failed to publish - content not found on any L1")

223
app/routers/auth.py Normal file
View File

@@ -0,0 +1,223 @@
"""
Authentication routes for L2 server.
Handles login, registration, logout, and token verification.
"""
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from artdag_common import render
from artdag_common.middleware import wants_html
from ..config import settings
from ..dependencies import get_templates, get_user_from_cookie
router = APIRouter()
security = HTTPBearer(auto_error=False)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, return_to: str = None):
"""Login page."""
username = get_user_from_cookie(request)
if username:
templates = get_templates(request)
return render(templates, "auth/already_logged_in.html", request,
user={"username": username},
)
templates = get_templates(request)
return render(templates, "auth/login.html", request,
return_to=return_to,
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
return_to: str = Form(None),
):
"""Handle login form submission."""
from auth import authenticate_user, create_access_token
if not username or not password:
return HTMLResponse(
'<div class="text-red-400">Username and password are required</div>'
)
user = await authenticate_user(settings.data_dir, username.strip(), password)
if not user:
return HTMLResponse(
'<div class="text-red-400">Invalid username or password</div>'
)
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
# Handle return_to redirect
if return_to and return_to.startswith("http"):
separator = "&" if "?" in return_to else "?"
redirect_url = f"{return_to}{separator}auth_token={token.access_token}"
response = HTMLResponse(f'''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "{redirect_url}";</script>
''')
else:
response = HTMLResponse('''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
response.set_cookie(
key="auth_token",
value=token.access_token,
httponly=True,
max_age=60 * 60 * 24 * 30,
samesite="lax",
secure=True,
)
return response
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
"""Registration page."""
username = get_user_from_cookie(request)
if username:
templates = get_templates(request)
return render(templates, "auth/already_logged_in.html", request,
user={"username": username},
)
templates = get_templates(request)
return render(templates, "auth/register.html", request)
@router.post("/register", response_class=HTMLResponse)
async def register_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
password2: str = Form(...),
email: str = Form(None),
):
"""Handle registration form submission."""
from auth import create_user, create_access_token
if not username or not password:
return HTMLResponse('<div class="text-red-400">Username and password are required</div>')
if password != password2:
return HTMLResponse('<div class="text-red-400">Passwords do not match</div>')
if len(password) < 6:
return HTMLResponse('<div class="text-red-400">Password must be at least 6 characters</div>')
try:
user = await create_user(settings.data_dir, username.strip(), password, email)
except ValueError as e:
return HTMLResponse(f'<div class="text-red-400">{str(e)}</div>')
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
response = HTMLResponse('''
<div class="text-green-400">Registration successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
response.set_cookie(
key="auth_token",
value=token.access_token,
httponly=True,
max_age=60 * 60 * 24 * 30,
samesite="lax",
secure=True,
)
return response
@router.get("/logout")
async def logout(request: Request):
"""Handle logout."""
import db
import requests
from auth import get_token_claims
token = request.cookies.get("auth_token")
claims = get_token_claims(token) if token else None
username = claims.get("sub") if claims else None
if username and token and claims:
# Revoke token in database
token_hash = hashlib.sha256(token.encode()).hexdigest()
expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc)
await db.revoke_token(token_hash, username, expires_at)
# Revoke on attached L1 servers
attached = await db.get_user_renderers(username)
for l1_url in attached:
try:
requests.post(
f"{l1_url}/auth/revoke-user",
json={"username": username, "l2_server": f"https://{settings.domain}"},
timeout=5,
)
except Exception:
pass
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("auth_token")
return response
@router.get("/verify")
async def verify_token(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Verify a token is valid.
Called by L1 servers to verify tokens during auth callback.
Returns user info if valid, 401 if not.
"""
import db
from auth import verify_token as verify_jwt, get_token_claims
# Get token from Authorization header or query param
token = None
if credentials:
token = credentials.credentials
else:
# Try Authorization header manually (for clients that don't use Bearer format)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
raise HTTPException(401, "No token provided")
# Verify JWT signature and expiry
username = verify_jwt(token)
if not username:
raise HTTPException(401, "Invalid or expired token")
# Check if token is revoked
claims = get_token_claims(token)
if claims:
token_hash = hashlib.sha256(token.encode()).hexdigest()
if await db.is_token_revoked(token_hash):
raise HTTPException(401, "Token has been revoked")
return {
"valid": True,
"username": username,
"claims": claims,
}

115
app/routers/federation.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Federation routes for L2 server.
Handles WebFinger, nodeinfo, and ActivityPub discovery.
"""
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from ..config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/.well-known/webfinger")
async def webfinger(resource: str):
"""WebFinger endpoint for actor discovery."""
import db
# Parse resource (acct:username@domain)
if not resource.startswith("acct:"):
raise HTTPException(400, "Invalid resource format")
parts = resource[5:].split("@")
if len(parts) != 2:
raise HTTPException(400, "Invalid resource format")
username, domain = parts
if domain != settings.domain:
raise HTTPException(404, "User not on this server")
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
return JSONResponse(
content={
"subject": resource,
"aliases": [f"https://{settings.domain}/users/{username}"],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{settings.domain}/users/{username}",
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{settings.domain}/users/{username}",
},
],
},
media_type="application/jrd+json",
)
@router.get("/.well-known/nodeinfo")
async def nodeinfo_index():
"""NodeInfo index."""
return JSONResponse(
content={
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{settings.domain}/nodeinfo/2.0",
}
]
},
media_type="application/json",
)
@router.get("/nodeinfo/2.0")
async def nodeinfo():
"""NodeInfo 2.0 endpoint."""
import db
user_count = await db.count_users()
activity_count = await db.count_activities()
return JSONResponse(
content={
"version": "2.0",
"software": {
"name": "artdag",
"version": "1.0.0",
},
"protocols": ["activitypub"],
"usage": {
"users": {"total": user_count, "activeMonth": user_count},
"localPosts": activity_count,
},
"openRegistrations": True,
"metadata": {
"nodeName": "Art-DAG",
"nodeDescription": "Content-addressable media processing with ActivityPub federation",
},
},
media_type="application/json",
)
@router.get("/.well-known/host-meta")
async def host_meta():
"""Host-meta endpoint."""
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="https://{settings.domain}/.well-known/webfinger?resource={{uri}}"/>
</XRD>'''
from fastapi.responses import Response
return Response(content=xml, media_type="application/xrd+xml")

93
app/routers/renderers.py Normal file
View File

@@ -0,0 +1,93 @@
"""
Renderer (L1) management routes for L2 server.
L1 servers are configured via environment variable L1_SERVERS.
Users connect to renderers to create and run recipes.
"""
import logging
from typing import Optional
import requests
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
def check_renderer_health(url: str, timeout: float = 5.0) -> bool:
"""Check if a renderer is healthy."""
try:
resp = requests.get(f"{url}/", timeout=timeout)
return resp.status_code == 200
except Exception:
return False
@router.get("")
async def list_renderers(request: Request):
"""List configured L1 renderers."""
# Get user if logged in
username = get_user_from_cookie(request)
user = None
if username:
# Get token for connection links
token = request.cookies.get("auth_token", "")
user = {"username": username, "token": token}
# Build server list with health status
servers = []
for url in settings.l1_servers:
servers.append({
"url": url,
"healthy": check_renderer_health(url),
})
if wants_json(request):
return {"servers": servers}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
servers=servers,
user=user,
active_tab="renderers",
)
@router.get("/{path:path}")
async def renderer_catchall(path: str, request: Request):
"""Catch-all for invalid renderer URLs - redirect to list."""
if wants_json(request):
raise HTTPException(404, "Not found")
return RedirectResponse(url="/renderers", status_code=302)
@router.post("")
@router.post("/{path:path}")
async def renderer_post_catchall(request: Request, path: str = ""):
"""
Catch-all for POST requests.
The old API expected JSON POST to attach renderers.
Now renderers are env-configured, so redirect to the list.
"""
if wants_json(request):
return {
"error": "Renderers are now configured via environment. See /renderers for available servers.",
"servers": settings.l1_servers,
}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
servers=[{"url": url, "healthy": check_renderer_health(url)} for url in settings.l1_servers],
user=get_user_from_cookie(request),
error="Renderers are configured by the system administrator. Use the Connect button to access a renderer.",
active_tab="renderers",
)

254
app/routers/storage.py Normal file
View File

@@ -0,0 +1,254 @@
"""
Storage provider routes for L2 server.
Manages user storage backends.
"""
import logging
from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
STORAGE_PROVIDERS_INFO = {
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
}
class AddStorageRequest(BaseModel):
provider_type: str
config: Dict[str, Any]
capacity_gb: int = 5
provider_name: Optional[str] = None
@router.get("")
async def list_storage(request: Request):
"""List user's storage providers."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
storages = await db.get_user_storage(username)
if wants_json(request):
return {"storages": storages}
templates = get_templates(request)
return render(templates, "storage/list.html", request,
storages=storages,
user={"username": username},
providers_info=STORAGE_PROVIDERS_INFO,
active_tab="storage",
)
@router.post("")
async def add_storage(
req: AddStorageRequest,
user: dict = Depends(require_auth),
):
"""Add a storage provider."""
import db
import storage_providers
if req.provider_type not in STORAGE_PROVIDERS_INFO:
raise HTTPException(400, f"Invalid provider type: {req.provider_type}")
# Test connection
provider = storage_providers.create_provider(req.provider_type, {
**req.config,
"capacity_gb": req.capacity_gb,
})
if not provider:
raise HTTPException(400, "Failed to create provider")
success, message = await provider.test_connection()
if not success:
raise HTTPException(400, f"Connection failed: {message}")
# Save
storage_id = await db.add_user_storage(
username=user["username"],
provider_type=req.provider_type,
provider_name=req.provider_name,
config=req.config,
capacity_gb=req.capacity_gb,
)
return {"id": storage_id, "message": "Storage provider added"}
@router.post("/add", response_class=HTMLResponse)
async def add_storage_form(
request: Request,
provider_type: str = Form(...),
provider_name: Optional[str] = Form(None),
capacity_gb: int = Form(5),
api_key: Optional[str] = Form(None),
secret_key: Optional[str] = Form(None),
api_token: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
project_secret: Optional[str] = Form(None),
access_key: Optional[str] = Form(None),
bucket: Optional[str] = Form(None),
path: Optional[str] = Form(None),
):
"""Add storage via HTML form."""
import db
import storage_providers
username = get_user_from_cookie(request)
if not username:
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
# Build config
config = {}
if provider_type == "pinata":
if not api_key or not secret_key:
return HTMLResponse('<div class="text-red-400">Pinata requires API Key and Secret Key</div>')
config = {"api_key": api_key, "secret_key": secret_key}
elif provider_type in ["web3storage", "nftstorage"]:
if not api_token:
return HTMLResponse(f'<div class="text-red-400">{provider_type} requires API Token</div>')
config = {"api_token": api_token}
elif provider_type == "infura":
if not project_id or not project_secret:
return HTMLResponse('<div class="text-red-400">Infura requires Project ID and Secret</div>')
config = {"project_id": project_id, "project_secret": project_secret}
elif provider_type in ["filebase", "storj"]:
if not access_key or not secret_key or not bucket:
return HTMLResponse('<div class="text-red-400">Requires Access Key, Secret Key, and Bucket</div>')
config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}
elif provider_type == "local":
if not path:
return HTMLResponse('<div class="text-red-400">Local storage requires a path</div>')
config = {"path": path}
else:
return HTMLResponse(f'<div class="text-red-400">Unknown provider: {provider_type}</div>')
# Test
provider = storage_providers.create_provider(provider_type, {**config, "capacity_gb": capacity_gb})
if provider:
success, message = await provider.test_connection()
if not success:
return HTMLResponse(f'<div class="text-red-400">Connection failed: {message}</div>')
# Save
storage_id = await db.add_user_storage(
username=username,
provider_type=provider_type,
provider_name=provider_name,
config=config,
capacity_gb=capacity_gb,
)
return HTMLResponse(f'''
<div class="text-green-400 mb-2">Storage provider added!</div>
<script>setTimeout(() => window.location.href = '/storage', 1500);</script>
''')
@router.get("/{storage_id}")
async def get_storage(
storage_id: int,
user: dict = Depends(require_auth),
):
"""Get storage details."""
import db
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
return storage
@router.delete("/{storage_id}")
async def delete_storage(
storage_id: int,
request: Request,
user: dict = Depends(require_auth),
):
"""Delete a storage provider."""
import db
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.remove_user_storage(storage_id)
if wants_html(request):
return HTMLResponse("")
return {"deleted": True}
@router.post("/{storage_id}/test")
async def test_storage(
storage_id: int,
request: Request,
user: dict = Depends(require_auth),
):
"""Test storage connectivity."""
import db
import storage_providers
import json
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
config = storage["config"]
if isinstance(config, str):
config = json.loads(config)
provider = storage_providers.create_provider(storage["provider_type"], {
**config,
"capacity_gb": storage.get("capacity_gb", 5),
})
if not provider:
if wants_html(request):
return HTMLResponse('<span class="text-red-400">Failed to create provider</span>')
return {"success": False, "message": "Failed to create provider"}
success, message = await provider.test_connection()
if wants_html(request):
color = "green" if success else "red"
return HTMLResponse(f'<span class="text-{color}-400">{message}</span>')
return {"success": success, "message": message}

161
app/routers/users.py Normal file
View File

@@ -0,0 +1,161 @@
"""
User profile routes for L2 server.
Handles ActivityPub actor profiles.
"""
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from artdag_common import render
from artdag_common.middleware import wants_html
from ..config import settings
from ..dependencies import get_templates, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/users/{username}")
async def get_user_profile(
username: str,
request: Request,
):
"""Get user profile (ActivityPub actor)."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
# ActivityPub response
accept = request.headers.get("accept", "")
if "application/activity+json" in accept or "application/ld+json" in accept:
actor = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"type": "Person",
"id": f"https://{settings.domain}/users/{username}",
"name": user.get("display_name", username),
"preferredUsername": username,
"inbox": f"https://{settings.domain}/users/{username}/inbox",
"outbox": f"https://{settings.domain}/users/{username}/outbox",
"publicKey": {
"id": f"https://{settings.domain}/users/{username}#main-key",
"owner": f"https://{settings.domain}/users/{username}",
"publicKeyPem": user.get("public_key", ""),
},
}
return JSONResponse(content=actor, media_type="application/activity+json")
# HTML profile page
current_user = get_user_from_cookie(request)
assets = await db.get_user_assets(username, limit=12)
templates = get_templates(request)
return render(templates, "users/profile.html", request,
profile=user,
assets=assets,
user={"username": current_user} if current_user else None,
)
@router.get("/users/{username}/outbox")
async def get_outbox(
username: str,
request: Request,
page: bool = False,
):
"""Get user's outbox (ActivityPub)."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
actor_id = f"https://{settings.domain}/users/{username}"
if not page:
# Return collection summary
total = await db.count_user_activities(username)
return JSONResponse(
content={
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": f"{actor_id}/outbox",
"totalItems": total,
"first": f"{actor_id}/outbox?page=true",
},
media_type="application/activity+json",
)
# Return paginated activities
activities = await db.get_user_activities(username, limit=20)
items = [a.get("activity_json", a) for a in activities]
return JSONResponse(
content={
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": f"{actor_id}/outbox?page=true",
"partOf": f"{actor_id}/outbox",
"orderedItems": items,
},
media_type="application/activity+json",
)
@router.post("/users/{username}/inbox")
async def receive_inbox(
username: str,
request: Request,
):
"""Receive ActivityPub inbox message."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
# TODO: Verify HTTP signature
# TODO: Process activity (Follow, Like, Announce, etc.)
body = await request.json()
logger.info(f"Received inbox activity for {username}: {body.get('type')}")
# For now, just acknowledge
return {"status": "accepted"}
@router.get("/")
async def home(request: Request):
"""Home page."""
import db
import markdown
username = get_user_from_cookie(request)
# Get recent activities
activities, _ = await db.get_activities_paginated(limit=10)
# Get README if exists
readme_html = ""
try:
from pathlib import Path
readme_path = Path(__file__).parent.parent.parent / "README.md"
if readme_path.exists():
readme_html = markdown.markdown(readme_path.read_text(), extensions=['tables', 'fenced_code'])
except Exception:
pass
templates = get_templates(request)
return render(templates, "home.html", request,
user={"username": username} if username else None,
activities=activities,
readme_html=readme_html,
)

11
app/templates/404.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Not Found - Art-DAG{% endblock %}
{% block content %}
<div class="text-center py-16">
<h2 class="text-6xl font-bold text-gray-600 mb-4">404</h2>
<p class="text-xl text-gray-400 mb-8">Page not found</p>
<a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Activities - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Activities</h1>
</div>
{% if activities %}
<div class="space-y-4">
{% for activity in activities %}
<a href="/activities/{{ activity.activity_id }}"
class="block bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="text-blue-400 font-medium">{{ activity.activity_type }}</span>
<span class="text-gray-500 text-sm">{{ activity.published }}</span>
</div>
<div class="text-gray-300 text-sm truncate">
{{ activity.actor_id }}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div class="mt-6 text-center">
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
class="text-blue-400 hover:text-blue-300">Load More</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No activities yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Anchors - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Bitcoin Anchors</h1>
</div>
{% if anchors %}
<div class="space-y-4">
{% for anchor in anchors %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="font-mono text-sm text-blue-400 truncate">{{ anchor.merkle_root[:16] }}...</span>
{% if anchor.confirmed_at %}
<span class="bg-green-600 text-xs px-2 py-1 rounded">Confirmed</span>
{% else %}
<span class="bg-yellow-600 text-xs px-2 py-1 rounded">Pending</span>
{% endif %}
</div>
<div class="text-gray-400 text-sm">
{{ anchor.activity_count or 0 }} activities | Created: {{ anchor.created_at }}
</div>
{% if anchor.bitcoin_txid %}
<div class="mt-2 text-xs text-gray-500 font-mono truncate">
TX: {{ anchor.bitcoin_txid }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if has_more %}
<div class="mt-6 text-center">
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
class="text-blue-400 hover:text-blue-300">Load More</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No anchors yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Assets - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Your Assets</h1>
{% if assets %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="assets-grid">
{% for asset in assets %}
<a href="/assets/{{ asset.id }}"
class="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all">
{% if asset.asset_type == 'image' %}
<img src="{{ asset.thumbnail_url or '/assets/' + asset.id + '/thumb' }}"
alt="{{ asset.name }}"
class="w-full h-40 object-cover">
{% elif asset.asset_type == 'video' %}
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
</svg>
</div>
{% else %}
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
<span class="text-gray-600">{{ asset.asset_type }}</span>
</div>
{% endif %}
<div class="p-3">
<div class="font-medium text-white truncate">{{ asset.name }}</div>
<div class="text-xs text-gray-500">{{ asset.asset_type }}</div>
{% if asset.ipfs_cid %}
<div class="text-xs text-green-400 mt-1">Published</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/assets?offset={{ offset + limit }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-target="#assets-grid"
class="h-20 flex items-center justify-center text-gray-500 mt-4">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No assets yet</p>
<p class="text-gray-600 text-sm">Create content on an L1 renderer and publish it here.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Already Logged In - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto text-center">
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
You are already logged in as <strong>{{ user.username }}</strong>
</div>
<p><a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Login - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<h2 class="text-xl font-semibold mb-6">Login</h2>
<div id="login-result"></div>
<form hx-post="/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4">
{% if return_to %}
<input type="hidden" name="return_to" value="{{ return_to }}">
{% endif %}
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
Login
</button>
</form>
<p class="mt-6 text-gray-400">
Don't have an account? <a href="/auth/register" class="text-blue-400 hover:text-blue-300">Register</a>
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Register - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<h2 class="text-xl font-semibold mb-6">Register</h2>
<div id="register-result"></div>
<form hx-post="/auth/register" hx-target="#register-result" hx-swap="innerHTML" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email (optional)</label>
<input type="email" id="email" name="email"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required minlength="6"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password2" class="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
<input type="password" id="password2" name="password2" required minlength="6"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
Register
</button>
</form>
<p class="mt-6 text-gray-400">
Already have an account? <a href="/auth/login" class="text-blue-400 hover:text-blue-300">Login</a>
</p>
</div>
{% endblock %}

47
app/templates/base.html Normal file
View File

@@ -0,0 +1,47 @@
{% extends "_base.html" %}
{% block brand %}
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
<span class="text-stone-400 mx-1">|</span>
<a href="/" class="no-underline text-stone-900">Art-DAG</a>
<span class="text-stone-400 mx-1">/</span>
<span class="text-stone-600 text-3xl">L2</span>
{% endblock %}
{% block cart_mini %}
{% if request and request.state.cart_mini_html %}
{{ request.state.cart_mini_html | safe }}
{% endif %}
{% endblock %}
{% block nav_tree %}
{% if request and request.state.nav_tree_html %}
{{ request.state.nav_tree_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu_mobile %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block sub_nav %}
<div class="bg-stone-200 border-b border-stone-300">
<div class="max-w-screen-2xl mx-auto px-4">
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
<a href="/assets" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'assets' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Assets</a>
<a href="/activities" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'activities' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Activities</a>
<a href="/anchors" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'anchors' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Anchors</a>
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage</a>
<a href="/renderers" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'renderers' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Renderers</a>
</nav>
</div>
</div>
{% endblock %}

42
app/templates/home.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
{% if readme_html %}
<div class="prose prose-invert max-w-none mb-12">
{{ readme_html | safe }}
</div>
{% else %}
<div class="text-center py-12">
<h1 class="text-4xl font-bold mb-4">Art-DAG</h1>
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media with ActivityPub Federation</p>
{% if not user %}
<div class="flex justify-center space-x-4">
<a href="/auth/login" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">Login</a>
<a href="/auth/register" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">Register</a>
</div>
{% endif %}
</div>
{% endif %}
{% if activities %}
<h2 class="text-2xl font-bold mb-4">Recent Activity</h2>
<div class="space-y-4">
{% for activity in activities %}
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-blue-400">{{ activity.actor }}</span>
<span class="text-gray-500 text-sm">{{ activity.created_at }}</span>
</div>
<div class="text-gray-300">
{{ activity.type }}: {{ activity.summary or activity.object_type }}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Renderers</h1>
<p class="text-gray-400 mb-6">
Renderers are L1 servers that process your media. Connect to a renderer to create and run recipes.
</p>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded mb-6">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded mb-6">
{{ success }}
</div>
{% endif %}
<div class="space-y-4">
{% for server in servers %}
<div class="bg-gray-800 rounded-lg p-4 flex items-center justify-between">
<div>
<a href="{{ server.url }}" target="_blank" class="text-blue-400 hover:text-blue-300 font-medium">
{{ server.url }}
</a>
{% if server.healthy %}
<span class="ml-2 text-green-400 text-sm">Online</span>
{% else %}
<span class="ml-2 text-red-400 text-sm">Offline</span>
{% endif %}
</div>
<div class="flex gap-2">
<a href="{{ server.url }}/auth?auth_token={{ user.token }}"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-sm">
Connect
</a>
</div>
</div>
{% else %}
<p class="text-gray-500">No renderers configured.</p>
{% endfor %}
</div>
<div class="mt-8 text-gray-500 text-sm">
<p>Renderers are configured by the system administrator.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Storage - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Storage Providers</h1>
<a href="/storage/add" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm">
Add Storage
</a>
</div>
{% if storages %}
<div class="space-y-4">
{% for storage in storages %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">{{ storage.name or storage.provider_type }}</span>
<span class="text-xs px-2 py-1 rounded {% if storage.is_active %}bg-green-600{% else %}bg-gray-600{% endif %}">
{{ storage.provider_type }}
</span>
</div>
<div class="text-gray-400 text-sm">
{% if storage.endpoint %}
{{ storage.endpoint }}
{% elif storage.bucket %}
Bucket: {{ storage.bucket }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No storage providers configured.</p>
<a href="/storage/add" class="text-blue-400 hover:text-blue-300 mt-2 inline-block">Add one now</a>
</div>
{% endif %}
</div>
{% endblock %}