Add modular app structure for L2 server
- 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 <noreply@anthropic.com>
This commit is contained in:
77
app/__init__.py
Normal file
77
app/__init__.py
Normal file
@@ -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()
|
||||||
56
app/config.py
Normal file
56
app/config.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
L2 Server Configuration.
|
||||||
|
|
||||||
|
Environment-based settings for the ActivityPub server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
"""L2 Server configuration."""
|
||||||
|
|
||||||
|
# Domain and URLs
|
||||||
|
domain: str = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com")
|
||||||
|
l1_public_url: str = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash.com")
|
||||||
|
effects_repo_url: str = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
|
||||||
|
ipfs_gateway_url: str = os.environ.get("IPFS_GATEWAY_URL", "")
|
||||||
|
|
||||||
|
# L1 servers
|
||||||
|
l1_servers: list = None
|
||||||
|
|
||||||
|
# Cookie domain for cross-subdomain auth
|
||||||
|
cookie_domain: str = None
|
||||||
|
|
||||||
|
# Data directory
|
||||||
|
data_dir: Path = None
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
jwt_secret: str = os.environ.get("JWT_SECRET", "")
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24 * 30 # 30 days
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Parse L1 servers
|
||||||
|
l1_str = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com")
|
||||||
|
self.l1_servers = [s.strip() for s in l1_str.split(",") if s.strip()]
|
||||||
|
|
||||||
|
# Cookie domain
|
||||||
|
env_cookie = os.environ.get("COOKIE_DOMAIN")
|
||||||
|
if env_cookie:
|
||||||
|
self.cookie_domain = env_cookie
|
||||||
|
else:
|
||||||
|
parts = self.domain.split(".")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
self.cookie_domain = "." + ".".join(parts[-2:])
|
||||||
|
|
||||||
|
# Data directory
|
||||||
|
self.data_dir = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
||||||
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(self.data_dir / "assets").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
80
app/dependencies.py
Normal file
80
app/dependencies.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
L2 Server Dependency Injection.
|
||||||
|
|
||||||
|
Provides common dependencies for routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Request, HTTPException, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_templates(request: Request):
|
||||||
|
"""Get Jinja2 templates from app state."""
|
||||||
|
return request.app.state.templates
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get current user from cookie or header.
|
||||||
|
|
||||||
|
Returns user dict or None if not authenticated.
|
||||||
|
"""
|
||||||
|
from auth import verify_token, get_token_claims
|
||||||
|
|
||||||
|
# Try cookie first
|
||||||
|
token = request.cookies.get("auth_token")
|
||||||
|
|
||||||
|
# Try Authorization header
|
||||||
|
if not token:
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify token
|
||||||
|
username = verify_token(token)
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get full claims
|
||||||
|
claims = get_token_claims(token)
|
||||||
|
if not claims:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"actor_id": f"https://{settings.domain}/users/{username}",
|
||||||
|
"token": token,
|
||||||
|
**claims,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(request: Request) -> dict:
|
||||||
|
"""
|
||||||
|
Require authentication.
|
||||||
|
|
||||||
|
Raises HTTPException 401 if not authenticated.
|
||||||
|
"""
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_from_cookie(request: Request) -> Optional[str]:
|
||||||
|
"""Get username from cookie (for HTML pages)."""
|
||||||
|
from auth import verify_token
|
||||||
|
|
||||||
|
token = request.cookies.get("auth_token")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return verify_token(token)
|
||||||
25
app/routers/__init__.py
Normal file
25
app/routers/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
L2 Server Routers.
|
||||||
|
|
||||||
|
Each router handles a specific domain of functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
from . import assets
|
||||||
|
from . import activities
|
||||||
|
from . import anchors
|
||||||
|
from . import storage
|
||||||
|
from . import users
|
||||||
|
from . import renderers
|
||||||
|
from . import federation
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"auth",
|
||||||
|
"assets",
|
||||||
|
"activities",
|
||||||
|
"anchors",
|
||||||
|
"storage",
|
||||||
|
"users",
|
||||||
|
"renderers",
|
||||||
|
"federation",
|
||||||
|
]
|
||||||
99
app/routers/activities.py
Normal file
99
app/routers/activities.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Activity routes for L2 server.
|
||||||
|
|
||||||
|
Handles ActivityPub activities and outbox.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from artdag_common import render
|
||||||
|
from artdag_common.middleware import wants_html, wants_json
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import get_templates, require_auth, get_user_from_cookie
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_activities(
|
||||||
|
request: Request,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
):
|
||||||
|
"""List recent activities."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
|
||||||
|
activities = 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}
|
||||||
203
app/routers/anchors.py
Normal file
203
app/routers/anchors.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Anchor routes for L2 server.
|
||||||
|
|
||||||
|
Handles OpenTimestamps anchoring and verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
|
||||||
|
from artdag_common import render
|
||||||
|
from artdag_common.middleware import wants_html, wants_json
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import get_templates, require_auth, get_user_from_cookie
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_anchors(
|
||||||
|
request: Request,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
):
|
||||||
|
"""List user's anchors."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
if not username:
|
||||||
|
if wants_json(request):
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
anchors = await db.get_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'<span class="text-green-400">Confirmed in block {result["block_height"]}</span>'
|
||||||
|
)
|
||||||
|
return HTMLResponse('<span class="text-yellow-400">Pending confirmation</span>')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Verification failed: {e}")
|
||||||
|
raise HTTPException(500, f"Verification failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{anchor_id}")
|
||||||
|
async def delete_anchor(
|
||||||
|
anchor_id: str,
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Delete an anchor."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
anchor = await db.get_anchor(anchor_id)
|
||||||
|
if not anchor:
|
||||||
|
raise HTTPException(404, "Anchor not found")
|
||||||
|
|
||||||
|
if anchor.get("username") != user["username"]:
|
||||||
|
raise HTTPException(403, "Not authorized")
|
||||||
|
|
||||||
|
success = await db.delete_anchor(anchor_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(400, "Failed to delete anchor")
|
||||||
|
|
||||||
|
return {"deleted": True}
|
||||||
243
app/routers/assets.py
Normal file
243
app/routers/assets.py
Normal file
@@ -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")
|
||||||
175
app/routers/auth.py
Normal file
175
app/routers/auth.py
Normal file
@@ -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(
|
||||||
|
'<div class="text-red-400">Username and password are required</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await authenticate_user(settings.data_dir, username.strip(), password)
|
||||||
|
if not user:
|
||||||
|
return HTMLResponse(
|
||||||
|
'<div class="text-red-400">Invalid username or password</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
|
||||||
|
|
||||||
|
# Handle return_to redirect
|
||||||
|
if return_to and return_to.startswith("http"):
|
||||||
|
separator = "&" if "?" in return_to else "?"
|
||||||
|
redirect_url = f"{return_to}{separator}auth_token={token.access_token}"
|
||||||
|
response = HTMLResponse(f'''
|
||||||
|
<div class="text-green-400">Login successful! Redirecting...</div>
|
||||||
|
<script>window.location.href = "{redirect_url}";</script>
|
||||||
|
''')
|
||||||
|
else:
|
||||||
|
response = HTMLResponse('''
|
||||||
|
<div class="text-green-400">Login successful! Redirecting...</div>
|
||||||
|
<script>window.location.href = "/";</script>
|
||||||
|
''')
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key="auth_token",
|
||||||
|
value=token.access_token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=60 * 60 * 24 * 30,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register", response_class=HTMLResponse)
|
||||||
|
async def register_page(request: Request):
|
||||||
|
"""Registration page."""
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
|
||||||
|
if username:
|
||||||
|
templates = get_templates(request)
|
||||||
|
return render(templates, "auth/already_logged_in.html", request,
|
||||||
|
user={"username": username},
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = get_templates(request)
|
||||||
|
return render(templates, "auth/register.html", request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_class=HTMLResponse)
|
||||||
|
async def register_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
password2: str = Form(...),
|
||||||
|
email: str = Form(None),
|
||||||
|
):
|
||||||
|
"""Handle registration form submission."""
|
||||||
|
from auth import create_user, create_access_token
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Username and password are required</div>')
|
||||||
|
|
||||||
|
if password != password2:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Passwords do not match</div>')
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Password must be at least 6 characters</div>')
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await create_user(settings.data_dir, username.strip(), password, email)
|
||||||
|
except ValueError as e:
|
||||||
|
return HTMLResponse(f'<div class="text-red-400">{str(e)}</div>')
|
||||||
|
|
||||||
|
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
|
||||||
|
|
||||||
|
response = HTMLResponse('''
|
||||||
|
<div class="text-green-400">Registration successful! Redirecting...</div>
|
||||||
|
<script>window.location.href = "/";</script>
|
||||||
|
''')
|
||||||
|
response.set_cookie(
|
||||||
|
key="auth_token",
|
||||||
|
value=token.access_token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=60 * 60 * 24 * 30,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Handle logout."""
|
||||||
|
import db
|
||||||
|
import requests
|
||||||
|
from auth import get_token_claims
|
||||||
|
|
||||||
|
token = request.cookies.get("auth_token")
|
||||||
|
claims = get_token_claims(token) if token else None
|
||||||
|
username = claims.get("sub") if claims else None
|
||||||
|
|
||||||
|
if username and token and claims:
|
||||||
|
# Revoke token in database
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc)
|
||||||
|
await db.revoke_token(token_hash, username, expires_at)
|
||||||
|
|
||||||
|
# Revoke on attached L1 servers
|
||||||
|
attached = await db.get_user_renderers(username)
|
||||||
|
for l1_url in attached:
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{l1_url}/auth/revoke-user",
|
||||||
|
json={"username": username, "l2_server": f"https://{settings.domain}"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
response.delete_cookie("auth_token")
|
||||||
|
return response
|
||||||
115
app/routers/federation.py
Normal file
115
app/routers/federation.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Federation routes for L2 server.
|
||||||
|
|
||||||
|
Handles WebFinger, nodeinfo, and ActivityPub discovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/.well-known/webfinger")
|
||||||
|
async def webfinger(resource: str):
|
||||||
|
"""WebFinger endpoint for actor discovery."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
# Parse resource (acct:username@domain)
|
||||||
|
if not resource.startswith("acct:"):
|
||||||
|
raise HTTPException(400, "Invalid resource format")
|
||||||
|
|
||||||
|
parts = resource[5:].split("@")
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise HTTPException(400, "Invalid resource format")
|
||||||
|
|
||||||
|
username, domain = parts
|
||||||
|
|
||||||
|
if domain != settings.domain:
|
||||||
|
raise HTTPException(404, "User not on this server")
|
||||||
|
|
||||||
|
user = await db.get_user(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"subject": resource,
|
||||||
|
"aliases": [f"https://{settings.domain}/users/{username}"],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/activity+json",
|
||||||
|
"href": f"https://{settings.domain}/users/{username}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
|
"type": "text/html",
|
||||||
|
"href": f"https://{settings.domain}/users/{username}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
media_type="application/jrd+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/.well-known/nodeinfo")
|
||||||
|
async def nodeinfo_index():
|
||||||
|
"""NodeInfo index."""
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||||
|
"href": f"https://{settings.domain}/nodeinfo/2.0",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nodeinfo/2.0")
|
||||||
|
async def nodeinfo():
|
||||||
|
"""NodeInfo 2.0 endpoint."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
user_count = await db.count_users()
|
||||||
|
activity_count = await db.count_activities()
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"version": "2.0",
|
||||||
|
"software": {
|
||||||
|
"name": "artdag",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
"protocols": ["activitypub"],
|
||||||
|
"usage": {
|
||||||
|
"users": {"total": user_count, "activeMonth": user_count},
|
||||||
|
"localPosts": activity_count,
|
||||||
|
},
|
||||||
|
"openRegistrations": True,
|
||||||
|
"metadata": {
|
||||||
|
"nodeName": "Art-DAG",
|
||||||
|
"nodeDescription": "Content-addressable media processing with ActivityPub federation",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/.well-known/host-meta")
|
||||||
|
async def host_meta():
|
||||||
|
"""Host-meta endpoint."""
|
||||||
|
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||||
|
<Link rel="lrdd" type="application/xrd+xml" template="https://{settings.domain}/.well-known/webfinger?resource={{uri}}"/>
|
||||||
|
</XRD>'''
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(content=xml, media_type="application/xrd+xml")
|
||||||
150
app/routers/renderers.py
Normal file
150
app/routers/renderers.py
Normal file
@@ -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'<span class="text-yellow-400">Synced {synced}, {len(errors)} errors</span>')
|
||||||
|
return HTMLResponse(f'<span class="text-green-400">Synced {synced} assets</span>')
|
||||||
|
|
||||||
|
return {"synced": synced, "errors": errors}
|
||||||
254
app/routers/storage.py
Normal file
254
app/routers/storage.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Storage provider routes for L2 server.
|
||||||
|
|
||||||
|
Manages user storage backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from artdag_common import render
|
||||||
|
from artdag_common.middleware import wants_html, wants_json
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import get_templates, require_auth, get_user_from_cookie
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
STORAGE_PROVIDERS_INFO = {
|
||||||
|
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
|
||||||
|
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
|
||||||
|
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
|
||||||
|
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
|
||||||
|
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
|
||||||
|
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
|
||||||
|
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AddStorageRequest(BaseModel):
|
||||||
|
provider_type: str
|
||||||
|
config: Dict[str, Any]
|
||||||
|
capacity_gb: int = 5
|
||||||
|
provider_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_storage(request: Request):
|
||||||
|
"""List user's storage providers."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
if not username:
|
||||||
|
if wants_json(request):
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
storages = await db.get_user_storage(username)
|
||||||
|
|
||||||
|
if wants_json(request):
|
||||||
|
return {"storages": storages}
|
||||||
|
|
||||||
|
templates = get_templates(request)
|
||||||
|
return render(templates, "storage/list.html", request,
|
||||||
|
storages=storages,
|
||||||
|
user={"username": username},
|
||||||
|
providers_info=STORAGE_PROVIDERS_INFO,
|
||||||
|
active_tab="storage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def add_storage(
|
||||||
|
req: AddStorageRequest,
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Add a storage provider."""
|
||||||
|
import db
|
||||||
|
import storage_providers
|
||||||
|
|
||||||
|
if req.provider_type not in STORAGE_PROVIDERS_INFO:
|
||||||
|
raise HTTPException(400, f"Invalid provider type: {req.provider_type}")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
provider = storage_providers.create_provider(req.provider_type, {
|
||||||
|
**req.config,
|
||||||
|
"capacity_gb": req.capacity_gb,
|
||||||
|
})
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(400, "Failed to create provider")
|
||||||
|
|
||||||
|
success, message = await provider.test_connection()
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(400, f"Connection failed: {message}")
|
||||||
|
|
||||||
|
# Save
|
||||||
|
storage_id = await db.add_user_storage(
|
||||||
|
username=user["username"],
|
||||||
|
provider_type=req.provider_type,
|
||||||
|
provider_name=req.provider_name,
|
||||||
|
config=req.config,
|
||||||
|
capacity_gb=req.capacity_gb,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"id": storage_id, "message": "Storage provider added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add", response_class=HTMLResponse)
|
||||||
|
async def add_storage_form(
|
||||||
|
request: Request,
|
||||||
|
provider_type: str = Form(...),
|
||||||
|
provider_name: Optional[str] = Form(None),
|
||||||
|
capacity_gb: int = Form(5),
|
||||||
|
api_key: Optional[str] = Form(None),
|
||||||
|
secret_key: Optional[str] = Form(None),
|
||||||
|
api_token: Optional[str] = Form(None),
|
||||||
|
project_id: Optional[str] = Form(None),
|
||||||
|
project_secret: Optional[str] = Form(None),
|
||||||
|
access_key: Optional[str] = Form(None),
|
||||||
|
bucket: Optional[str] = Form(None),
|
||||||
|
path: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
"""Add storage via HTML form."""
|
||||||
|
import db
|
||||||
|
import storage_providers
|
||||||
|
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
if not username:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
|
||||||
|
|
||||||
|
# Build config
|
||||||
|
config = {}
|
||||||
|
if provider_type == "pinata":
|
||||||
|
if not api_key or not secret_key:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Pinata requires API Key and Secret Key</div>')
|
||||||
|
config = {"api_key": api_key, "secret_key": secret_key}
|
||||||
|
elif provider_type in ["web3storage", "nftstorage"]:
|
||||||
|
if not api_token:
|
||||||
|
return HTMLResponse(f'<div class="text-red-400">{provider_type} requires API Token</div>')
|
||||||
|
config = {"api_token": api_token}
|
||||||
|
elif provider_type == "infura":
|
||||||
|
if not project_id or not project_secret:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Infura requires Project ID and Secret</div>')
|
||||||
|
config = {"project_id": project_id, "project_secret": project_secret}
|
||||||
|
elif provider_type in ["filebase", "storj"]:
|
||||||
|
if not access_key or not secret_key or not bucket:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Requires Access Key, Secret Key, and Bucket</div>')
|
||||||
|
config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}
|
||||||
|
elif provider_type == "local":
|
||||||
|
if not path:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Local storage requires a path</div>')
|
||||||
|
config = {"path": path}
|
||||||
|
else:
|
||||||
|
return HTMLResponse(f'<div class="text-red-400">Unknown provider: {provider_type}</div>')
|
||||||
|
|
||||||
|
# Test
|
||||||
|
provider = storage_providers.create_provider(provider_type, {**config, "capacity_gb": capacity_gb})
|
||||||
|
if provider:
|
||||||
|
success, message = await provider.test_connection()
|
||||||
|
if not success:
|
||||||
|
return HTMLResponse(f'<div class="text-red-400">Connection failed: {message}</div>')
|
||||||
|
|
||||||
|
# Save
|
||||||
|
storage_id = await db.add_user_storage(
|
||||||
|
username=username,
|
||||||
|
provider_type=provider_type,
|
||||||
|
provider_name=provider_name,
|
||||||
|
config=config,
|
||||||
|
capacity_gb=capacity_gb,
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTMLResponse(f'''
|
||||||
|
<div class="text-green-400 mb-2">Storage provider added!</div>
|
||||||
|
<script>setTimeout(() => window.location.href = '/storage', 1500);</script>
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{storage_id}")
|
||||||
|
async def get_storage(
|
||||||
|
storage_id: int,
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Get storage details."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
storage = await db.get_storage_by_id(storage_id)
|
||||||
|
if not storage:
|
||||||
|
raise HTTPException(404, "Storage not found")
|
||||||
|
|
||||||
|
if storage.get("username") != user["username"]:
|
||||||
|
raise HTTPException(403, "Not authorized")
|
||||||
|
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{storage_id}")
|
||||||
|
async def delete_storage(
|
||||||
|
storage_id: int,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Delete a storage provider."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
storage = await db.get_storage_by_id(storage_id)
|
||||||
|
if not storage:
|
||||||
|
raise HTTPException(404, "Storage not found")
|
||||||
|
|
||||||
|
if storage.get("username") != user["username"]:
|
||||||
|
raise HTTPException(403, "Not authorized")
|
||||||
|
|
||||||
|
success = await db.remove_user_storage(storage_id)
|
||||||
|
|
||||||
|
if wants_html(request):
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{storage_id}/test")
|
||||||
|
async def test_storage(
|
||||||
|
storage_id: int,
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Test storage connectivity."""
|
||||||
|
import db
|
||||||
|
import storage_providers
|
||||||
|
import json
|
||||||
|
|
||||||
|
storage = await db.get_storage_by_id(storage_id)
|
||||||
|
if not storage:
|
||||||
|
raise HTTPException(404, "Storage not found")
|
||||||
|
|
||||||
|
if storage.get("username") != user["username"]:
|
||||||
|
raise HTTPException(403, "Not authorized")
|
||||||
|
|
||||||
|
config = storage["config"]
|
||||||
|
if isinstance(config, str):
|
||||||
|
config = json.loads(config)
|
||||||
|
|
||||||
|
provider = storage_providers.create_provider(storage["provider_type"], {
|
||||||
|
**config,
|
||||||
|
"capacity_gb": storage.get("capacity_gb", 5),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
if wants_html(request):
|
||||||
|
return HTMLResponse('<span class="text-red-400">Failed to create provider</span>')
|
||||||
|
return {"success": False, "message": "Failed to create provider"}
|
||||||
|
|
||||||
|
success, message = await provider.test_connection()
|
||||||
|
|
||||||
|
if wants_html(request):
|
||||||
|
color = "green" if success else "red"
|
||||||
|
return HTMLResponse(f'<span class="text-{color}-400">{message}</span>')
|
||||||
|
|
||||||
|
return {"success": success, "message": message}
|
||||||
161
app/routers/users.py
Normal file
161
app/routers/users.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
User profile routes for L2 server.
|
||||||
|
|
||||||
|
Handles ActivityPub actor profiles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from artdag_common import render
|
||||||
|
from artdag_common.middleware import wants_html
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import get_templates, get_user_from_cookie
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{username}")
|
||||||
|
async def get_user_profile(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
"""Get user profile (ActivityPub actor)."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
user = await db.get_user(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
# ActivityPub response
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "application/activity+json" in accept or "application/ld+json" in accept:
|
||||||
|
actor = {
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"type": "Person",
|
||||||
|
"id": f"https://{settings.domain}/users/{username}",
|
||||||
|
"name": user.get("display_name", username),
|
||||||
|
"preferredUsername": username,
|
||||||
|
"inbox": f"https://{settings.domain}/users/{username}/inbox",
|
||||||
|
"outbox": f"https://{settings.domain}/users/{username}/outbox",
|
||||||
|
"publicKey": {
|
||||||
|
"id": f"https://{settings.domain}/users/{username}#main-key",
|
||||||
|
"owner": f"https://{settings.domain}/users/{username}",
|
||||||
|
"publicKeyPem": user.get("public_key", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return JSONResponse(content=actor, media_type="application/activity+json")
|
||||||
|
|
||||||
|
# HTML profile page
|
||||||
|
current_user = get_user_from_cookie(request)
|
||||||
|
assets = await db.get_user_assets(username, limit=12)
|
||||||
|
|
||||||
|
templates = get_templates(request)
|
||||||
|
return render(templates, "users/profile.html", request,
|
||||||
|
profile=user,
|
||||||
|
assets=assets,
|
||||||
|
user={"username": current_user} if current_user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{username}/outbox")
|
||||||
|
async def get_outbox(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
page: bool = False,
|
||||||
|
):
|
||||||
|
"""Get user's outbox (ActivityPub)."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
user = await db.get_user(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
actor_id = f"https://{settings.domain}/users/{username}"
|
||||||
|
|
||||||
|
if not page:
|
||||||
|
# Return collection summary
|
||||||
|
total = await db.count_user_activities(username)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"id": f"{actor_id}/outbox",
|
||||||
|
"totalItems": total,
|
||||||
|
"first": f"{actor_id}/outbox?page=true",
|
||||||
|
},
|
||||||
|
media_type="application/activity+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return paginated activities
|
||||||
|
activities = await db.get_user_activities(username, limit=20)
|
||||||
|
items = [a.get("activity_json", a) for a in activities]
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "OrderedCollectionPage",
|
||||||
|
"id": f"{actor_id}/outbox?page=true",
|
||||||
|
"partOf": f"{actor_id}/outbox",
|
||||||
|
"orderedItems": items,
|
||||||
|
},
|
||||||
|
media_type="application/activity+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/inbox")
|
||||||
|
async def receive_inbox(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
"""Receive ActivityPub inbox message."""
|
||||||
|
import db
|
||||||
|
|
||||||
|
user = await db.get_user(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
# TODO: Verify HTTP signature
|
||||||
|
# TODO: Process activity (Follow, Like, Announce, etc.)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
logger.info(f"Received inbox activity for {username}: {body.get('type')}")
|
||||||
|
|
||||||
|
# For now, just acknowledge
|
||||||
|
return {"status": "accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def home(request: Request):
|
||||||
|
"""Home page."""
|
||||||
|
import db
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
|
||||||
|
# Get recent activities
|
||||||
|
activities = await db.get_activities(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,
|
||||||
|
)
|
||||||
11
app/templates/404.html
Normal file
11
app/templates/404.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Not Found - Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center py-16">
|
||||||
|
<h2 class="text-6xl font-bold text-gray-600 mb-4">404</h2>
|
||||||
|
<p class="text-xl text-gray-400 mb-8">Page not found</p>
|
||||||
|
<a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
58
app/templates/assets/list.html
Normal file
58
app/templates/assets/list.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Assets - Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Your Assets</h1>
|
||||||
|
|
||||||
|
{% if assets %}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="assets-grid">
|
||||||
|
{% for asset in assets %}
|
||||||
|
<a href="/assets/{{ asset.id }}"
|
||||||
|
class="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all">
|
||||||
|
{% if asset.asset_type == 'image' %}
|
||||||
|
<img src="{{ asset.thumbnail_url or '/assets/' + asset.id + '/thumb' }}"
|
||||||
|
alt="{{ asset.name }}"
|
||||||
|
class="w-full h-40 object-cover">
|
||||||
|
{% elif asset.asset_type == 'video' %}
|
||||||
|
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
|
||||||
|
<span class="text-gray-600">{{ asset.asset_type }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="font-medium text-white truncate">{{ asset.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ asset.asset_type }}</div>
|
||||||
|
{% if asset.ipfs_cid %}
|
||||||
|
<div class="text-xs text-green-400 mt-1">Published</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<div hx-get="/assets?offset={{ offset + limit }}"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-target="#assets-grid"
|
||||||
|
class="h-20 flex items-center justify-center text-gray-500 mt-4">
|
||||||
|
Loading more...
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||||
|
<p class="text-gray-500 mb-4">No assets yet</p>
|
||||||
|
<p class="text-gray-600 text-sm">Create content on an L1 renderer and publish it here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
app/templates/auth/already_logged_in.html
Normal file
12
app/templates/auth/already_logged_in.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Already Logged In - Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto text-center">
|
||||||
|
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
|
||||||
|
You are already logged in as <strong>{{ user.username }}</strong>
|
||||||
|
</div>
|
||||||
|
<p><a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
37
app/templates/auth/login.html
Normal file
37
app/templates/auth/login.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login - Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-6">Login</h2>
|
||||||
|
|
||||||
|
<div id="login-result"></div>
|
||||||
|
|
||||||
|
<form hx-post="/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4">
|
||||||
|
{% if return_to %}
|
||||||
|
<input type="hidden" name="return_to" value="{{ return_to }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-gray-400">
|
||||||
|
Don't have an account? <a href="/auth/register" class="text-blue-400 hover:text-blue-300">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
45
app/templates/auth/register.html
Normal file
45
app/templates/auth/register.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Register - Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<h2 class="text-xl font-semibold mb-6">Register</h2>
|
||||||
|
|
||||||
|
<div id="register-result"></div>
|
||||||
|
|
||||||
|
<form hx-post="/auth/register" hx-target="#register-result" hx-swap="innerHTML" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+"
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email (optional)</label>
|
||||||
|
<input type="email" id="email" name="email"
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="6"
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password2" class="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
|
||||||
|
<input type="password" id="password2" name="password2" required minlength="6"
|
||||||
|
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-gray-400">
|
||||||
|
Already have an account? <a href="/auth/login" class="text-blue-400 hover:text-blue-300">Login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
app/templates/base.html
Normal file
27
app/templates/base.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block brand %}Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block nav_items %}
|
||||||
|
<nav class="flex items-center space-x-6">
|
||||||
|
<a href="/assets" class="text-gray-300 hover:text-white {% if active_tab == 'assets' %}text-white font-medium{% endif %}">Assets</a>
|
||||||
|
<a href="/activities" class="text-gray-300 hover:text-white {% if active_tab == 'activities' %}text-white font-medium{% endif %}">Activities</a>
|
||||||
|
<a href="/anchors" class="text-gray-300 hover:text-white {% if active_tab == 'anchors' %}text-white font-medium{% endif %}">Anchors</a>
|
||||||
|
<a href="/storage" class="text-gray-300 hover:text-white {% if active_tab == 'storage' %}text-white font-medium{% endif %}">Storage</a>
|
||||||
|
<a href="/renderers" class="text-gray-300 hover:text-white {% if active_tab == 'renderers' %}text-white font-medium{% endif %}">Renderers</a>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block nav_right %}
|
||||||
|
{% if user %}
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/users/{{ user.username }}" class="text-gray-400 hover:text-white">{{ user.username }}</a>
|
||||||
|
<a href="/auth/logout" class="text-gray-300 hover:text-white">Logout</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/auth/login" class="text-gray-300 hover:text-white">Login</a>
|
||||||
|
<a href="/auth/register" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">Register</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
42
app/templates/home.html
Normal file
42
app/templates/home.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Art-DAG{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
{% if readme_html %}
|
||||||
|
<div class="prose prose-invert max-w-none mb-12">
|
||||||
|
{{ readme_html | safe }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<h1 class="text-4xl font-bold mb-4">Art-DAG</h1>
|
||||||
|
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media with ActivityPub Federation</p>
|
||||||
|
|
||||||
|
{% if not user %}
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<a href="/auth/login" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">Login</a>
|
||||||
|
<a href="/auth/register" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">Register</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if activities %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Recent Activity</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for activity in activities %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-blue-400">{{ activity.actor }}</span>
|
||||||
|
<span class="text-gray-500 text-sm">{{ activity.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300">
|
||||||
|
{{ activity.type }}: {{ activity.summary or activity.object_type }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user