Compare commits

..

25 Commits

Author SHA1 Message Date
giles
40b010b8d9 Disable CI — moved to coop/art-dag monorepo 2026-02-24 23:24:39 +00:00
giles
79caa24e21 Add coop internal URL env vars to L2 docker-compose
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:04:26 +00:00
giles
e3c8b85812 Add coop fragment middleware and env vars
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Fetch nav-tree, auth-menu, cart-mini from coop apps for unified
header. Add INTERNAL_URL env vars for Docker networking. Update
base.html to render fragment blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:03:43 +00:00
giles
2448dabb83 Restyle base.html to use coop header theme
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Match coop light theme: sky header, stone sub-nav pills. Removes
dark-theme nav classes. L2-specific tabs in sub-nav bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:00:26 +00:00
giles
eed5aff238 Add healthcheck and start-first update for l2-server
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Zero-downtime deploys: new container starts and passes health
check before the old one is stopped. Caddy always has a healthy
backend to proxy to.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:20:00 +00:00
giles
859ff0b835 Eliminate ${VAR} substitutions from docker-compose.yml
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Move DATABASE_URL and POSTGRES_PASSWORD to .env via env_file.
docker stack deploy no longer needs env vars sourced, and
repeat deploys won't trigger spurious restarts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:06:40 +00:00
giles
5e45f24fba Source .env before docker stack deploy in CI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
docker stack deploy does not read .env files automatically
(unlike docker compose), so ${VAR} substitutions resolve to
empty strings. Source .env to export vars before deploying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 21:44:38 +00:00
gilesb
fbf188afdc Remove hardcoded secrets from public repo
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Remove default password fallback from POSTGRES_PASSWORD in docker-compose.yml
- Remove default password fallback from db.py and migrate.py
- Update .env.example with required POSTGRES_PASSWORD
- Update README to mark DATABASE_URL as required

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:29:24 +00:00
giles
8f1ba74c53 Add Gitea Actions CI/CD and use private registry
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 12s
Add CI workflow mirroring celery pipeline: SSH to deploy server,
git pull, build and push to registry, deploy docker stack.
Update docker-compose to pull l2-server from registry.rose-ash.com:5000.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:23:22 +00:00
gilesb
655f533439 Add /auth/verify endpoint for L1 token verification
L1 servers call this endpoint to verify tokens during auth callback.
Returns user info if token is valid, 401 if invalid or revoked.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:49:39 +00:00
gilesb
8c4a30d18f Simplify renderers to use env-configured L1 servers
- L1 servers now come from L1_SERVERS env var instead of per-user attachment
- Added renderers/list.html template showing available servers
- Health check shows if servers are online
- Elegant error handling for invalid requests (no more raw JSON errors)
- Connect button passes auth token to L1 for seamless login

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:36:38 +00:00
giles
dcb487e6f4 Fix renderer list and enable markdown tables
- Fix get_user_renderers usage (returns strings not dicts)
- Enable tables and fenced_code markdown extensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:20:47 +00:00
giles
0a15b2532e Add missing list templates for activities, anchors, storage
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:19:07 +00:00
giles
f8f44945ab Fix db function calls and add missing functions
- Fix get_activities to use get_activities_paginated
- Add get_user_assets, delete_asset, count_users, count_user_activities
- Add get_user_activities, get_renderer, update_anchor, delete_anchor
- Add record_run and get_run functions
- Fix create_asset calls to use dict parameter
- Fix update_asset call signature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:13:40 +00:00
giles
65994ac107 Update artdag-common to 889ea98 with prose fix 2026-01-11 13:10:44 +00:00
giles
c3d131644a Fix anchors router to use get_anchors_paginated
Anchors are global, not user-specific. Added paginated db function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:08:37 +00:00
giles
65169f49f9 Pin artdag-common to commit 2163cbd
Forces pip to fetch latest version with typography plugin.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:02:24 +00:00
giles
ff7ce1a61e Add cache busting to force pip to re-fetch dependencies
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:44:44 +00:00
giles
39870a499c Fix get_activities call to use get_activities_paginated
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:29:00 +00:00
giles
bfd38559b3 Update base.html to extend _base.html
Matches renamed template in artdag-common package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:28:28 +00:00
giles
358fbba7b2 Add git to Dockerfile and httpx dependency
- Install git in Docker image for pip to clone git dependencies
- Add httpx package required by auth_service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:11:51 +00:00
giles
0a31e1acfa Add artdag-common dependency 2026-01-11 11:55:41 +00:00
giles
d49e759d5a Refactor to modular app factory architecture
- Replace 3765-line monolithic server.py with 26-line entry point
- All routes now in app/routers/ using Jinja2 templates
- Backup old server as server_legacy.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:48:24 +00:00
giles
dd3d5927f5 Add /help routes to display README documentation
Provides /help index and /help/{doc_name} routes to view
L1 server and Common library READMEs in the web UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:29:49 +00:00
giles
c9c4a340fd Remove redundant documentation UI routes
/docs now correctly points to FastAPI's Swagger API docs.
README files can be viewed directly in the git repository.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:21:16 +00:00
26 changed files with 4645 additions and 4033 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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)

View File

@@ -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}

View File

@@ -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):

View File

@@ -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}")

View File

@@ -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,
}

View File

@@ -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",
)

View File

@@ -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

View 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 %}

View File

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

View File

@@ -0,0 +1,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 %}

View File

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

View File

@@ -0,0 +1,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 %}

View File

@@ -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 %}

View File

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

View File

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

View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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!)

View File

@@ -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

3861
server.py

File diff suppressed because it is too large Load Diff

3765
server_legacy.py Normal file

File diff suppressed because it is too large Load Diff