Compare commits
25 Commits
5730cd0f22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b010b8d9 | ||
|
|
79caa24e21 | ||
|
|
e3c8b85812 | ||
|
|
2448dabb83 | ||
|
|
eed5aff238 | ||
|
|
859ff0b835 | ||
|
|
5e45f24fba | ||
|
|
fbf188afdc | ||
|
|
8f1ba74c53 | ||
|
|
655f533439 | ||
|
|
8c4a30d18f | ||
|
|
dcb487e6f4 | ||
|
|
0a15b2532e | ||
|
|
f8f44945ab | ||
|
|
65994ac107 | ||
|
|
c3d131644a | ||
|
|
65169f49f9 | ||
|
|
ff7ce1a61e | ||
|
|
39870a499c | ||
|
|
bfd38559b3 | ||
|
|
358fbba7b2 | ||
|
|
0a31e1acfa | ||
|
|
d49e759d5a | ||
|
|
dd3d5927f5 | ||
|
|
c9c4a340fd |
@@ -1,5 +1,8 @@
|
||||
# L2 Server Configuration
|
||||
|
||||
# PostgreSQL password (REQUIRED - no default)
|
||||
POSTGRES_PASSWORD=changeme-generate-with-openssl-rand-hex-16
|
||||
|
||||
# Domain for this ActivityPub server
|
||||
ARTDAG_DOMAIN=artdag.rose-ash.com
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for pip to clone dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
ARG CACHEBUST=1
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
|
||||
@@ -27,7 +27,7 @@ pip install -r requirements.txt
|
||||
# Configure
|
||||
export ARTDAG_DOMAIN=artdag.example.com
|
||||
export ARTDAG_USER=giles
|
||||
export DATABASE_URL=postgresql://artdag:artdag@localhost:5432/artdag
|
||||
export DATABASE_URL=postgresql://artdag:$POSTGRES_PASSWORD@localhost:5432/artdag
|
||||
export L1_SERVERS=https://celery-artdag.example.com
|
||||
|
||||
# Generate signing keys (required for federation)
|
||||
@@ -52,7 +52,7 @@ docker stack deploy -c docker-compose.yml artdag-l2
|
||||
| `ARTDAG_DOMAIN` | `artdag.rose-ash.com` | Domain for ActivityPub actors |
|
||||
| `ARTDAG_USER` | `giles` | Default username |
|
||||
| `ARTDAG_DATA` | `~/.artdag/l2` | Data directory |
|
||||
| `DATABASE_URL` | `postgresql://artdag:artdag@localhost:5432/artdag` | PostgreSQL connection |
|
||||
| `DATABASE_URL` | **(required)** | PostgreSQL connection |
|
||||
| `L1_SERVERS` | - | Comma-separated list of L1 server URLs |
|
||||
| `JWT_SECRET` | (generated) | JWT signing secret |
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
|
||||
from artdag_common import create_jinja_env
|
||||
from artdag_common.middleware.auth import get_user_from_cookie
|
||||
|
||||
from .config import settings
|
||||
|
||||
@@ -37,6 +38,44 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini
|
||||
_FRAG_SKIP = ("/auth/", "/.well-known/", "/health",
|
||||
"/internal/", "/static/", "/inbox")
|
||||
|
||||
@app.middleware("http")
|
||||
async def coop_fragments_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if (
|
||||
request.method != "GET"
|
||||
or any(path.startswith(p) for p in _FRAG_SKIP)
|
||||
or request.headers.get("hx-request")
|
||||
):
|
||||
request.state.nav_tree_html = ""
|
||||
request.state.auth_menu_html = ""
|
||||
request.state.cart_mini_html = ""
|
||||
return await call_next(request)
|
||||
|
||||
from artdag_common.fragments import fetch_fragments as _fetch_frags
|
||||
|
||||
user = get_user_from_cookie(request)
|
||||
auth_params = {"email": user.email} if user and user.email else {}
|
||||
nav_params = {"app_name": "artdag", "path": path}
|
||||
|
||||
try:
|
||||
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
|
||||
("blog", "nav-tree", nav_params),
|
||||
("account", "auth-menu", auth_params or None),
|
||||
("cart", "cart-mini", None),
|
||||
])
|
||||
except Exception:
|
||||
nav_tree_html = auth_menu_html = cart_mini_html = ""
|
||||
|
||||
request.state.nav_tree_html = nav_tree_html
|
||||
request.state.auth_menu_html = auth_menu_html
|
||||
request.state.cart_mini_html = cart_mini_html
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
# Initialize Jinja2 templates
|
||||
template_dir = Path(__file__).parent / "templates"
|
||||
app.state.templates = create_jinja_env(template_dir)
|
||||
|
||||
@@ -31,8 +31,8 @@ async def list_activities(
|
||||
|
||||
username = get_user_from_cookie(request)
|
||||
|
||||
activities = await db.get_activities(offset=offset, limit=limit)
|
||||
has_more = len(activities) >= limit
|
||||
activities, total = await db.get_activities_paginated(limit=limit, offset=offset)
|
||||
has_more = offset + len(activities) < total
|
||||
|
||||
if wants_json(request):
|
||||
return {"activities": activities, "offset": offset, "limit": limit}
|
||||
|
||||
@@ -36,7 +36,7 @@ async def list_anchors(
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
anchors = await db.get_user_anchors(username, offset=offset, limit=limit)
|
||||
anchors = await db.get_anchors_paginated(offset=offset, limit=limit)
|
||||
has_more = len(anchors) >= limit
|
||||
|
||||
if wants_json(request):
|
||||
|
||||
@@ -82,21 +82,21 @@ async def create_asset(
|
||||
"""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,
|
||||
)
|
||||
asset = await db.create_asset({
|
||||
"owner": user["username"],
|
||||
"name": req.name,
|
||||
"content_hash": req.content_hash,
|
||||
"ipfs_cid": req.ipfs_cid,
|
||||
"asset_type": req.asset_type,
|
||||
"tags": req.tags or [],
|
||||
"metadata": req.metadata or {},
|
||||
"provenance": req.provenance,
|
||||
})
|
||||
|
||||
if not asset_id:
|
||||
if not asset:
|
||||
raise HTTPException(400, "Failed to create asset")
|
||||
|
||||
return {"asset_id": asset_id, "message": "Asset registered"}
|
||||
return {"asset_id": asset.get("name"), "message": "Asset registered"}
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
@@ -155,26 +155,27 @@ async def record_run(
|
||||
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={
|
||||
asset = await db.create_asset({
|
||||
"owner": user["username"],
|
||||
"name": f"{req.recipe}-{req.run_id[:8]}",
|
||||
"content_hash": req.output_hash,
|
||||
"ipfs_cid": req.ipfs_cid,
|
||||
"asset_type": "render",
|
||||
"metadata": {
|
||||
"run_id": req.run_id,
|
||||
"recipe": req.recipe,
|
||||
"inputs": req.inputs,
|
||||
},
|
||||
provenance=req.provenance,
|
||||
)
|
||||
"provenance": req.provenance,
|
||||
})
|
||||
asset_id = asset.get("name") if asset else None
|
||||
|
||||
# Record run
|
||||
await db.record_run(
|
||||
run_id=req.run_id,
|
||||
username=user["username"],
|
||||
recipe=req.recipe,
|
||||
inputs=req.inputs,
|
||||
inputs=req.inputs or [],
|
||||
output_hash=req.output_hash,
|
||||
ipfs_cid=req.ipfs_cid,
|
||||
asset_id=asset_id,
|
||||
@@ -235,7 +236,7 @@ async def publish_asset(
|
||||
# Pin to IPFS
|
||||
cid = await ipfs_client.add_bytes(resp.content)
|
||||
if cid:
|
||||
await db.update_asset(asset_id, ipfs_cid=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}")
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
Authentication routes for L2 server.
|
||||
|
||||
Handles login, registration, and logout.
|
||||
Handles login, registration, logout, and token verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi import APIRouter, Request, Form, HTTPException, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html
|
||||
@@ -17,6 +18,7 @@ from ..config import settings
|
||||
from ..dependencies import get_templates, get_user_from_cookie
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
@@ -173,3 +175,49 @@ async def logout(request: Request):
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.delete_cookie("auth_token")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""
|
||||
Verify a token is valid.
|
||||
|
||||
Called by L1 servers to verify tokens during auth callback.
|
||||
Returns user info if valid, 401 if not.
|
||||
"""
|
||||
import db
|
||||
from auth import verify_token as verify_jwt, get_token_claims
|
||||
|
||||
# Get token from Authorization header or query param
|
||||
token = None
|
||||
if credentials:
|
||||
token = credentials.credentials
|
||||
else:
|
||||
# Try Authorization header manually (for clients that don't use Bearer format)
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
|
||||
if not token:
|
||||
raise HTTPException(401, "No token provided")
|
||||
|
||||
# Verify JWT signature and expiry
|
||||
username = verify_jwt(token)
|
||||
if not username:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
# Check if token is revoked
|
||||
claims = get_token_claims(token)
|
||||
if claims:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
if await db.is_token_revoked(token_hash):
|
||||
raise HTTPException(401, "Token has been revoked")
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"username": username,
|
||||
"claims": claims,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""
|
||||
Renderer (L1) management routes for L2 server.
|
||||
|
||||
Handles L1 server attachments and delegation.
|
||||
L1 servers are configured via environment variable L1_SERVERS.
|
||||
Users connect to renderers to create and run recipes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
@@ -21,130 +22,72 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AttachRendererRequest(BaseModel):
|
||||
l1_url: str
|
||||
def check_renderer_health(url: str, timeout: float = 5.0) -> bool:
|
||||
"""Check if a renderer is healthy."""
|
||||
try:
|
||||
resp = requests.get(f"{url}/", timeout=timeout)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_renderers(
|
||||
request: Request,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
"""List attached L1 renderers."""
|
||||
import db
|
||||
async def list_renderers(request: Request):
|
||||
"""List configured L1 renderers."""
|
||||
# Get user if logged in
|
||||
username = get_user_from_cookie(request)
|
||||
user = None
|
||||
if username:
|
||||
# Get token for connection links
|
||||
token = request.cookies.get("auth_token", "")
|
||||
user = {"username": username, "token": token}
|
||||
|
||||
renderers = await db.get_user_renderers(user["username"])
|
||||
|
||||
# Add status info
|
||||
for r in renderers:
|
||||
r["known"] = r["url"] in settings.l1_servers
|
||||
# Build server list with health status
|
||||
servers = []
|
||||
for url in settings.l1_servers:
|
||||
servers.append({
|
||||
"url": url,
|
||||
"healthy": check_renderer_health(url),
|
||||
})
|
||||
|
||||
if wants_json(request):
|
||||
return {"renderers": renderers, "known_servers": settings.l1_servers}
|
||||
return {"servers": servers}
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "renderers/list.html", request,
|
||||
renderers=renderers,
|
||||
known_servers=settings.l1_servers,
|
||||
servers=servers,
|
||||
user=user,
|
||||
active_tab="renderers",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{path:path}")
|
||||
async def renderer_catchall(path: str, request: Request):
|
||||
"""Catch-all for invalid renderer URLs - redirect to list."""
|
||||
if wants_json(request):
|
||||
raise HTTPException(404, "Not found")
|
||||
return RedirectResponse(url="/renderers", status_code=302)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def attach_renderer(
|
||||
req: AttachRendererRequest,
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
"""Attach an L1 renderer."""
|
||||
import db
|
||||
@router.post("/{path:path}")
|
||||
async def renderer_post_catchall(request: Request, path: str = ""):
|
||||
"""
|
||||
Catch-all for POST requests.
|
||||
|
||||
l1_url = req.l1_url.rstrip("/")
|
||||
The old API expected JSON POST to attach renderers.
|
||||
Now renderers are env-configured, so redirect to the list.
|
||||
"""
|
||||
if wants_json(request):
|
||||
return {
|
||||
"error": "Renderers are now configured via environment. See /renderers for available servers.",
|
||||
"servers": settings.l1_servers,
|
||||
}
|
||||
|
||||
# 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}
|
||||
templates = get_templates(request)
|
||||
return render(templates, "renderers/list.html", request,
|
||||
servers=[{"url": url, "healthy": check_renderer_health(url)} for url in settings.l1_servers],
|
||||
user=get_user_from_cookie(request),
|
||||
error="Renderers are configured by the system administrator. Use the Connect button to access a renderer.",
|
||||
active_tab="renderers",
|
||||
)
|
||||
|
||||
@@ -141,7 +141,7 @@ async def home(request: Request):
|
||||
username = get_user_from_cookie(request)
|
||||
|
||||
# Get recent activities
|
||||
activities = await db.get_activities(limit=10)
|
||||
activities, _ = await db.get_activities_paginated(limit=10)
|
||||
|
||||
# Get README if exists
|
||||
readme_html = ""
|
||||
@@ -149,7 +149,7 @@ async def home(request: Request):
|
||||
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())
|
||||
readme_html = markdown.markdown(readme_path.read_text(), extensions=['tables', 'fenced_code'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
63
app/templates/activities/detail.html
Normal file
63
app/templates/activities/detail.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Activity {{ activity.activity_id[:16] }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/activities" class="inline-flex items-center text-blue-400 hover:text-blue-300">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Activities
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">{{ activity.activity_type }}</h1>
|
||||
<span class="px-3 py-1 bg-blue-600 text-white text-sm rounded-full">
|
||||
Activity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Activity ID</p>
|
||||
<p class="font-mono text-sm text-gray-200 break-all">{{ activity.activity_id }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Actor</p>
|
||||
<p class="text-gray-200">{{ activity.actor_id }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Published</p>
|
||||
<p class="text-gray-200">{{ activity.published }}</p>
|
||||
</div>
|
||||
|
||||
{% if activity.anchor_root %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Anchor Root</p>
|
||||
<p class="font-mono text-sm text-gray-200 break-all">{{ activity.anchor_root }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if activity.object_data %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-2">Object Data</p>
|
||||
<pre class="bg-gray-900 rounded p-4 text-xs text-gray-300 overflow-x-auto">{{ activity.object_data | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if activity.signature %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-2">Signature</p>
|
||||
<pre class="bg-gray-900 rounded p-4 text-xs text-gray-300 overflow-x-auto">{{ activity.signature | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
39
app/templates/activities/list.html
Normal file
39
app/templates/activities/list.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Activities - Art-DAG{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Activities</h1>
|
||||
</div>
|
||||
|
||||
{% if activities %}
|
||||
<div class="space-y-4">
|
||||
{% for activity in activities %}
|
||||
<a href="/activities/{{ activity.activity_id }}"
|
||||
class="block bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-blue-400 font-medium">{{ activity.activity_type }}</span>
|
||||
<span class="text-gray-500 text-sm">{{ activity.published }}</span>
|
||||
</div>
|
||||
<div class="text-gray-300 text-sm truncate">
|
||||
{{ activity.actor_id }}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div class="mt-6 text-center">
|
||||
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
|
||||
class="text-blue-400 hover:text-blue-300">Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No activities yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
81
app/templates/anchors/detail.html
Normal file
81
app/templates/anchors/detail.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Anchor {{ anchor.merkle_root[:16] }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/anchors" class="inline-flex items-center text-blue-400 hover:text-blue-300">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Anchors
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Bitcoin Anchor</h1>
|
||||
<span class="px-3 py-1 text-sm rounded-full
|
||||
{% if anchor.confirmed_at %}bg-green-600{% else %}bg-yellow-600{% endif %}">
|
||||
{% if anchor.confirmed_at %}Confirmed{% else %}Pending{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Merkle Root</p>
|
||||
<p class="font-mono text-sm text-gray-200 break-all">{{ anchor.merkle_root }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Activity Count</p>
|
||||
<p class="text-xl font-semibold text-white">{{ anchor.activity_count }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Created</p>
|
||||
<p class="text-gray-200">{{ anchor.created_at }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if anchor.bitcoin_txid %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Bitcoin Transaction</p>
|
||||
<a href="https://mempool.space/tx/{{ anchor.bitcoin_txid }}" target="_blank" rel="noopener"
|
||||
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
|
||||
{{ anchor.bitcoin_txid }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if anchor.confirmed_at %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Confirmed At</p>
|
||||
<p class="text-gray-200">{{ anchor.confirmed_at }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if anchor.tree_ipfs_cid %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">Merkle Tree IPFS CID</p>
|
||||
<a href="https://ipfs.io/ipfs/{{ anchor.tree_ipfs_cid }}" target="_blank" rel="noopener"
|
||||
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
|
||||
{{ anchor.tree_ipfs_cid }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if anchor.ots_proof_cid %}
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">OpenTimestamps Proof CID</p>
|
||||
<a href="https://ipfs.io/ipfs/{{ anchor.ots_proof_cid }}" target="_blank" rel="noopener"
|
||||
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
|
||||
{{ anchor.ots_proof_cid }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
app/templates/anchors/list.html
Normal file
47
app/templates/anchors/list.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Anchors - Art-DAG{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Bitcoin Anchors</h1>
|
||||
</div>
|
||||
|
||||
{% if anchors %}
|
||||
<div class="space-y-4">
|
||||
{% for anchor in anchors %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-mono text-sm text-blue-400 truncate">{{ anchor.merkle_root[:16] }}...</span>
|
||||
{% if anchor.confirmed_at %}
|
||||
<span class="bg-green-600 text-xs px-2 py-1 rounded">Confirmed</span>
|
||||
{% else %}
|
||||
<span class="bg-yellow-600 text-xs px-2 py-1 rounded">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm">
|
||||
{{ anchor.activity_count or 0 }} activities | Created: {{ anchor.created_at }}
|
||||
</div>
|
||||
{% if anchor.bitcoin_txid %}
|
||||
<div class="mt-2 text-xs text-gray-500 font-mono truncate">
|
||||
TX: {{ anchor.bitcoin_txid }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div class="mt-6 text-center">
|
||||
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
|
||||
class="text-blue-400 hover:text-blue-300">Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No anchors yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
81
app/templates/assets/detail.html
Normal file
81
app/templates/assets/detail.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ asset.name }} - Asset{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/assets" class="inline-flex items-center text-blue-400 hover:text-blue-300">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Assets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<!-- Asset Preview -->
|
||||
<div class="aspect-video bg-gray-900 flex items-center justify-center">
|
||||
{% if asset.asset_type == 'video' %}
|
||||
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% elif asset.asset_type == 'image' %}
|
||||
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Asset Info -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ asset.name }}</h1>
|
||||
<p class="text-gray-400">by {{ asset.owner }}</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-purple-600 text-white text-sm rounded-full">
|
||||
{{ asset.asset_type }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if asset.description %}
|
||||
<p class="text-gray-300 mb-6">{{ asset.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if asset.tags %}
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{% for tag in asset.tags %}
|
||||
<span class="px-2 py-1 bg-gray-700 text-gray-300 text-sm rounded">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-400 mb-1">Content Hash</p>
|
||||
<p class="font-mono text-xs text-gray-200 break-all">{{ asset.content_hash }}</p>
|
||||
</div>
|
||||
{% if asset.ipfs_cid %}
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-400 mb-1">IPFS CID</p>
|
||||
<a href="https://ipfs.io/ipfs/{{ asset.ipfs_cid }}" target="_blank" rel="noopener"
|
||||
class="font-mono text-xs text-blue-400 hover:text-blue-300 break-all">
|
||||
{{ asset.ipfs_cid }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
Created {{ asset.created_at }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,27 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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>
|
||||
{% block brand %}
|
||||
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
|
||||
<span class="text-stone-400 mx-1">|</span>
|
||||
<a href="/" class="no-underline text-stone-900">Art-DAG</a>
|
||||
<span class="text-stone-400 mx-1">/</span>
|
||||
<span class="text-stone-600 text-3xl">L2</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block 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>
|
||||
{% block cart_mini %}
|
||||
{% if request and request.state.cart_mini_html %}
|
||||
{{ request.state.cart_mini_html | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block nav_tree %}
|
||||
{% if request and request.state.nav_tree_html %}
|
||||
{{ request.state.nav_tree_html | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block auth_menu %}
|
||||
{% if request and request.state.auth_menu_html %}
|
||||
{{ request.state.auth_menu_html | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block auth_menu_mobile %}
|
||||
{% if request and request.state.auth_menu_html %}
|
||||
{{ request.state.auth_menu_html | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_nav %}
|
||||
<div class="bg-stone-200 border-b border-stone-300">
|
||||
<div class="max-w-screen-2xl mx-auto px-4">
|
||||
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
|
||||
<a href="/assets" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'assets' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Assets</a>
|
||||
<a href="/activities" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'activities' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Activities</a>
|
||||
<a href="/anchors" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'anchors' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Anchors</a>
|
||||
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage</a>
|
||||
<a href="/renderers" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'renderers' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Renderers</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
52
app/templates/renderers/list.html
Normal file
52
app/templates/renderers/list.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Renderers</h1>
|
||||
|
||||
<p class="text-gray-400 mb-6">
|
||||
Renderers are L1 servers that process your media. Connect to a renderer to create and run recipes.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded mb-6">
|
||||
{{ success }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for server in servers %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ server.url }}" target="_blank" class="text-blue-400 hover:text-blue-300 font-medium">
|
||||
{{ server.url }}
|
||||
</a>
|
||||
{% if server.healthy %}
|
||||
<span class="ml-2 text-green-400 text-sm">Online</span>
|
||||
{% else %}
|
||||
<span class="ml-2 text-red-400 text-sm">Offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ server.url }}/auth?auth_token={{ user.token }}"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-sm">
|
||||
Connect
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No renderers configured.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-gray-500 text-sm">
|
||||
<p>Renderers are configured by the system administrator.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
app/templates/storage/list.html
Normal file
41
app/templates/storage/list.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Storage - Art-DAG{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Storage Providers</h1>
|
||||
<a href="/storage/add" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm">
|
||||
Add Storage
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if storages %}
|
||||
<div class="space-y-4">
|
||||
{% for storage in storages %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">{{ storage.name or storage.provider_type }}</span>
|
||||
<span class="text-xs px-2 py-1 rounded {% if storage.is_active %}bg-green-600{% else %}bg-gray-600{% endif %}">
|
||||
{{ storage.provider_type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm">
|
||||
{% if storage.endpoint %}
|
||||
{{ storage.endpoint }}
|
||||
{% elif storage.bucket %}
|
||||
Bucket: {{ storage.bucket }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No storage providers configured.</p>
|
||||
<a href="/storage/add" class="text-blue-400 hover:text-blue-300 mt-2 inline-block">Add one now</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
62
app/templates/users/profile.html
Normal file
62
app/templates/users/profile.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ profile.username }} - Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Profile Header -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-3xl font-bold text-white">
|
||||
{{ profile.username[0]|upper }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ profile.display_name or profile.username }}</h1>
|
||||
<p class="text-gray-400 mb-3">@{{ profile.username }}</p>
|
||||
{% if profile.bio %}
|
||||
<p class="text-gray-300">{{ profile.bio }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets -->
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Assets</h2>
|
||||
|
||||
{% if assets %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for asset in assets %}
|
||||
<a href="/assets/{{ asset.name }}" class="group">
|
||||
<div class="aspect-square bg-gray-900 rounded-lg overflow-hidden">
|
||||
{% if asset.asset_type == 'image' %}
|
||||
<div class="w-full h-full bg-gradient-to-br from-green-900/50 to-blue-900/50 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif asset.asset_type == 'video' %}
|
||||
<div class="w-full h-full bg-gradient-to-br from-purple-900/50 to-pink-900/50 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-300 truncate group-hover:text-white">{{ asset.name }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-center py-8">No assets yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
152
db.py
152
db.py
@@ -32,10 +32,9 @@ def _parse_timestamp(ts) -> datetime:
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
# Configuration from environment
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
||||
)
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError("DATABASE_URL environment variable is required")
|
||||
|
||||
# Schema for database initialization
|
||||
SCHEMA = """
|
||||
@@ -759,6 +758,28 @@ async def get_all_anchors() -> list[dict]:
|
||||
return results
|
||||
|
||||
|
||||
async def get_anchors_paginated(offset: int = 0, limit: int = 20) -> list[dict]:
|
||||
"""Get anchors with pagination, newest first."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM anchors ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
limit, offset
|
||||
)
|
||||
results = []
|
||||
for row in rows:
|
||||
result = dict(row)
|
||||
if result.get("first_activity_id"):
|
||||
result["first_activity_id"] = str(result["first_activity_id"])
|
||||
if result.get("last_activity_id"):
|
||||
result["last_activity_id"] = str(result["last_activity_id"])
|
||||
if result.get("created_at"):
|
||||
result["created_at"] = result["created_at"].isoformat()
|
||||
if result.get("confirmed_at"):
|
||||
result["confirmed_at"] = result["confirmed_at"].isoformat()
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
async def update_anchor_confirmed(merkle_root: str, bitcoin_txid: str) -> bool:
|
||||
"""Mark anchor as confirmed with Bitcoin txid."""
|
||||
async with get_connection() as conn:
|
||||
@@ -1069,3 +1090,126 @@ async def cleanup_expired_revocations() -> int:
|
||||
return int(result.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
# ============ Additional helper functions ============
|
||||
|
||||
async def get_user_assets(username: str, offset: int = 0, limit: int = 20, asset_type: str = None) -> list[dict]:
|
||||
"""Get assets owned by a user with pagination."""
|
||||
async with get_connection() as conn:
|
||||
if asset_type:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT * FROM assets WHERE owner = $1 AND asset_type = $2
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4""",
|
||||
username, asset_type, limit, offset
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT * FROM assets WHERE owner = $1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3""",
|
||||
username, limit, offset
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_asset(asset_id: str) -> bool:
|
||||
"""Delete an asset by name/id."""
|
||||
async with get_connection() as conn:
|
||||
result = await conn.execute("DELETE FROM assets WHERE name = $1", asset_id)
|
||||
return "DELETE 1" in result
|
||||
|
||||
|
||||
async def count_users() -> int:
|
||||
"""Count total users."""
|
||||
async with get_connection() as conn:
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM users")
|
||||
|
||||
|
||||
async def count_user_activities(username: str) -> int:
|
||||
"""Count activities by a user."""
|
||||
async with get_connection() as conn:
|
||||
return await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM activities WHERE actor_id LIKE $1",
|
||||
f"%{username}%"
|
||||
)
|
||||
|
||||
|
||||
async def get_user_activities(username: str, limit: int = 20, offset: int = 0) -> list[dict]:
|
||||
"""Get activities by a user."""
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
|
||||
FROM activities WHERE actor_id LIKE $1
|
||||
ORDER BY published DESC LIMIT $2 OFFSET $3""",
|
||||
f"%{username}%", limit, offset
|
||||
)
|
||||
return [_parse_activity_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_renderer(renderer_id: str) -> Optional[dict]:
|
||||
"""Get a renderer by ID/URL."""
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM user_renderers WHERE l1_url = $1",
|
||||
renderer_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def update_anchor(anchor_id: str, **updates) -> bool:
|
||||
"""Update an anchor."""
|
||||
async with get_connection() as conn:
|
||||
if "bitcoin_txid" in updates:
|
||||
result = await conn.execute(
|
||||
"""UPDATE anchors SET bitcoin_txid = $1, confirmed_at = NOW()
|
||||
WHERE merkle_root = $2""",
|
||||
updates["bitcoin_txid"], anchor_id
|
||||
)
|
||||
return "UPDATE 1" in result
|
||||
return False
|
||||
|
||||
|
||||
async def delete_anchor(anchor_id: str) -> bool:
|
||||
"""Delete an anchor."""
|
||||
async with get_connection() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM anchors WHERE merkle_root = $1", anchor_id
|
||||
)
|
||||
return "DELETE 1" in result
|
||||
|
||||
|
||||
async def record_run(run_id: str, username: str, recipe: str, inputs: list,
|
||||
output_hash: str, ipfs_cid: str = None, asset_id: str = None) -> dict:
|
||||
"""Record a completed run."""
|
||||
async with get_connection() as conn:
|
||||
# Check if runs table exists, if not just return the data
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO runs (run_id, username, recipe, inputs, output_hash, ipfs_cid, asset_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (run_id) DO UPDATE SET
|
||||
output_hash = EXCLUDED.output_hash,
|
||||
ipfs_cid = EXCLUDED.ipfs_cid,
|
||||
asset_id = EXCLUDED.asset_id
|
||||
RETURNING *""",
|
||||
run_id, username, recipe, json.dumps(inputs), output_hash, ipfs_cid, asset_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
except Exception:
|
||||
# Table might not exist
|
||||
return {"run_id": run_id, "username": username, "recipe": recipe}
|
||||
|
||||
|
||||
async def get_run(run_id: str) -> Optional[dict]:
|
||||
"""Get a run by ID."""
|
||||
async with get_connection() as conn:
|
||||
try:
|
||||
row = await conn.fetchrow("SELECT * FROM runs WHERE run_id = $1", run_id)
|
||||
if row:
|
||||
result = dict(row)
|
||||
if result.get("inputs") and isinstance(result["inputs"], str):
|
||||
result["inputs"] = json.loads(result["inputs"])
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -7,7 +7,7 @@ echo "=== Pulling latest code ==="
|
||||
git pull
|
||||
|
||||
echo "=== Building Docker image ==="
|
||||
docker build -t git.rose-ash.com/art-dag/l2-server:latest .
|
||||
docker build --build-arg CACHEBUST=$(date +%s) -t git.rose-ash.com/art-dag/l2-server:latest .
|
||||
|
||||
echo "=== Redeploying activitypub stack ==="
|
||||
docker stack deploy -c docker-compose.yml activitypub
|
||||
|
||||
@@ -3,9 +3,10 @@ version: "3.8"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: artdag
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artdag}
|
||||
POSTGRES_DB: artdag
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
@@ -16,6 +17,10 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.gpu != true
|
||||
|
||||
ipfs:
|
||||
image: ipfs/kubo:latest
|
||||
@@ -31,17 +36,29 @@ services:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.gpu != true
|
||||
|
||||
l2-server:
|
||||
image: git.rose-ash.com/art-dag/l2-server:latest
|
||||
image: registry.rose-ash.com:5000/l2-server:latest
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ARTDAG_DATA=/data/l2
|
||||
- DATABASE_URL=postgresql://artdag:${POSTGRES_PASSWORD:-artdag}@postgres:5432/artdag
|
||||
- IPFS_API=/dns/ipfs/tcp/5001
|
||||
- ANCHOR_BACKUP_DIR=/data/anchors
|
||||
# ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file
|
||||
# Coop app internal URLs for fragment composition
|
||||
- INTERNAL_URL_BLOG=http://blog:8000
|
||||
- INTERNAL_URL_CART=http://cart:8000
|
||||
- INTERNAL_URL_ACCOUNT=http://account:8000
|
||||
# DATABASE_URL, ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8200/')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
volumes:
|
||||
- l2_data:/data/l2 # Still needed for RSA keys
|
||||
- anchor_backup:/data/anchors # Persistent anchor proofs (survives DB wipes)
|
||||
@@ -53,8 +70,13 @@ services:
|
||||
- ipfs
|
||||
deploy:
|
||||
replicas: 1
|
||||
update_config:
|
||||
order: start-first
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.gpu != true
|
||||
|
||||
volumes:
|
||||
l2_data:
|
||||
|
||||
@@ -27,10 +27,9 @@ import asyncpg
|
||||
|
||||
# Configuration
|
||||
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
||||
)
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError("DATABASE_URL environment variable is required")
|
||||
|
||||
SCHEMA = """
|
||||
-- Drop existing tables (careful in production!)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
requests>=2.31.0
|
||||
httpx>=0.27.0
|
||||
cryptography>=42.0.0
|
||||
bcrypt>=4.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
@@ -8,3 +9,5 @@ markdown>=3.5.0
|
||||
python-multipart>=0.0.6
|
||||
asyncpg>=0.29.0
|
||||
boto3>=1.34.0
|
||||
# Shared components
|
||||
git+https://git.rose-ash.com/art-dag/common.git@889ea98
|
||||
|
||||
3765
server_legacy.py
Normal file
3765
server_legacy.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user