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
+
+
+
+
+
+
+ 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 %}
+
+ Assets
+ Activities
+ Anchors
+ Storage
+ Renderers
+
+{% 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 %}
+
+ {% 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 %}