From d1e928782908048aae5b81224a3d55be60391eb0 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 11 Jan 2026 07:46:26 +0000 Subject: [PATCH] Add modular app structure for L2 server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create app factory with routers and templates - Auth, assets, activities, anchors, storage, users, renderers routers - Federation router for WebFinger and nodeinfo - Jinja2 templates for L2 pages - Config and dependency injection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/__init__.py | 77 +++++++ app/config.py | 56 +++++ app/dependencies.py | 80 +++++++ app/routers/__init__.py | 25 +++ app/routers/activities.py | 99 +++++++++ app/routers/anchors.py | 203 +++++++++++++++++ app/routers/assets.py | 243 +++++++++++++++++++++ app/routers/auth.py | 175 +++++++++++++++ app/routers/federation.py | 115 ++++++++++ app/routers/renderers.py | 150 +++++++++++++ app/routers/storage.py | 254 ++++++++++++++++++++++ app/routers/users.py | 161 ++++++++++++++ app/templates/404.html | 11 + app/templates/assets/list.html | 58 +++++ app/templates/auth/already_logged_in.html | 12 + app/templates/auth/login.html | 37 ++++ app/templates/auth/register.html | 45 ++++ app/templates/base.html | 27 +++ app/templates/home.html | 42 ++++ 19 files changed, 1870 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/dependencies.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/activities.py create mode 100644 app/routers/anchors.py create mode 100644 app/routers/assets.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/federation.py create mode 100644 app/routers/renderers.py create mode 100644 app/routers/storage.py create mode 100644 app/routers/users.py create mode 100644 app/templates/404.html create mode 100644 app/templates/assets/list.html create mode 100644 app/templates/auth/already_logged_in.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/home.html diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..c662b7f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,77 @@ +""" +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 .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, + ) + + # 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() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d88d435 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..d10d063 --- /dev/null +++ b/app/dependencies.py @@ -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) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..8365296 --- /dev/null +++ b/app/routers/__init__.py @@ -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", +] diff --git a/app/routers/activities.py b/app/routers/activities.py new file mode 100644 index 0000000..49c25d6 --- /dev/null +++ b/app/routers/activities.py @@ -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 = await db.get_activities(offset=offset, limit=limit) + has_more = len(activities) >= limit + + 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} diff --git a/app/routers/anchors.py b/app/routers/anchors.py new file mode 100644 index 0000000..3416b40 --- /dev/null +++ b/app/routers/anchors.py @@ -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_user_anchors(username, 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'Confirmed in block {result["block_height"]}' + ) + return HTMLResponse('Pending confirmation') + + 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} diff --git a/app/routers/assets.py b/app/routers/assets.py new file mode 100644 index 0000000..2cb6cd6 --- /dev/null +++ b/app/routers/assets.py @@ -0,0 +1,243 @@ +""" +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_id = await db.create_asset( + username=user["username"], + name=req.name, + content_hash=req.content_hash, + ipfs_cid=req.ipfs_cid, + asset_type=req.asset_type, + tags=req.tags, + metadata=req.metadata, + provenance=req.provenance, + ) + + if not asset_id: + raise HTTPException(400, "Failed to create asset") + + return {"asset_id": asset_id, "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_id = await db.create_asset( + username=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, + ) + + # Record run + await db.record_run( + run_id=req.run_id, + username=user["username"], + recipe=req.recipe, + inputs=req.inputs, + 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") diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..350b9bb --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,175 @@ +""" +Authentication routes for L2 server. + +Handles login, registration, and logout. +""" + +import hashlib +from datetime import datetime, timezone + +from fastapi import APIRouter, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse + +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() + + +@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( + '
Username and password are required
' + ) + + user = await authenticate_user(settings.data_dir, username.strip(), password) + if not user: + return HTMLResponse( + '
Invalid username or password
' + ) + + 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''' +
Login successful! Redirecting...
+ + ''') + else: + response = HTMLResponse(''' +
Login successful! Redirecting...
+ + ''') + + 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('
Username and password are required
') + + if password != password2: + return HTMLResponse('
Passwords do not match
') + + if len(password) < 6: + return HTMLResponse('
Password must be at least 6 characters
') + + try: + user = await create_user(settings.data_dir, username.strip(), password, email) + except ValueError as e: + return HTMLResponse(f'
{str(e)}
') + + token = create_access_token(user.username, l2_server=f"https://{settings.domain}") + + response = HTMLResponse(''' +
Registration successful! Redirecting...
+ + ''') + 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 diff --git a/app/routers/federation.py b/app/routers/federation.py new file mode 100644 index 0000000..ab3fb4f --- /dev/null +++ b/app/routers/federation.py @@ -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''' + + +''' + from fastapi.responses import Response + return Response(content=xml, media_type="application/xrd+xml") diff --git a/app/routers/renderers.py b/app/routers/renderers.py new file mode 100644 index 0000000..58562f2 --- /dev/null +++ b/app/routers/renderers.py @@ -0,0 +1,150 @@ +""" +Renderer (L1) management routes for L2 server. + +Handles L1 server attachments and delegation. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Request, Depends, HTTPException +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 AttachRendererRequest(BaseModel): + l1_url: str + + +@router.get("") +async def list_renderers( + request: Request, + user: dict = Depends(require_auth), +): + """List attached L1 renderers.""" + import db + + renderers = await db.get_user_renderers(user["username"]) + + # Add status info + for r in renderers: + r["known"] = r["url"] in settings.l1_servers + + if wants_json(request): + return {"renderers": renderers, "known_servers": settings.l1_servers} + + templates = get_templates(request) + return render(templates, "renderers/list.html", request, + renderers=renderers, + known_servers=settings.l1_servers, + user=user, + active_tab="renderers", + ) + + +@router.post("") +async def attach_renderer( + req: AttachRendererRequest, + user: dict = Depends(require_auth), +): + """Attach an L1 renderer.""" + import db + + l1_url = req.l1_url.rstrip("/") + + # Validate URL + if not l1_url.startswith("http"): + raise HTTPException(400, "Invalid URL") + + # Check if already attached + existing = await db.get_user_renderers(user["username"]) + if l1_url in [r["url"] for r in existing]: + raise HTTPException(400, "Renderer already attached") + + # Test connection + try: + import requests + resp = requests.get(f"{l1_url}/health", timeout=10) + if resp.status_code != 200: + raise HTTPException(400, f"L1 server not healthy: {resp.status_code}") + except Exception as e: + raise HTTPException(400, f"Failed to connect to L1: {e}") + + # Attach + await db.attach_renderer(user["username"], l1_url) + + return {"attached": True, "url": l1_url} + + +@router.delete("/{renderer_id}") +async def detach_renderer( + renderer_id: int, + user: dict = Depends(require_auth), +): + """Detach an L1 renderer.""" + import db + + success = await db.detach_renderer(user["username"], renderer_id) + if not success: + raise HTTPException(404, "Renderer not found") + + return {"detached": True} + + +@router.post("/{renderer_id}/sync") +async def sync_renderer( + renderer_id: int, + request: Request, + user: dict = Depends(require_auth), +): + """Sync assets with an L1 renderer.""" + import db + import requests + + renderer = await db.get_renderer(renderer_id) + if not renderer: + raise HTTPException(404, "Renderer not found") + + if renderer.get("username") != user["username"]: + raise HTTPException(403, "Not authorized") + + l1_url = renderer["url"] + + # Get user's assets + assets = await db.get_user_assets(user["username"]) + + synced = 0 + errors = [] + + for asset in assets: + if asset.get("ipfs_cid"): + try: + # Tell L1 to import from IPFS + resp = requests.post( + f"{l1_url}/cache/import", + json={"ipfs_cid": asset["ipfs_cid"]}, + headers={"Authorization": f"Bearer {user['token']}"}, + timeout=30, + ) + if resp.status_code == 200: + synced += 1 + else: + errors.append(f"{asset['name']}: {resp.status_code}") + except Exception as e: + errors.append(f"{asset['name']}: {e}") + + if wants_html(request): + if errors: + return HTMLResponse(f'Synced {synced}, {len(errors)} errors') + return HTMLResponse(f'Synced {synced} assets') + + return {"synced": synced, "errors": errors} diff --git a/app/routers/storage.py b/app/routers/storage.py new file mode 100644 index 0000000..f9cdcaf --- /dev/null +++ b/app/routers/storage.py @@ -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('
Not authenticated
', status_code=401) + + # Build config + config = {} + if provider_type == "pinata": + if not api_key or not secret_key: + return HTMLResponse('
Pinata requires API Key and Secret Key
') + config = {"api_key": api_key, "secret_key": secret_key} + elif provider_type in ["web3storage", "nftstorage"]: + if not api_token: + return HTMLResponse(f'
{provider_type} requires API Token
') + config = {"api_token": api_token} + elif provider_type == "infura": + if not project_id or not project_secret: + return HTMLResponse('
Infura requires Project ID and Secret
') + 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('
Requires Access Key, Secret Key, and Bucket
') + config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket} + elif provider_type == "local": + if not path: + return HTMLResponse('
Local storage requires a path
') + config = {"path": path} + else: + return HTMLResponse(f'
Unknown provider: {provider_type}
') + + # 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'
Connection failed: {message}
') + + # 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''' +
Storage provider added!
+ + ''') + + +@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('Failed to create provider') + 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'{message}') + + return {"success": success, "message": message} diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..ee57ae0 --- /dev/null +++ b/app/routers/users.py @@ -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(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()) + 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, + ) diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..f6dbdcb --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Not Found - Art-DAG{% endblock %} + +{% block content %} +
+

404

+

Page not found

+ Go to home page +
+{% endblock %} diff --git a/app/templates/assets/list.html b/app/templates/assets/list.html new file mode 100644 index 0000000..b82f3b1 --- /dev/null +++ b/app/templates/assets/list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Assets - Art-DAG{% endblock %} + +{% block content %} +
+

Your Assets

+ + {% if assets %} + + + {% if has_more %} +
+ Loading more... +
+ {% endif %} + + {% else %} +
+

No assets yet

+

Create content on an L1 renderer and publish it here.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/auth/already_logged_in.html b/app/templates/auth/already_logged_in.html new file mode 100644 index 0000000..aa94799 --- /dev/null +++ b/app/templates/auth/already_logged_in.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}Already Logged In - Art-DAG{% endblock %} + +{% block content %} +
+
+ You are already logged in as {{ user.username }} +
+

Go to home page

+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..0ba4e66 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Login - Art-DAG{% endblock %} + +{% block content %} +
+

Login

+ +
+ +
+ {% if return_to %} + + {% endif %} + +
+ + +
+ +
+ + +
+ + +
+ +

+ Don't have an account? Register +

+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..8a1837e --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Register - Art-DAG{% endblock %} + +{% block content %} +
+

Register

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? Login +

+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..e76a084 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block brand %}Art-DAG{% endblock %} + +{% block nav_items %} + +{% endblock %} + +{% block nav_right %} +{% if user %} + +{% else %} + +{% endif %} +{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..1898981 --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Art-DAG{% endblock %} + +{% block content %} +
+ {% if readme_html %} +
+ {{ readme_html | safe }} +
+ {% else %} +
+

Art-DAG

+

Content-Addressable Media with ActivityPub Federation

+ + {% if not user %} +
+ Login + Register +
+ {% endif %} +
+ {% endif %} + + {% if activities %} +

Recent Activity

+
+ {% for activity in activities %} +
+
+ {{ activity.actor }} + {{ activity.created_at }} +
+
+ {{ activity.type }}: {{ activity.summary or activity.object_type }} +
+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %}