Import L1 (celery) as l1/
This commit is contained in:
237
l1/app/__init__.py
Normal file
237
l1/app/__init__.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Art-DAG L1 Server Application Factory.
|
||||
|
||||
Creates and configures the FastAPI application with all routers and middleware.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from artdag_common import create_jinja_env
|
||||
from artdag_common.middleware.auth import get_user_from_cookie
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Paths that should never trigger a silent auth check
|
||||
_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/", "/inbox", "/health", "/internal/", "/oembed")
|
||||
_SILENT_CHECK_COOLDOWN = 300 # 5 minutes
|
||||
_DEVICE_COOKIE = "artdag_did"
|
||||
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
||||
|
||||
# Derive external base URL from oauth_redirect_uri (e.g. https://celery-artdag.rose-ash.com)
|
||||
_EXTERNAL_BASE = settings.oauth_redirect_uri.rsplit("/auth/callback", 1)[0]
|
||||
|
||||
|
||||
def _external_url(request: Request) -> str:
|
||||
"""Build external URL from request path + query, using configured base domain."""
|
||||
url = f"{_EXTERNAL_BASE}{request.url.path}"
|
||||
if request.url.query:
|
||||
url += f"?{request.url.query}"
|
||||
return url
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
Create and configure the L1 FastAPI application.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI instance
|
||||
"""
|
||||
app = FastAPI(
|
||||
title="Art-DAG L1 Server",
|
||||
description="Content-addressed media processing with distributed execution",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Database lifecycle events
|
||||
from database import init_db, close_db
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
await close_db()
|
||||
|
||||
# Silent auth check — auto-login via prompt=none OAuth
|
||||
# NOTE: registered BEFORE device_id so device_id is outermost (runs first)
|
||||
@app.middleware("http")
|
||||
async def silent_auth_check(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if (
|
||||
request.method != "GET"
|
||||
or any(path.startswith(p) for p in _SKIP_PREFIXES)
|
||||
or request.headers.get("hx-request") # skip HTMX
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
# Already logged in — but verify account hasn't logged out
|
||||
if get_user_from_cookie(request):
|
||||
device_id = getattr(request.state, "device_id", None)
|
||||
if device_id:
|
||||
try:
|
||||
from .dependencies import get_redis_client
|
||||
r = get_redis_client()
|
||||
if not r.get(f"did_auth:{device_id}"):
|
||||
# Account logged out — clear our cookie
|
||||
response = await call_next(request)
|
||||
response.delete_cookie("artdag_session")
|
||||
response.delete_cookie("pnone_at")
|
||||
return response
|
||||
except Exception:
|
||||
pass
|
||||
return await call_next(request)
|
||||
|
||||
# Check cooldown — don't re-check within 5 minutes
|
||||
pnone_at = request.cookies.get("pnone_at")
|
||||
if pnone_at:
|
||||
try:
|
||||
pnone_ts = float(pnone_at)
|
||||
if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN:
|
||||
# But first check if account signalled a login via inbox delivery
|
||||
device_id = getattr(request.state, "device_id", None)
|
||||
if device_id:
|
||||
try:
|
||||
from .dependencies import get_redis_client
|
||||
r = get_redis_client()
|
||||
auth_ts = r.get(f"did_auth:{device_id}")
|
||||
if auth_ts and float(auth_ts) > pnone_ts:
|
||||
# Login happened since our last check — retry
|
||||
current_url = _external_url(request)
|
||||
return RedirectResponse(
|
||||
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
|
||||
status_code=302,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return await call_next(request)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Redirect to silent OAuth check
|
||||
current_url = _external_url(request)
|
||||
return RedirectResponse(
|
||||
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
# Device ID middleware — track browser identity across domains
|
||||
# Registered AFTER silent_auth_check so it's outermost (always runs)
|
||||
@app.middleware("http")
|
||||
async def device_id_middleware(request: Request, call_next):
|
||||
did = request.cookies.get(_DEVICE_COOKIE)
|
||||
if did:
|
||||
request.state.device_id = did
|
||||
request.state._new_device_id = False
|
||||
else:
|
||||
request.state.device_id = secrets.token_urlsafe(32)
|
||||
request.state._new_device_id = True
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if getattr(request.state, "_new_device_id", False):
|
||||
response.set_cookie(
|
||||
key=_DEVICE_COOKIE,
|
||||
value=request.state.device_id,
|
||||
max_age=_DEVICE_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
return response
|
||||
|
||||
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini into
|
||||
# request.state for full-page HTML renders. Skips HTMX, API, and
|
||||
# internal paths. Failures are silent (fragments default to "").
|
||||
_FRAG_SKIP = ("/auth/", "/api/", "/internal/", "/health", "/oembed",
|
||||
"/ipfs/", "/download/", "/inbox", "/static/")
|
||||
|
||||
@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")
|
||||
or request.headers.get(fragments.FRAGMENT_HEADER)
|
||||
):
|
||||
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)
|
||||
|
||||
# Custom 404 handler
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
from artdag_common.middleware import wants_html
|
||||
if wants_html(request):
|
||||
from artdag_common import render
|
||||
return render(app.state.templates, "404.html", request,
|
||||
user=None,
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
|
||||
# Include routers
|
||||
from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox, fragments, oembed
|
||||
|
||||
# Home and auth routers (root level)
|
||||
app.include_router(home.router, tags=["home"])
|
||||
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
app.include_router(inbox.router, tags=["inbox"])
|
||||
app.include_router(fragments.router, tags=["fragments"])
|
||||
app.include_router(oembed.router, tags=["oembed"])
|
||||
|
||||
# Feature routers
|
||||
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||
app.include_router(api.router, prefix="/api", tags=["api"])
|
||||
|
||||
# Runs and recipes routers
|
||||
app.include_router(runs.router, prefix="/runs", tags=["runs"])
|
||||
app.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||
|
||||
# Cache router - handles /cache and /media
|
||||
app.include_router(cache.router, prefix="/cache", tags=["cache"])
|
||||
# Also mount cache router at /media for convenience
|
||||
app.include_router(cache.router, prefix="/media", tags=["media"])
|
||||
|
||||
# Effects router
|
||||
app.include_router(effects.router, prefix="/effects", tags=["effects"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create the default app instance
|
||||
app = create_app()
|
||||
116
l1/app/config.py
Normal file
116
l1/app/config.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
L1 Server Configuration.
|
||||
|
||||
Environment-based configuration with sensible defaults.
|
||||
All config should go through this module - no direct os.environ calls elsewhere.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
# Server
|
||||
host: str = field(default_factory=lambda: os.environ.get("HOST", "0.0.0.0"))
|
||||
port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8000")))
|
||||
debug: bool = field(default_factory=lambda: os.environ.get("DEBUG", "").lower() == "true")
|
||||
|
||||
# Cache (use /data/cache in Docker via env var, ~/.artdag/cache locally)
|
||||
cache_dir: Path = field(
|
||||
default_factory=lambda: Path(os.environ.get("CACHE_DIR", str(Path.home() / ".artdag" / "cache")))
|
||||
)
|
||||
|
||||
# Redis
|
||||
redis_url: str = field(
|
||||
default_factory=lambda: os.environ.get("REDIS_URL", "redis://localhost:6379/5")
|
||||
)
|
||||
|
||||
# Database
|
||||
database_url: str = field(
|
||||
default_factory=lambda: os.environ.get("DATABASE_URL", "")
|
||||
)
|
||||
|
||||
# IPFS
|
||||
ipfs_api: str = field(
|
||||
default_factory=lambda: os.environ.get("IPFS_API", "/dns/localhost/tcp/5001")
|
||||
)
|
||||
ipfs_gateway_url: str = field(
|
||||
default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
|
||||
)
|
||||
|
||||
# OAuth SSO (replaces L2 auth)
|
||||
oauth_authorize_url: str = field(
|
||||
default_factory=lambda: os.environ.get("OAUTH_AUTHORIZE_URL", "https://account.rose-ash.com/auth/oauth/authorize")
|
||||
)
|
||||
oauth_token_url: str = field(
|
||||
default_factory=lambda: os.environ.get("OAUTH_TOKEN_URL", "https://account.rose-ash.com/auth/oauth/token")
|
||||
)
|
||||
oauth_client_id: str = field(
|
||||
default_factory=lambda: os.environ.get("OAUTH_CLIENT_ID", "artdag")
|
||||
)
|
||||
oauth_redirect_uri: str = field(
|
||||
default_factory=lambda: os.environ.get("OAUTH_REDIRECT_URI", "https://celery-artdag.rose-ash.com/auth/callback")
|
||||
)
|
||||
oauth_logout_url: str = field(
|
||||
default_factory=lambda: os.environ.get("OAUTH_LOGOUT_URL", "https://account.rose-ash.com/auth/sso-logout/")
|
||||
)
|
||||
secret_key: str = field(
|
||||
default_factory=lambda: os.environ.get("SECRET_KEY", "change-me-in-production")
|
||||
)
|
||||
|
||||
# GPU/Streaming settings
|
||||
streaming_gpu_persist: bool = field(
|
||||
default_factory=lambda: os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
|
||||
)
|
||||
ipfs_gateways: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"IPFS_GATEWAYS", "https://ipfs.io,https://cloudflare-ipfs.com,https://dweb.link"
|
||||
)
|
||||
)
|
||||
|
||||
# Derived paths
|
||||
@property
|
||||
def plan_cache_dir(self) -> Path:
|
||||
return self.cache_dir / "plans"
|
||||
|
||||
@property
|
||||
def analysis_cache_dir(self) -> Path:
|
||||
return self.cache_dir / "analysis"
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Create required directories."""
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.plan_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.analysis_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def log_config(self, logger=None) -> None:
|
||||
"""Log all configuration values for debugging."""
|
||||
output = logger.info if logger else lambda x: print(x, file=sys.stderr)
|
||||
output("=" * 60)
|
||||
output("CONFIGURATION")
|
||||
output("=" * 60)
|
||||
output(f" cache_dir: {self.cache_dir}")
|
||||
output(f" redis_url: {self.redis_url}")
|
||||
output(f" database_url: {self.database_url[:50]}...")
|
||||
output(f" ipfs_api: {self.ipfs_api}")
|
||||
output(f" ipfs_gateway_url: {self.ipfs_gateway_url}")
|
||||
output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...")
|
||||
output(f" streaming_gpu_persist: {self.streaming_gpu_persist}")
|
||||
output(f" oauth_client_id: {self.oauth_client_id}")
|
||||
output(f" oauth_authorize_url: {self.oauth_authorize_url}")
|
||||
output("=" * 60)
|
||||
|
||||
|
||||
# Singleton settings instance
|
||||
settings = Settings()
|
||||
|
||||
# Log config on import if DEBUG or SHOW_CONFIG is set
|
||||
if os.environ.get("DEBUG") or os.environ.get("SHOW_CONFIG"):
|
||||
settings.log_config()
|
||||
186
l1/app/dependencies.py
Normal file
186
l1/app/dependencies.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
FastAPI dependency injection container.
|
||||
|
||||
Provides shared resources and services to route handlers.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from fastapi import Request, Depends, HTTPException
|
||||
from jinja2 import Environment
|
||||
|
||||
from artdag_common.middleware.auth import UserContext, get_user_from_cookie, get_user_from_header
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
# Lazy imports to avoid circular dependencies
|
||||
_redis_client = None
|
||||
_cache_manager = None
|
||||
_database = None
|
||||
|
||||
|
||||
def get_redis_client():
|
||||
"""Get the Redis client singleton."""
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
import redis
|
||||
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def get_cache_manager():
|
||||
"""Get the cache manager singleton."""
|
||||
global _cache_manager
|
||||
if _cache_manager is None:
|
||||
from cache_manager import get_cache_manager as _get_cache_manager
|
||||
_cache_manager = _get_cache_manager()
|
||||
return _cache_manager
|
||||
|
||||
|
||||
def get_database():
|
||||
"""Get the database singleton."""
|
||||
global _database
|
||||
if _database is None:
|
||||
import database
|
||||
_database = database
|
||||
return _database
|
||||
|
||||
|
||||
def get_templates(request: Request) -> Environment:
|
||||
"""Get the Jinja2 environment from app state."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> Optional[UserContext]:
|
||||
"""
|
||||
Get the current user from request (cookie or header).
|
||||
|
||||
This is a permissive dependency - returns None if not authenticated.
|
||||
Use require_auth for routes that require authentication.
|
||||
"""
|
||||
# Try header first (API clients)
|
||||
ctx = get_user_from_header(request)
|
||||
if ctx:
|
||||
return ctx
|
||||
|
||||
# Fall back to cookie (browser)
|
||||
return get_user_from_cookie(request)
|
||||
|
||||
|
||||
async def require_auth(request: Request) -> UserContext:
|
||||
"""
|
||||
Require authentication for a route.
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
HTTPException 302 redirect to login for HTML requests
|
||||
"""
|
||||
ctx = await get_current_user(request)
|
||||
if ctx is None:
|
||||
# Check if HTML request for redirect
|
||||
accept = request.headers.get("accept", "")
|
||||
if "text/html" in accept:
|
||||
raise HTTPException(
|
||||
status_code=302,
|
||||
headers={"Location": "/auth/login"}
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return ctx
|
||||
|
||||
|
||||
async def get_user_context_from_cookie(request: Request) -> Optional[UserContext]:
|
||||
"""
|
||||
Legacy compatibility: get user from cookie.
|
||||
|
||||
Validates token with L2 server if configured.
|
||||
"""
|
||||
ctx = get_user_from_cookie(request)
|
||||
if ctx is None:
|
||||
return None
|
||||
|
||||
# If L2 server configured, could validate token here
|
||||
# For now, trust the cookie
|
||||
return ctx
|
||||
|
||||
|
||||
# Service dependencies (lazy loading)
|
||||
|
||||
def get_run_service():
|
||||
"""Get the run service."""
|
||||
from .services.run_service import RunService
|
||||
return RunService(
|
||||
database=get_database(),
|
||||
redis=get_redis_client(),
|
||||
cache=get_cache_manager(),
|
||||
)
|
||||
|
||||
|
||||
def get_recipe_service():
|
||||
"""Get the recipe service."""
|
||||
from .services.recipe_service import RecipeService
|
||||
return RecipeService(
|
||||
redis=get_redis_client(), # Kept for API compatibility, not used
|
||||
cache=get_cache_manager(),
|
||||
)
|
||||
|
||||
|
||||
def get_cache_service():
|
||||
"""Get the cache service."""
|
||||
from .services.cache_service import CacheService
|
||||
return CacheService(
|
||||
cache_manager=get_cache_manager(),
|
||||
database=get_database(),
|
||||
)
|
||||
|
||||
|
||||
async def get_nav_counts(actor_id: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Get counts for navigation bar display.
|
||||
|
||||
Returns dict with: runs, recipes, effects, media, storage
|
||||
"""
|
||||
counts = {}
|
||||
|
||||
try:
|
||||
import database
|
||||
counts["media"] = await database.count_user_items(actor_id) if actor_id else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_service = get_recipe_service()
|
||||
recipes = await recipe_service.list_recipes(actor_id)
|
||||
counts["recipes"] = len(recipes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
run_service = get_run_service()
|
||||
runs = await run_service.list_runs(actor_id)
|
||||
counts["runs"] = len(runs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Effects are stored in _effects/ directory, not in cache
|
||||
from pathlib import Path
|
||||
cache_mgr = get_cache_manager()
|
||||
effects_dir = Path(cache_mgr.cache_dir) / "_effects"
|
||||
if effects_dir.exists():
|
||||
counts["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
|
||||
else:
|
||||
counts["effects"] = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import database
|
||||
storage_providers = await database.get_user_storage_providers(actor_id) if actor_id else []
|
||||
counts["storage"] = len(storage_providers) if storage_providers else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return counts
|
||||
10
l1/app/repositories/__init__.py
Normal file
10
l1/app/repositories/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
L1 Server Repositories.
|
||||
|
||||
Data access layer for persistence operations.
|
||||
"""
|
||||
|
||||
# TODO: Implement repositories
|
||||
# - RunRepository - Redis-backed run storage
|
||||
# - RecipeRepository - Redis-backed recipe storage
|
||||
# - CacheRepository - Filesystem + PostgreSQL cache metadata
|
||||
23
l1/app/routers/__init__.py
Normal file
23
l1/app/routers/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
L1 Server Routers.
|
||||
|
||||
Each router handles a specific domain of functionality.
|
||||
"""
|
||||
|
||||
from . import auth
|
||||
from . import storage
|
||||
from . import api
|
||||
from . import recipes
|
||||
from . import cache
|
||||
from . import runs
|
||||
from . import home
|
||||
|
||||
__all__ = [
|
||||
"auth",
|
||||
"storage",
|
||||
"api",
|
||||
"recipes",
|
||||
"cache",
|
||||
"runs",
|
||||
"home",
|
||||
]
|
||||
257
l1/app/routers/api.py
Normal file
257
l1/app/routers/api.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
3-phase API routes for L1 server.
|
||||
|
||||
Provides the plan/execute/run-recipe endpoints for programmatic access.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
from ..dependencies import require_auth, get_redis_client, get_cache_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis key prefix
|
||||
RUNS_KEY_PREFIX = "artdag:run:"
|
||||
|
||||
|
||||
class PlanRequest(BaseModel):
|
||||
recipe_sexp: str
|
||||
input_hashes: Dict[str, str]
|
||||
|
||||
|
||||
class ExecutePlanRequest(BaseModel):
|
||||
plan_json: str
|
||||
run_id: Optional[str] = None
|
||||
|
||||
|
||||
class RecipeRunRequest(BaseModel):
|
||||
recipe_sexp: str
|
||||
input_hashes: Dict[str, str]
|
||||
|
||||
|
||||
def compute_run_id(input_hashes: List[str], recipe: str, recipe_hash: str = None) -> str:
|
||||
"""Compute deterministic run_id from inputs and recipe."""
|
||||
data = {
|
||||
"inputs": sorted(input_hashes),
|
||||
"recipe": recipe_hash or f"effect:{recipe}",
|
||||
"version": "1",
|
||||
}
|
||||
json_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha3_256(json_str.encode()).hexdigest()
|
||||
|
||||
|
||||
@router.post("/plan")
|
||||
async def generate_plan_endpoint(
|
||||
request: PlanRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Generate an execution plan without executing it.
|
||||
|
||||
Phase 1 (Analyze) + Phase 2 (Plan) of the 3-phase model.
|
||||
Returns the plan with cache status for each step.
|
||||
"""
|
||||
from tasks.orchestrate import generate_plan
|
||||
|
||||
try:
|
||||
task = generate_plan.delay(
|
||||
recipe_sexp=request.recipe_sexp,
|
||||
input_hashes=request.input_hashes,
|
||||
)
|
||||
|
||||
# Wait for result (plan generation is usually fast)
|
||||
result = task.get(timeout=60)
|
||||
|
||||
return {
|
||||
"status": result.get("status"),
|
||||
"recipe": result.get("recipe"),
|
||||
"plan_id": result.get("plan_id"),
|
||||
"total_steps": result.get("total_steps"),
|
||||
"cached_steps": result.get("cached_steps"),
|
||||
"pending_steps": result.get("pending_steps"),
|
||||
"steps": result.get("steps"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Plan generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_plan_endpoint(
|
||||
request: ExecutePlanRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Execute a pre-generated execution plan.
|
||||
|
||||
Phase 3 (Execute) of the 3-phase model.
|
||||
Submits the plan to Celery for parallel execution.
|
||||
"""
|
||||
from tasks.orchestrate import run_plan
|
||||
|
||||
run_id = request.run_id or str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
task = run_plan.delay(
|
||||
plan_json=request.plan_json,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"run_id": run_id,
|
||||
"celery_task_id": task.id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Plan execution failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/run-recipe")
|
||||
async def run_recipe_endpoint(
|
||||
request: RecipeRunRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Run a complete recipe through all 3 phases.
|
||||
|
||||
1. Analyze: Extract features from inputs
|
||||
2. Plan: Generate execution plan with cache IDs
|
||||
3. Execute: Run steps with parallel execution
|
||||
|
||||
Returns immediately with run_id. Poll /api/run/{run_id} for status.
|
||||
"""
|
||||
from tasks.orchestrate import run_recipe
|
||||
from artdag.sexp import compile_string
|
||||
import database
|
||||
|
||||
redis = get_redis_client()
|
||||
cache = get_cache_manager()
|
||||
|
||||
# Parse recipe name from S-expression
|
||||
try:
|
||||
compiled = compile_string(request.recipe_sexp)
|
||||
recipe_name = compiled.name or "unknown"
|
||||
except Exception:
|
||||
recipe_name = "unknown"
|
||||
|
||||
# Compute deterministic run_id
|
||||
run_id = compute_run_id(
|
||||
list(request.input_hashes.values()),
|
||||
recipe_name,
|
||||
hashlib.sha3_256(request.recipe_sexp.encode()).hexdigest()
|
||||
)
|
||||
|
||||
# Check if already completed
|
||||
cached = await database.get_run_cache(run_id)
|
||||
if cached:
|
||||
output_cid = cached.get("output_cid")
|
||||
if cache.has_content(output_cid):
|
||||
return {
|
||||
"status": "completed",
|
||||
"run_id": run_id,
|
||||
"output_cid": output_cid,
|
||||
"output_ipfs_cid": cache.get_ipfs_cid(output_cid),
|
||||
"cached": True,
|
||||
}
|
||||
|
||||
# Submit to Celery
|
||||
try:
|
||||
task = run_recipe.delay(
|
||||
recipe_sexp=request.recipe_sexp,
|
||||
input_hashes=request.input_hashes,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
# Store run status in Redis
|
||||
run_data = {
|
||||
"run_id": run_id,
|
||||
"status": "pending",
|
||||
"recipe": recipe_name,
|
||||
"inputs": list(request.input_hashes.values()),
|
||||
"celery_task_id": task.id,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"username": ctx.actor_id,
|
||||
}
|
||||
redis.setex(
|
||||
f"{RUNS_KEY_PREFIX}{run_id}",
|
||||
86400,
|
||||
json.dumps(run_data)
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"run_id": run_id,
|
||||
"celery_task_id": task.id,
|
||||
"recipe": recipe_name,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe run failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/run/{run_id}")
|
||||
async def get_run_status(
|
||||
run_id: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get status of a recipe execution run."""
|
||||
import database
|
||||
from celery.result import AsyncResult
|
||||
|
||||
redis = get_redis_client()
|
||||
|
||||
# Check Redis for run status
|
||||
run_data = redis.get(f"{RUNS_KEY_PREFIX}{run_id}")
|
||||
if run_data:
|
||||
data = json.loads(run_data)
|
||||
|
||||
# If pending, check Celery task status
|
||||
if data.get("status") == "pending" and data.get("celery_task_id"):
|
||||
result = AsyncResult(data["celery_task_id"])
|
||||
|
||||
if result.ready():
|
||||
if result.successful():
|
||||
task_result = result.get()
|
||||
data["status"] = task_result.get("status", "completed")
|
||||
data["output_cid"] = task_result.get("output_cache_id")
|
||||
data["output_ipfs_cid"] = task_result.get("output_ipfs_cid")
|
||||
data["total_steps"] = task_result.get("total_steps")
|
||||
data["cached"] = task_result.get("cached")
|
||||
data["executed"] = task_result.get("executed")
|
||||
|
||||
# Update Redis
|
||||
redis.setex(
|
||||
f"{RUNS_KEY_PREFIX}{run_id}",
|
||||
86400,
|
||||
json.dumps(data)
|
||||
)
|
||||
else:
|
||||
data["status"] = "failed"
|
||||
data["error"] = str(result.result)
|
||||
else:
|
||||
data["celery_status"] = result.status
|
||||
|
||||
return data
|
||||
|
||||
# Check database cache
|
||||
cached = await database.get_run_cache(run_id)
|
||||
if cached:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": "completed",
|
||||
"output_cid": cached.get("output_cid"),
|
||||
"cached": True,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
165
l1/app/routers/auth.py
Normal file
165
l1/app/routers/auth.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Authentication routes — OAuth2 authorization code flow via account.rose-ash.com.
|
||||
|
||||
GET /auth/login — redirect to account OAuth authorize
|
||||
GET /auth/callback — exchange code for user info, set session cookie
|
||||
GET /auth/logout — clear cookie, redirect through account SSO logout
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from itsdangerous import URLSafeSerializer
|
||||
|
||||
from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie
|
||||
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_signer = None
|
||||
|
||||
|
||||
def _get_signer() -> URLSafeSerializer:
|
||||
global _signer
|
||||
if _signer is None:
|
||||
_signer = URLSafeSerializer(settings.secret_key, salt="oauth-state")
|
||||
return _signer
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Store state + next in signed cookie, redirect to account OAuth authorize."""
|
||||
next_url = request.query_params.get("next", "/")
|
||||
prompt = request.query_params.get("prompt", "")
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
signer = _get_signer()
|
||||
state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt})
|
||||
|
||||
device_id = getattr(request.state, "device_id", "")
|
||||
authorize_url = (
|
||||
f"{settings.oauth_authorize_url}"
|
||||
f"?client_id={settings.oauth_client_id}"
|
||||
f"&redirect_uri={settings.oauth_redirect_uri}"
|
||||
f"&device_id={device_id}"
|
||||
f"&state={state}"
|
||||
)
|
||||
if prompt:
|
||||
authorize_url += f"&prompt={prompt}"
|
||||
|
||||
response = RedirectResponse(url=authorize_url, status_code=302)
|
||||
response.set_cookie(
|
||||
key="oauth_state",
|
||||
value=state_payload,
|
||||
max_age=600, # 10 minutes
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(request: Request):
|
||||
"""Validate state, exchange code via token endpoint, set session cookie."""
|
||||
code = request.query_params.get("code", "")
|
||||
state = request.query_params.get("state", "")
|
||||
error = request.query_params.get("error", "")
|
||||
account_did = request.query_params.get("account_did", "")
|
||||
|
||||
# Adopt account's device ID as our own (one identity across all apps)
|
||||
if account_did:
|
||||
request.state.device_id = account_did
|
||||
request.state._new_device_id = True # device_id middleware will set cookie
|
||||
|
||||
# Recover state from signed cookie
|
||||
state_cookie = request.cookies.get("oauth_state", "")
|
||||
signer = _get_signer()
|
||||
try:
|
||||
payload = signer.loads(state_cookie) if state_cookie else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
next_url = payload.get("next", "/")
|
||||
|
||||
# Handle prompt=none rejection (user not logged in on account)
|
||||
if error == "login_required":
|
||||
response = RedirectResponse(url=next_url, status_code=302)
|
||||
response.delete_cookie("oauth_state")
|
||||
# Set cooldown cookie — don't re-check for 5 minutes
|
||||
response.set_cookie(
|
||||
key="pnone_at",
|
||||
value=str(time.time()),
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
# Set device cookie if adopted
|
||||
if account_did:
|
||||
response.set_cookie(
|
||||
key="artdag_did",
|
||||
value=account_did,
|
||||
max_age=30 * 24 * 3600,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
return response
|
||||
|
||||
# Normal callback — validate state + code
|
||||
if not state_cookie or not code or not state:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
if payload.get("state") != state:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
# Exchange code for user info via account's token endpoint
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
settings.oauth_token_url,
|
||||
json={
|
||||
"code": code,
|
||||
"client_id": settings.oauth_client_id,
|
||||
"redirect_uri": settings.oauth_redirect_uri,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
# Map OAuth response to artdag UserContext
|
||||
# Note: account token endpoint returns user.email as "username"
|
||||
display_name = data.get("display_name", "")
|
||||
username = data.get("username", "")
|
||||
email = username # OAuth response "username" is the user's email
|
||||
actor_id = f"@{username}"
|
||||
|
||||
user = UserContext(username=username, actor_id=actor_id, email=email)
|
||||
|
||||
response = RedirectResponse(url=next_url, status_code=302)
|
||||
set_auth_cookie(response, user)
|
||||
response.delete_cookie("oauth_state")
|
||||
response.delete_cookie("pnone_at")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session cookie, redirect through account SSO logout."""
|
||||
response = RedirectResponse(url=settings.oauth_logout_url, status_code=302)
|
||||
clear_auth_cookie(response)
|
||||
response.delete_cookie("oauth_state")
|
||||
response.delete_cookie("pnone_at")
|
||||
return response
|
||||
515
l1/app/routers/cache.py
Normal file
515
l1/app/routers/cache.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Cache and media routes for L1 server.
|
||||
|
||||
Handles content retrieval, metadata, media preview, and publishing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
|
||||
from ..dependencies import (
|
||||
require_auth, get_templates, get_redis_client,
|
||||
get_cache_manager, get_current_user
|
||||
)
|
||||
from ..services.auth_service import AuthService
|
||||
from ..services.cache_service import CacheService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateMetadataRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
custom: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def get_cache_service():
|
||||
"""Get cache service instance."""
|
||||
import database
|
||||
return CacheService(database, get_cache_manager())
|
||||
|
||||
|
||||
@router.get("/{cid}")
|
||||
async def get_cached(
|
||||
cid: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get cached content by hash. Content negotiation: HTML for browsers, JSON for APIs."""
|
||||
ctx = await get_current_user(request)
|
||||
|
||||
# Pass actor_id to get friendly name and user-specific metadata
|
||||
actor_id = ctx.actor_id if ctx else None
|
||||
cache_item = await cache_service.get_cache_item(cid, actor_id=actor_id)
|
||||
if not cache_item:
|
||||
if wants_html(request):
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/not_found.html", request,
|
||||
cid=cid,
|
||||
user=ctx,
|
||||
active_tab="media",
|
||||
)
|
||||
raise HTTPException(404, f"Content {cid} not in cache")
|
||||
|
||||
# JSON response
|
||||
if wants_json(request):
|
||||
return cache_item
|
||||
|
||||
# HTML response
|
||||
if not ctx:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/auth", status_code=302)
|
||||
|
||||
# Check access
|
||||
has_access = await cache_service.check_access(cid, ctx.actor_id, ctx.username)
|
||||
if not has_access:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/detail.html", request,
|
||||
cache=cache_item,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="media",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{cid}/raw")
|
||||
async def get_cached_raw(
|
||||
cid: str,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get raw cached content (file download)."""
|
||||
file_path, media_type, filename = await cache_service.get_raw_file(cid)
|
||||
|
||||
if not file_path:
|
||||
raise HTTPException(404, f"Content {cid} not in cache")
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get("/{cid}/mp4")
|
||||
async def get_cached_mp4(
|
||||
cid: str,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get cached content as MP4 (transcodes MKV on first request)."""
|
||||
mp4_path, error = await cache_service.get_as_mp4(cid)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400 if "not a video" in error else 404, error)
|
||||
|
||||
return FileResponse(mp4_path, media_type="video/mp4")
|
||||
|
||||
|
||||
@router.get("/{cid}/meta")
|
||||
async def get_metadata(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get content metadata."""
|
||||
meta = await cache_service.get_metadata(cid, ctx.actor_id)
|
||||
if meta is None:
|
||||
raise HTTPException(404, "Content not found")
|
||||
return meta
|
||||
|
||||
|
||||
@router.patch("/{cid}/meta")
|
||||
async def update_metadata(
|
||||
cid: str,
|
||||
req: UpdateMetadataRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Update content metadata."""
|
||||
success, error = await cache_service.update_metadata(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
title=req.title,
|
||||
description=req.description,
|
||||
tags=req.tags,
|
||||
custom=req.custom,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"updated": True}
|
||||
|
||||
|
||||
@router.post("/{cid}/publish")
|
||||
async def publish_content(
|
||||
cid: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Publish content to L2 and IPFS."""
|
||||
ipfs_cid, error = await cache_service.publish_to_l2(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
auth_token=request.cookies.get("auth_token"),
|
||||
)
|
||||
|
||||
if error:
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-green-400">Published: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "published": True}
|
||||
|
||||
|
||||
@router.delete("/{cid}")
|
||||
async def delete_content(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Delete content from cache."""
|
||||
success, error = await cache_service.delete_content(cid, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400 if "Cannot" in error or "pinned" in error else 404, error)
|
||||
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_from_ipfs(
|
||||
ipfs_cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Import content from IPFS."""
|
||||
cid, error = await cache_service.import_from_ipfs(ipfs_cid, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"cid": cid, "imported": True}
|
||||
|
||||
|
||||
@router.post("/upload/chunk")
|
||||
async def upload_chunk(
|
||||
request: Request,
|
||||
chunk: UploadFile = File(...),
|
||||
upload_id: str = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
total_chunks: int = Form(...),
|
||||
filename: str = Form(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Upload a file chunk. Assembles file when all chunks received."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create temp dir for this upload
|
||||
chunk_dir = Path(tempfile.gettempdir()) / "uploads" / upload_id
|
||||
chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save this chunk
|
||||
chunk_path = chunk_dir / f"chunk_{chunk_index:05d}"
|
||||
chunk_data = await chunk.read()
|
||||
chunk_path.write_bytes(chunk_data)
|
||||
|
||||
# Check if all chunks received
|
||||
received = len(list(chunk_dir.glob("chunk_*")))
|
||||
|
||||
if received < total_chunks:
|
||||
return {"status": "partial", "received": received, "total": total_chunks}
|
||||
|
||||
# All chunks received - assemble file
|
||||
final_path = chunk_dir / filename
|
||||
with open(final_path, 'wb') as f:
|
||||
for i in range(total_chunks):
|
||||
cp = chunk_dir / f"chunk_{i:05d}"
|
||||
f.write(cp.read_bytes())
|
||||
cp.unlink() # Clean up chunk
|
||||
|
||||
# Read assembled file
|
||||
content = final_path.read_bytes()
|
||||
final_path.unlink()
|
||||
chunk_dir.rmdir()
|
||||
|
||||
# Now do the normal upload flow
|
||||
cid, ipfs_cid, error = await cache_service.upload_content(
|
||||
content=content,
|
||||
filename=filename,
|
||||
actor_id=ctx.actor_id,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
# Assign friendly name
|
||||
final_cid = ipfs_cid or cid
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=final_cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "complete",
|
||||
"cid": final_cid,
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"filename": filename,
|
||||
"size": len(content),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_content(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Upload content to cache and IPFS.
|
||||
|
||||
Args:
|
||||
file: The file to upload
|
||||
display_name: Optional custom name for the media (used as friendly name)
|
||||
"""
|
||||
content = await file.read()
|
||||
cid, ipfs_cid, error = await cache_service.upload_content(
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
actor_id=ctx.actor_id,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
# Assign friendly name (use IPFS CID if available, otherwise local hash)
|
||||
final_cid = ipfs_cid or cid
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=final_cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name, # Use custom name if provided
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
return {
|
||||
"cid": final_cid,
|
||||
"content_hash": cid, # Legacy, for backwards compatibility
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"filename": file.filename,
|
||||
"size": len(content),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
|
||||
# Media listing endpoint
|
||||
@router.get("")
|
||||
async def list_media(
|
||||
request: Request,
|
||||
offset: int = 0,
|
||||
limit: int = 24,
|
||||
media_type: Optional[str] = None,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""List all media in cache."""
|
||||
items = await cache_service.list_media(
|
||||
actor_id=ctx.actor_id,
|
||||
username=ctx.username,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
media_type=media_type,
|
||||
)
|
||||
has_more = len(items) >= limit
|
||||
|
||||
if wants_json(request):
|
||||
return {"items": items, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "cache/media_list.html", request,
|
||||
items=items,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
active_tab="media",
|
||||
)
|
||||
|
||||
|
||||
# HTMX metadata form
|
||||
@router.get("/{cid}/meta-form", response_class=HTMLResponse)
|
||||
async def get_metadata_form(
|
||||
cid: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get metadata editing form (HTMX)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
meta = await cache_service.get_metadata(cid, ctx.actor_id)
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<h2 class="text-lg font-semibold mb-4">Metadata</h2>
|
||||
<form hx-patch="/cache/{cid}/meta"
|
||||
hx-target="#metadata-section"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Title</label>
|
||||
<input type="text" name="title" value="{meta.get('title', '') if meta else ''}"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white"
|
||||
>{meta.get('description', '') if meta else ''}</textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Save Metadata
|
||||
</button>
|
||||
</form>
|
||||
''')
|
||||
|
||||
|
||||
@router.patch("/{cid}/meta", response_class=HTMLResponse)
|
||||
async def update_metadata_htmx(
|
||||
cid: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Update metadata (HTMX form handler)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
success, error = await cache_service.update_metadata(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
title=form_data.get("title"),
|
||||
description=form_data.get("description"),
|
||||
)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse('''
|
||||
<div class="text-green-400 mb-4">Metadata saved!</div>
|
||||
<script>setTimeout(() => location.reload(), 1000);</script>
|
||||
''')
|
||||
|
||||
|
||||
# Friendly name editing
|
||||
@router.get("/{cid}/name-form", response_class=HTMLResponse)
|
||||
async def get_name_form(
|
||||
cid: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get friendly name editing form (HTMX)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
# Get current friendly name
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
entry = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
current_name = entry.get("base_name", "") if entry else ""
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<form hx-post="/cache/{cid}/name"
|
||||
hx-target="#friendly-name-section"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Friendly Name</label>
|
||||
<input type="text" name="display_name" value="{current_name}"
|
||||
placeholder="e.g., my-background-video"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A name to reference this media in recipes</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Save
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="location.reload()"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
''')
|
||||
|
||||
|
||||
@router.post("/{cid}/name", response_class=HTMLResponse)
|
||||
async def update_friendly_name(
|
||||
cid: str,
|
||||
request: Request,
|
||||
):
|
||||
"""Update friendly name (HTMX form handler)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
form_data = await request.form()
|
||||
display_name = form_data.get("display_name", "").strip()
|
||||
|
||||
if not display_name:
|
||||
return HTMLResponse('<div class="text-red-400">Name cannot be empty</div>')
|
||||
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
|
||||
try:
|
||||
entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<div class="text-green-400 mb-2">Name updated!</div>
|
||||
<script>setTimeout(() => location.reload(), 1000);</script>
|
||||
''')
|
||||
except Exception as e:
|
||||
return HTMLResponse(f'<div class="text-red-400">Error: {e}</div>')
|
||||
415
l1/app/routers/effects.py
Normal file
415
l1/app/routers/effects.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Effects routes for L1 server.
|
||||
|
||||
Handles effect upload, listing, and metadata.
|
||||
Effects are S-expression files stored in IPFS like all other content-addressed data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
|
||||
from ..dependencies import (
|
||||
require_auth, get_templates, get_redis_client,
|
||||
get_cache_manager,
|
||||
)
|
||||
from ..services.auth_service import AuthService
|
||||
import ipfs_client
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_effects_dir() -> Path:
|
||||
"""Get effects storage directory."""
|
||||
cache_mgr = get_cache_manager()
|
||||
effects_dir = Path(cache_mgr.cache_dir) / "_effects"
|
||||
effects_dir.mkdir(parents=True, exist_ok=True)
|
||||
return effects_dir
|
||||
|
||||
|
||||
def parse_effect_metadata(source: str) -> dict:
|
||||
"""
|
||||
Parse effect metadata from S-expression source code.
|
||||
|
||||
Extracts metadata from comment headers (;; @key value format)
|
||||
or from (defeffect name ...) form.
|
||||
"""
|
||||
metadata = {
|
||||
"name": "",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"temporal": False,
|
||||
"description": "",
|
||||
"params": [],
|
||||
}
|
||||
|
||||
# Parse comment-based metadata (;; @key value)
|
||||
for line in source.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith(";"):
|
||||
# Stop parsing metadata at first non-comment line
|
||||
if stripped and not stripped.startswith("("):
|
||||
continue
|
||||
if stripped.startswith("("):
|
||||
break
|
||||
|
||||
# Remove comment prefix
|
||||
comment = stripped.lstrip(";").strip()
|
||||
|
||||
if comment.startswith("@effect "):
|
||||
metadata["name"] = comment[8:].strip()
|
||||
elif comment.startswith("@name "):
|
||||
metadata["name"] = comment[6:].strip()
|
||||
elif comment.startswith("@version "):
|
||||
metadata["version"] = comment[9:].strip()
|
||||
elif comment.startswith("@author "):
|
||||
metadata["author"] = comment[8:].strip()
|
||||
elif comment.startswith("@temporal"):
|
||||
val = comment[9:].strip().lower() if len(comment) > 9 else "true"
|
||||
metadata["temporal"] = val in ("true", "yes", "1", "")
|
||||
elif comment.startswith("@description "):
|
||||
metadata["description"] = comment[13:].strip()
|
||||
elif comment.startswith("@param "):
|
||||
# Format: @param name type [description]
|
||||
parts = comment[7:].split(None, 2)
|
||||
if len(parts) >= 2:
|
||||
param = {"name": parts[0], "type": parts[1]}
|
||||
if len(parts) > 2:
|
||||
param["description"] = parts[2]
|
||||
metadata["params"].append(param)
|
||||
|
||||
# Also try to extract name from (defeffect "name" ...) or (effect "name" ...)
|
||||
if not metadata["name"]:
|
||||
name_match = re.search(r'\((defeffect|effect)\s+"([^"]+)"', source)
|
||||
if name_match:
|
||||
metadata["name"] = name_match.group(2)
|
||||
|
||||
# Try to extract name from first (define ...) form
|
||||
if not metadata["name"]:
|
||||
define_match = re.search(r'\(define\s+(\w+)', source)
|
||||
if define_match:
|
||||
metadata["name"] = define_match.group(1)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_effect(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Upload an S-expression effect to IPFS.
|
||||
|
||||
Parses metadata from comment headers.
|
||||
Returns IPFS CID for use in recipes.
|
||||
|
||||
Args:
|
||||
file: The .sexp effect file
|
||||
display_name: Optional custom friendly name for the effect
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Effect must be valid UTF-8 text")
|
||||
|
||||
# Parse metadata from sexp source
|
||||
try:
|
||||
meta = parse_effect_metadata(source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse effect metadata: {e}")
|
||||
meta = {"name": file.filename or "unknown"}
|
||||
|
||||
if not meta.get("name"):
|
||||
meta["name"] = Path(file.filename).stem if file.filename else "unknown"
|
||||
|
||||
# Store effect source in IPFS
|
||||
cid = ipfs_client.add_bytes(content)
|
||||
if not cid:
|
||||
raise HTTPException(500, "Failed to store effect in IPFS")
|
||||
|
||||
# Also keep local cache for fast worker access
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
(effect_dir / "effect.sexp").write_text(source, encoding="utf-8")
|
||||
|
||||
# Store metadata (locally and in IPFS)
|
||||
full_meta = {
|
||||
"cid": cid,
|
||||
"meta": meta,
|
||||
"uploader": ctx.actor_id,
|
||||
"uploaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"filename": file.filename,
|
||||
}
|
||||
(effect_dir / "metadata.json").write_text(json.dumps(full_meta, indent=2))
|
||||
|
||||
# Also store metadata in IPFS for discoverability
|
||||
meta_cid = ipfs_client.add_json(full_meta)
|
||||
|
||||
# Track ownership in item_types
|
||||
import database
|
||||
await database.save_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
# Assign friendly name (use custom display_name if provided, else from metadata)
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
display_name=display_name or meta.get("name"),
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} friendly_name='{friendly_entry['friendly_name']}' by {ctx.actor_id}")
|
||||
|
||||
return {
|
||||
"cid": cid,
|
||||
"metadata_cid": meta_cid,
|
||||
"name": meta.get("name"),
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"version": meta.get("version"),
|
||||
"temporal": meta.get("temporal", False),
|
||||
"params": meta.get("params", []),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{cid}")
|
||||
async def get_effect(
|
||||
cid: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get effect metadata by CID."""
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
metadata_path = effect_dir / "metadata.json"
|
||||
|
||||
# Try local cache first
|
||||
if metadata_path.exists():
|
||||
meta = json.loads(metadata_path.read_text())
|
||||
else:
|
||||
# Fetch from IPFS
|
||||
source_bytes = ipfs_client.get_bytes(cid)
|
||||
if not source_bytes:
|
||||
raise HTTPException(404, f"Effect {cid[:16]}... not found")
|
||||
|
||||
# Cache locally
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
(effect_dir / "effect.sexp").write_text(source)
|
||||
|
||||
# Parse metadata from source
|
||||
parsed_meta = parse_effect_metadata(source)
|
||||
meta = {"cid": cid, "meta": parsed_meta}
|
||||
(effect_dir / "metadata.json").write_text(json.dumps(meta, indent=2))
|
||||
|
||||
# Add friendly name if available
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
meta["version_id"] = friendly["version_id"]
|
||||
|
||||
if wants_json(request):
|
||||
return meta
|
||||
|
||||
# HTML response
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "effects/detail.html", request,
|
||||
effect=meta,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="effects",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{cid}/source")
|
||||
async def get_effect_source(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get effect source code."""
|
||||
effects_dir = get_effects_dir()
|
||||
source_path = effects_dir / cid / "effect.sexp"
|
||||
|
||||
# Try local cache first (check both .sexp and legacy .py)
|
||||
if source_path.exists():
|
||||
return PlainTextResponse(source_path.read_text())
|
||||
|
||||
legacy_path = effects_dir / cid / "effect.py"
|
||||
if legacy_path.exists():
|
||||
return PlainTextResponse(legacy_path.read_text())
|
||||
|
||||
# Fetch from IPFS
|
||||
source_bytes = ipfs_client.get_bytes(cid)
|
||||
if not source_bytes:
|
||||
raise HTTPException(404, f"Effect {cid[:16]}... not found")
|
||||
|
||||
# Cache locally
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
source_path.write_text(source)
|
||||
|
||||
return PlainTextResponse(source)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_effects(
|
||||
request: Request,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""List user's effects with pagination."""
|
||||
import database
|
||||
effects_dir = get_effects_dir()
|
||||
effects = []
|
||||
|
||||
# Get user's effect CIDs from item_types
|
||||
user_items = await database.get_user_items(ctx.actor_id, item_type="effect", limit=1000)
|
||||
effect_cids = [item["cid"] for item in user_items]
|
||||
|
||||
# Get naming service for friendly name lookup
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
|
||||
for cid in effect_cids:
|
||||
effect_dir = effects_dir / cid
|
||||
metadata_path = effect_dir / "metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
meta = json.loads(metadata_path.read_text())
|
||||
# Add friendly name if available
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
effects.append(meta)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Sort by upload time (newest first)
|
||||
effects.sort(key=lambda e: e.get("uploaded_at", ""), reverse=True)
|
||||
|
||||
# Apply pagination
|
||||
total = len(effects)
|
||||
paginated_effects = effects[offset:offset + limit]
|
||||
has_more = offset + limit < total
|
||||
|
||||
if wants_json(request):
|
||||
return {"effects": paginated_effects, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "effects/list.html", request,
|
||||
effects=paginated_effects,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="effects",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{cid}/publish")
|
||||
async def publish_effect(
|
||||
cid: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Publish effect to L2 ActivityPub server."""
|
||||
from ..services.cache_service import CacheService
|
||||
import database
|
||||
|
||||
# Verify effect exists
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
if not effect_dir.exists():
|
||||
error = "Effect not found"
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(404, error)
|
||||
|
||||
# Use cache service to publish
|
||||
cache_service = CacheService(database, get_cache_manager())
|
||||
ipfs_cid, error = await cache_service.publish_to_l2(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
auth_token=request.cookies.get("auth_token"),
|
||||
)
|
||||
|
||||
if error:
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(400, error)
|
||||
|
||||
logger.info(f"Published effect {cid[:16]}... to L2 by {ctx.actor_id}")
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-green-400">Shared: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "cid": cid, "published": True}
|
||||
|
||||
|
||||
@router.delete("/{cid}")
|
||||
async def delete_effect(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Remove user's ownership link to an effect."""
|
||||
import database
|
||||
|
||||
# Remove user's ownership link from item_types
|
||||
await database.delete_item_type(cid, ctx.actor_id, "effect")
|
||||
|
||||
# Remove friendly name
|
||||
await database.delete_friendly_name(ctx.actor_id, cid)
|
||||
|
||||
# Check if anyone still owns this effect
|
||||
remaining_owners = await database.get_item_types(cid)
|
||||
|
||||
# Only delete local files if no one owns it anymore
|
||||
if not remaining_owners:
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
if effect_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(effect_dir)
|
||||
|
||||
# Unpin from IPFS
|
||||
ipfs_client.unpin(cid)
|
||||
logger.info(f"Garbage collected effect {cid[:16]}... (no remaining owners)")
|
||||
|
||||
logger.info(f"Removed effect {cid[:16]}... ownership for {ctx.actor_id}")
|
||||
return {"deleted": True}
|
||||
143
l1/app/routers/fragments.py
Normal file
143
l1/app/routers/fragments.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Art-DAG fragment endpoints.
|
||||
|
||||
Exposes HTML fragments at ``/internal/fragments/{type}`` for consumption
|
||||
by coop apps via the fragment client.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Registry of fragment handlers: type -> async callable(request) returning HTML str
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||
|
||||
|
||||
@router.get("/internal/fragments/{fragment_type}")
|
||||
async def get_fragment(fragment_type: str, request: Request):
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response(content="", status_code=403)
|
||||
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response(content="", media_type="text/html", status_code=200)
|
||||
html = await handler(request)
|
||||
return Response(content=html, media_type="text/html", status_code=200)
|
||||
|
||||
|
||||
# --- nav-item fragment ---
|
||||
|
||||
async def _nav_item_handler(request: Request) -> str:
|
||||
from artdag_common import render_fragment
|
||||
|
||||
templates = request.app.state.templates
|
||||
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||
return render_fragment(templates, "fragments/nav_item.html", artdag_url=artdag_url)
|
||||
|
||||
|
||||
_handlers["nav-item"] = _nav_item_handler
|
||||
|
||||
|
||||
# --- link-card fragment ---
|
||||
|
||||
async def _link_card_handler(request: Request) -> str:
|
||||
from artdag_common import render_fragment
|
||||
import database
|
||||
|
||||
templates = request.app.state.templates
|
||||
cid = request.query_params.get("cid", "")
|
||||
content_type = request.query_params.get("type", "media")
|
||||
slug = request.query_params.get("slug", "")
|
||||
keys_raw = request.query_params.get("keys", "")
|
||||
|
||||
# Batch mode: return multiple cards separated by markers
|
||||
if keys_raw:
|
||||
keys = [k.strip() for k in keys_raw.split(",") if k.strip()]
|
||||
parts = []
|
||||
for key in keys:
|
||||
parts.append(f"<!-- fragment:{key} -->")
|
||||
card_html = await _render_single_link_card(
|
||||
templates, key, content_type,
|
||||
)
|
||||
parts.append(card_html)
|
||||
return "\n".join(parts)
|
||||
|
||||
# Single mode: use cid or slug
|
||||
lookup_cid = cid or slug
|
||||
if not lookup_cid:
|
||||
return ""
|
||||
return await _render_single_link_card(templates, lookup_cid, content_type)
|
||||
|
||||
|
||||
async def _render_single_link_card(templates, cid: str, content_type: str) -> str:
|
||||
import database
|
||||
from artdag_common import render_fragment
|
||||
|
||||
if not cid:
|
||||
return ""
|
||||
|
||||
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||
|
||||
# Try item_types first (has metadata)
|
||||
item = await database.get_item_types(cid)
|
||||
# get_item_types returns a list; pick best match for content_type
|
||||
meta = None
|
||||
if item:
|
||||
for it in item:
|
||||
if it.get("type") == content_type:
|
||||
meta = it
|
||||
break
|
||||
if not meta:
|
||||
meta = item[0]
|
||||
|
||||
# Try friendly name for display
|
||||
friendly = None
|
||||
if meta and meta.get("actor_id"):
|
||||
friendly = await database.get_friendly_name_by_cid(meta["actor_id"], cid)
|
||||
|
||||
# Try run cache if type is "run"
|
||||
run = None
|
||||
if content_type == "run":
|
||||
run = await database.get_run_cache(cid)
|
||||
|
||||
title = ""
|
||||
description = ""
|
||||
link = ""
|
||||
|
||||
if friendly:
|
||||
title = friendly.get("display_name") or friendly.get("base_name", cid[:12])
|
||||
elif meta:
|
||||
title = meta.get("filename") or meta.get("description", cid[:12])
|
||||
elif run:
|
||||
title = f"Run {cid[:12]}"
|
||||
else:
|
||||
title = cid[:16]
|
||||
|
||||
if meta:
|
||||
description = meta.get("description", "")
|
||||
|
||||
if content_type == "run":
|
||||
link = f"{artdag_url}/runs/{cid}"
|
||||
elif content_type == "recipe":
|
||||
link = f"{artdag_url}/recipes/{cid}"
|
||||
elif content_type == "effect":
|
||||
link = f"{artdag_url}/effects/{cid}"
|
||||
else:
|
||||
link = f"{artdag_url}/cache/{cid}"
|
||||
|
||||
return render_fragment(
|
||||
templates, "fragments/link_card.html",
|
||||
title=title,
|
||||
description=description,
|
||||
link=link,
|
||||
cid=cid,
|
||||
content_type=content_type,
|
||||
artdag_url=artdag_url,
|
||||
)
|
||||
|
||||
|
||||
_handlers["link-card"] = _link_card_handler
|
||||
253
l1/app/routers/home.py
Normal file
253
l1/app/routers/home.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Home and root routes for L1 server.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html
|
||||
|
||||
from ..dependencies import get_templates, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint — always returns 200."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def get_user_stats(actor_id: str) -> dict:
|
||||
"""Get stats for a user."""
|
||||
import database
|
||||
from ..services.run_service import RunService
|
||||
from ..dependencies import get_redis_client, get_cache_manager
|
||||
|
||||
stats = {}
|
||||
|
||||
try:
|
||||
# Count only actual media types (video, image, audio), not effects/recipes
|
||||
media_count = 0
|
||||
for media_type in ["video", "image", "audio", "unknown"]:
|
||||
media_count += await database.count_user_items(actor_id, item_type=media_type)
|
||||
stats["media"] = media_count
|
||||
except Exception:
|
||||
stats["media"] = 0
|
||||
|
||||
try:
|
||||
# Count user's recipes from database (ownership-based)
|
||||
stats["recipes"] = await database.count_user_items(actor_id, item_type="recipe")
|
||||
except Exception:
|
||||
stats["recipes"] = 0
|
||||
|
||||
try:
|
||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||
runs = await run_service.list_runs(actor_id)
|
||||
stats["runs"] = len(runs)
|
||||
except Exception:
|
||||
stats["runs"] = 0
|
||||
|
||||
try:
|
||||
storage_providers = await database.get_user_storage_providers(actor_id)
|
||||
stats["storage"] = len(storage_providers) if storage_providers else 0
|
||||
except Exception:
|
||||
stats["storage"] = 0
|
||||
|
||||
try:
|
||||
# Count user's effects from database (ownership-based)
|
||||
stats["effects"] = await database.count_user_items(actor_id, item_type="effect")
|
||||
except Exception:
|
||||
stats["effects"] = 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/api/stats")
|
||||
async def api_stats(request: Request):
|
||||
"""Get user stats as JSON for CLI and API clients."""
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
stats = await get_user_stats(user.actor_id)
|
||||
return stats
|
||||
|
||||
|
||||
@router.delete("/api/clear-data")
|
||||
async def clear_user_data(request: Request):
|
||||
"""
|
||||
Clear all user L1 data except storage configuration.
|
||||
|
||||
Deletes: runs, recipes, effects, media/cache items.
|
||||
Preserves: storage provider configurations.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
import database
|
||||
from ..services.recipe_service import RecipeService
|
||||
from ..services.run_service import RunService
|
||||
from ..dependencies import get_redis_client, get_cache_manager
|
||||
|
||||
actor_id = user.actor_id
|
||||
username = user.username
|
||||
deleted = {
|
||||
"runs": 0,
|
||||
"recipes": 0,
|
||||
"effects": 0,
|
||||
"media": 0,
|
||||
}
|
||||
errors = []
|
||||
|
||||
# Delete all runs
|
||||
try:
|
||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||
runs = await run_service.list_runs(actor_id, offset=0, limit=10000)
|
||||
for run in runs:
|
||||
try:
|
||||
await run_service.discard_run(run["run_id"], actor_id, username)
|
||||
deleted["runs"] += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Run {run['run_id']}: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to list runs: {e}")
|
||||
|
||||
# Delete all recipes
|
||||
try:
|
||||
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
||||
recipes = await recipe_service.list_recipes(actor_id, offset=0, limit=10000)
|
||||
for recipe in recipes:
|
||||
try:
|
||||
success, error = await recipe_service.delete_recipe(recipe["recipe_id"], actor_id)
|
||||
if success:
|
||||
deleted["recipes"] += 1
|
||||
else:
|
||||
errors.append(f"Recipe {recipe['recipe_id']}: {error}")
|
||||
except Exception as e:
|
||||
errors.append(f"Recipe {recipe['recipe_id']}: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to list recipes: {e}")
|
||||
|
||||
# Delete all effects (uses ownership model)
|
||||
cache_manager = get_cache_manager()
|
||||
try:
|
||||
# Get user's effects from item_types
|
||||
effect_items = await database.get_user_items(actor_id, item_type="effect", limit=10000)
|
||||
for item in effect_items:
|
||||
cid = item.get("cid")
|
||||
if cid:
|
||||
try:
|
||||
# Remove ownership link
|
||||
await database.delete_item_type(cid, actor_id, "effect")
|
||||
await database.delete_friendly_name(actor_id, cid)
|
||||
|
||||
# Check if orphaned
|
||||
remaining = await database.get_item_types(cid)
|
||||
if not remaining:
|
||||
# Garbage collect
|
||||
effects_dir = Path(cache_manager.cache_dir) / "_effects" / cid
|
||||
if effects_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(effects_dir)
|
||||
import ipfs_client
|
||||
ipfs_client.unpin(cid)
|
||||
deleted["effects"] += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Effect {cid[:16]}...: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to delete effects: {e}")
|
||||
|
||||
# Delete all media/cache items for user (uses ownership model)
|
||||
try:
|
||||
from ..services.cache_service import CacheService
|
||||
cache_service = CacheService(database, cache_manager)
|
||||
|
||||
# Get user's media items (video, image, audio)
|
||||
for media_type in ["video", "image", "audio", "unknown"]:
|
||||
items = await database.get_user_items(actor_id, item_type=media_type, limit=10000)
|
||||
for item in items:
|
||||
cid = item.get("cid")
|
||||
if cid:
|
||||
try:
|
||||
success, error = await cache_service.delete_content(cid, actor_id)
|
||||
if success:
|
||||
deleted["media"] += 1
|
||||
elif error:
|
||||
errors.append(f"Media {cid[:16]}...: {error}")
|
||||
except Exception as e:
|
||||
errors.append(f"Media {cid[:16]}...: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to delete media: {e}")
|
||||
|
||||
logger.info(f"Cleared data for {actor_id}: {deleted}")
|
||||
if errors:
|
||||
logger.warning(f"Errors during clear: {errors[:10]}") # Log first 10 errors
|
||||
|
||||
return {
|
||||
"message": "User data cleared",
|
||||
"deleted": deleted,
|
||||
"errors": errors[:10] if errors else [], # Return first 10 errors
|
||||
"storage_preserved": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home(request: Request):
|
||||
"""
|
||||
Home page - show README and stats.
|
||||
"""
|
||||
user = await get_current_user(request)
|
||||
|
||||
# Load README
|
||||
readme_html = ""
|
||||
try:
|
||||
readme_path = Path(__file__).parent.parent.parent / "README.md"
|
||||
if readme_path.exists():
|
||||
readme_html = markdown.markdown(readme_path.read_text(), extensions=['tables', 'fenced_code'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get stats for current user
|
||||
stats = {}
|
||||
if user:
|
||||
stats = await get_user_stats(user.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "home.html", request,
|
||||
user=user,
|
||||
readme_html=readme_html,
|
||||
stats=stats,
|
||||
nav_counts=stats, # Reuse stats for nav counts
|
||||
active_tab="home",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_redirect(request: Request):
|
||||
"""Redirect to OAuth login flow."""
|
||||
return RedirectResponse(url="/auth/login", status_code=302)
|
||||
|
||||
|
||||
# Client tarball path
|
||||
CLIENT_TARBALL = Path(__file__).parent.parent.parent / "artdag-client.tar.gz"
|
||||
|
||||
|
||||
@router.get("/download/client")
|
||||
async def download_client():
|
||||
"""Download the Art DAG CLI client."""
|
||||
if not CLIENT_TARBALL.exists():
|
||||
raise HTTPException(404, "Client package not found. Run build-client.sh to create it.")
|
||||
return FileResponse(
|
||||
CLIENT_TARBALL,
|
||||
media_type="application/gzip",
|
||||
filename="artdag-client.tar.gz"
|
||||
)
|
||||
125
l1/app/routers/inbox.py
Normal file
125
l1/app/routers/inbox.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""AP-style inbox endpoint for receiving signed activities from the coop.
|
||||
|
||||
POST /inbox — verify HTTP Signature, dispatch by activity type.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..dependencies import get_redis_client
|
||||
from ..utils.http_signatures import verify_request_signature, parse_key_id
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Cache fetched public keys in Redis for 24 hours
|
||||
_KEY_CACHE_TTL = 86400
|
||||
|
||||
|
||||
async def _fetch_actor_public_key(actor_url: str) -> str | None:
|
||||
"""Fetch an actor's public key, with Redis caching."""
|
||||
redis = get_redis_client()
|
||||
cache_key = f"actor_pubkey:{actor_url}"
|
||||
|
||||
# Check cache
|
||||
cached = redis.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Fetch actor JSON
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
actor_url,
|
||||
headers={"Accept": "application/activity+json, application/ld+json"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning("Failed to fetch actor %s: %d", actor_url, resp.status_code)
|
||||
return None
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
log.warning("Error fetching actor %s", actor_url, exc_info=True)
|
||||
return None
|
||||
|
||||
pub_key_pem = (data.get("publicKey") or {}).get("publicKeyPem")
|
||||
if not pub_key_pem:
|
||||
log.warning("No publicKey in actor %s", actor_url)
|
||||
return None
|
||||
|
||||
# Cache it
|
||||
redis.set(cache_key, pub_key_pem, ex=_KEY_CACHE_TTL)
|
||||
return pub_key_pem
|
||||
|
||||
|
||||
@router.post("/inbox")
|
||||
async def inbox(request: Request):
|
||||
"""Receive signed AP activities from the coop platform."""
|
||||
sig_header = request.headers.get("signature", "")
|
||||
if not sig_header:
|
||||
return JSONResponse({"error": "missing signature"}, status_code=401)
|
||||
|
||||
# Read body
|
||||
body = await request.body()
|
||||
|
||||
# Verify HTTP Signature
|
||||
actor_url = parse_key_id(sig_header)
|
||||
if not actor_url:
|
||||
return JSONResponse({"error": "invalid keyId"}, status_code=401)
|
||||
|
||||
pub_key = await _fetch_actor_public_key(actor_url)
|
||||
if not pub_key:
|
||||
return JSONResponse({"error": "could not fetch public key"}, status_code=401)
|
||||
|
||||
req_headers = dict(request.headers)
|
||||
path = request.url.path
|
||||
valid = verify_request_signature(
|
||||
public_key_pem=pub_key,
|
||||
signature_header=sig_header,
|
||||
method="POST",
|
||||
path=path,
|
||||
headers=req_headers,
|
||||
)
|
||||
if not valid:
|
||||
log.warning("Invalid signature from %s", actor_url)
|
||||
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
||||
|
||||
# Parse and dispatch
|
||||
try:
|
||||
activity = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "invalid json"}, status_code=400)
|
||||
|
||||
activity_type = activity.get("type", "")
|
||||
log.info("Inbox received: %s from %s", activity_type, actor_url)
|
||||
|
||||
if activity_type == "rose:DeviceAuth":
|
||||
_handle_device_auth(activity)
|
||||
|
||||
# Always 202 — AP convention
|
||||
return JSONResponse({"status": "accepted"}, status_code=202)
|
||||
|
||||
|
||||
def _handle_device_auth(activity: dict) -> None:
|
||||
"""Set or delete did_auth:{device_id} in local Redis."""
|
||||
obj = activity.get("object", {})
|
||||
device_id = obj.get("device_id", "")
|
||||
action = obj.get("action", "")
|
||||
|
||||
if not device_id:
|
||||
log.warning("rose:DeviceAuth missing device_id")
|
||||
return
|
||||
|
||||
redis = get_redis_client()
|
||||
if action == "login":
|
||||
redis.set(f"did_auth:{device_id}", str(time.time()), ex=30 * 24 * 3600)
|
||||
log.info("did_auth set for device %s...", device_id[:16])
|
||||
elif action == "logout":
|
||||
redis.delete(f"did_auth:{device_id}")
|
||||
log.info("did_auth cleared for device %s...", device_id[:16])
|
||||
else:
|
||||
log.warning("rose:DeviceAuth unknown action: %s", action)
|
||||
74
l1/app/routers/oembed.py
Normal file
74
l1/app/routers/oembed.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Art-DAG oEmbed endpoint.
|
||||
|
||||
Returns oEmbed JSON responses for Art-DAG content (media, recipes, effects, runs).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/oembed")
|
||||
async def oembed(request: Request):
|
||||
url = request.query_params.get("url", "")
|
||||
if not url:
|
||||
return JSONResponse({"error": "url parameter required"}, status_code=400)
|
||||
|
||||
# Parse URL to extract content type and CID
|
||||
# URL patterns: /cache/{cid}, /recipes/{cid}, /effects/{cid}, /runs/{cid}
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
||||
|
||||
if len(parts) < 2:
|
||||
return JSONResponse({"error": "could not parse content URL"}, status_code=404)
|
||||
|
||||
content_type = parts[0].rstrip("s") # recipes -> recipe, runs -> run
|
||||
cid = parts[1]
|
||||
|
||||
import database
|
||||
|
||||
title = cid[:16]
|
||||
thumbnail_url = None
|
||||
|
||||
# Look up metadata
|
||||
items = await database.get_item_types(cid)
|
||||
if items:
|
||||
meta = items[0]
|
||||
title = meta.get("filename") or meta.get("description") or title
|
||||
|
||||
# Try friendly name
|
||||
actor_id = meta.get("actor_id")
|
||||
if actor_id:
|
||||
friendly = await database.get_friendly_name_by_cid(actor_id, cid)
|
||||
if friendly:
|
||||
title = friendly.get("display_name") or friendly.get("base_name", title)
|
||||
|
||||
# Media items get a thumbnail
|
||||
if meta.get("type") == "media":
|
||||
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||
thumbnail_url = f"{artdag_url}/cache/{cid}/raw"
|
||||
|
||||
elif content_type == "run":
|
||||
run = await database.get_run_cache(cid)
|
||||
if run:
|
||||
title = f"Run {cid[:12]}"
|
||||
|
||||
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
|
||||
|
||||
resp = {
|
||||
"version": "1.0",
|
||||
"type": "link",
|
||||
"title": title,
|
||||
"provider_name": "art-dag",
|
||||
"provider_url": artdag_url,
|
||||
"url": url,
|
||||
}
|
||||
if thumbnail_url:
|
||||
resp["thumbnail_url"] = thumbnail_url
|
||||
|
||||
return JSONResponse(resp)
|
||||
686
l1/app/routers/recipes.py
Normal file
686
l1/app/routers/recipes.py
Normal file
@@ -0,0 +1,686 @@
|
||||
"""
|
||||
Recipe management routes for L1 server.
|
||||
|
||||
Handles recipe upload, listing, viewing, and execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
|
||||
from ..dependencies import require_auth, get_current_user, get_templates, get_redis_client, get_cache_manager
|
||||
from ..services.auth_service import AuthService
|
||||
from ..services.recipe_service import RecipeService
|
||||
from ..types import (
|
||||
CompiledNode, TransformedNode, Registry, Recipe,
|
||||
is_variable_input, get_effect_cid,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeUploadRequest(BaseModel):
|
||||
content: str # S-expression or YAML
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class RecipeRunRequest(BaseModel):
|
||||
"""Request to run a recipe with variable inputs."""
|
||||
inputs: Dict[str, str] = {} # Map input names to CIDs
|
||||
|
||||
|
||||
def get_recipe_service() -> RecipeService:
|
||||
"""Get recipe service instance."""
|
||||
return RecipeService(get_redis_client(), get_cache_manager())
|
||||
|
||||
|
||||
def transform_node(
|
||||
node: CompiledNode,
|
||||
assets: Dict[str, Dict[str, Any]],
|
||||
effects: Dict[str, Dict[str, Any]],
|
||||
) -> TransformedNode:
|
||||
"""
|
||||
Transform a compiled node to artdag execution format.
|
||||
|
||||
- Resolves asset references to CIDs for SOURCE nodes
|
||||
- Resolves effect references to CIDs for EFFECT nodes
|
||||
- Renames 'type' to 'node_type', 'id' to 'node_id'
|
||||
"""
|
||||
node_id = node.get("id", "")
|
||||
config = dict(node.get("config", {})) # Copy to avoid mutation
|
||||
|
||||
# Resolve asset references for SOURCE nodes
|
||||
if node.get("type") == "SOURCE" and "asset" in config:
|
||||
asset_name = config["asset"]
|
||||
if asset_name in assets:
|
||||
config["cid"] = assets[asset_name].get("cid")
|
||||
|
||||
# Resolve effect references for EFFECT nodes
|
||||
if node.get("type") == "EFFECT" and "effect" in config:
|
||||
effect_name = config["effect"]
|
||||
if effect_name in effects:
|
||||
config["cid"] = effects[effect_name].get("cid")
|
||||
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"node_type": node.get("type", "EFFECT"),
|
||||
"config": config,
|
||||
"inputs": node.get("inputs", []),
|
||||
"name": node.get("name"),
|
||||
}
|
||||
|
||||
|
||||
def build_input_name_mapping(
|
||||
nodes: Dict[str, TransformedNode],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Build a mapping from input names to node IDs for variable inputs.
|
||||
|
||||
Variable inputs can be referenced by:
|
||||
- node_id directly
|
||||
- config.name (e.g., "Second Video")
|
||||
- snake_case version (e.g., "second_video")
|
||||
- kebab-case version (e.g., "second-video")
|
||||
- node.name (def binding name)
|
||||
"""
|
||||
input_name_to_node: Dict[str, str] = {}
|
||||
|
||||
for node_id, node in nodes.items():
|
||||
if node.get("node_type") != "SOURCE":
|
||||
continue
|
||||
|
||||
config = node.get("config", {})
|
||||
if not is_variable_input(config):
|
||||
continue
|
||||
|
||||
# Map by node_id
|
||||
input_name_to_node[node_id] = node_id
|
||||
|
||||
# Map by config.name
|
||||
name = config.get("name")
|
||||
if name:
|
||||
input_name_to_node[name] = node_id
|
||||
input_name_to_node[name.lower().replace(" ", "_")] = node_id
|
||||
input_name_to_node[name.lower().replace(" ", "-")] = node_id
|
||||
|
||||
# Map by node.name (def binding)
|
||||
node_name = node.get("name")
|
||||
if node_name:
|
||||
input_name_to_node[node_name] = node_id
|
||||
input_name_to_node[node_name.replace("-", "_")] = node_id
|
||||
|
||||
return input_name_to_node
|
||||
|
||||
|
||||
def bind_inputs(
|
||||
nodes: Dict[str, TransformedNode],
|
||||
input_name_to_node: Dict[str, str],
|
||||
user_inputs: Dict[str, str],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Bind user-provided input CIDs to source nodes.
|
||||
|
||||
Returns list of warnings for inputs that couldn't be bound.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
|
||||
for input_name, cid in user_inputs.items():
|
||||
# Try direct node ID match first
|
||||
if input_name in nodes:
|
||||
node = nodes[input_name]
|
||||
if node.get("node_type") == "SOURCE":
|
||||
node["config"]["cid"] = cid
|
||||
logger.info(f"Bound input {input_name} directly to node, cid={cid[:16]}...")
|
||||
continue
|
||||
|
||||
# Try input name lookup
|
||||
if input_name in input_name_to_node:
|
||||
node_id = input_name_to_node[input_name]
|
||||
node = nodes[node_id]
|
||||
node["config"]["cid"] = cid
|
||||
logger.info(f"Bound input {input_name} via lookup to node {node_id}, cid={cid[:16]}...")
|
||||
continue
|
||||
|
||||
# Input not found
|
||||
warnings.append(f"Input '{input_name}' not found in recipe")
|
||||
logger.warning(f"Input {input_name} not found in nodes or input_name_to_node")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
async def resolve_friendly_names_in_registry(
|
||||
registry: dict,
|
||||
actor_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Resolve friendly names to CIDs in the registry.
|
||||
|
||||
Friendly names are identified by containing a space (e.g., "brightness 01hw3x9k")
|
||||
or by not being a valid CID format.
|
||||
"""
|
||||
from ..services.naming_service import get_naming_service
|
||||
import re
|
||||
|
||||
naming = get_naming_service()
|
||||
resolved = {"assets": {}, "effects": {}}
|
||||
|
||||
# CID patterns: IPFS CID (Qm..., bafy...) or SHA256 hash (64 hex chars)
|
||||
cid_pattern = re.compile(r'^(Qm[a-zA-Z0-9]{44}|bafy[a-zA-Z0-9]+|[a-f0-9]{64})$')
|
||||
|
||||
for asset_name, asset_info in registry.get("assets", {}).items():
|
||||
cid = asset_info.get("cid", "")
|
||||
if cid and not cid_pattern.match(cid):
|
||||
# Looks like a friendly name, resolve it
|
||||
resolved_cid = await naming.resolve(actor_id, cid, item_type="media")
|
||||
if resolved_cid:
|
||||
asset_info = dict(asset_info)
|
||||
asset_info["cid"] = resolved_cid
|
||||
asset_info["_resolved_from"] = cid
|
||||
resolved["assets"][asset_name] = asset_info
|
||||
|
||||
for effect_name, effect_info in registry.get("effects", {}).items():
|
||||
cid = effect_info.get("cid", "")
|
||||
if cid and not cid_pattern.match(cid):
|
||||
# Looks like a friendly name, resolve it
|
||||
resolved_cid = await naming.resolve(actor_id, cid, item_type="effect")
|
||||
if resolved_cid:
|
||||
effect_info = dict(effect_info)
|
||||
effect_info["cid"] = resolved_cid
|
||||
effect_info["_resolved_from"] = cid
|
||||
resolved["effects"][effect_name] = effect_info
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
async def prepare_dag_for_execution(
|
||||
recipe: Recipe,
|
||||
user_inputs: Dict[str, str],
|
||||
actor_id: str = None,
|
||||
) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Prepare a recipe DAG for execution by transforming nodes and binding inputs.
|
||||
|
||||
Resolves friendly names to CIDs if actor_id is provided.
|
||||
Returns (dag_json, warnings).
|
||||
"""
|
||||
recipe_dag = recipe.get("dag")
|
||||
if not recipe_dag or not isinstance(recipe_dag, dict):
|
||||
raise ValueError("Recipe has no DAG definition")
|
||||
|
||||
# Deep copy to avoid mutating original
|
||||
dag_copy = json.loads(json.dumps(recipe_dag))
|
||||
nodes = dag_copy.get("nodes", {})
|
||||
|
||||
# Get registry for resolving references
|
||||
registry = recipe.get("registry", {})
|
||||
|
||||
# Resolve friendly names to CIDs
|
||||
if actor_id and registry:
|
||||
registry = await resolve_friendly_names_in_registry(registry, actor_id)
|
||||
|
||||
assets = registry.get("assets", {}) if registry else {}
|
||||
effects = registry.get("effects", {}) if registry else {}
|
||||
|
||||
# Transform nodes from list to dict if needed
|
||||
if isinstance(nodes, list):
|
||||
nodes_dict: Dict[str, TransformedNode] = {}
|
||||
for node in nodes:
|
||||
node_id = node.get("id")
|
||||
if node_id:
|
||||
nodes_dict[node_id] = transform_node(node, assets, effects)
|
||||
nodes = nodes_dict
|
||||
dag_copy["nodes"] = nodes
|
||||
|
||||
# Build input name mapping and bind user inputs
|
||||
input_name_to_node = build_input_name_mapping(nodes)
|
||||
logger.info(f"Input name to node mapping: {input_name_to_node}")
|
||||
logger.info(f"User-provided inputs: {user_inputs}")
|
||||
|
||||
warnings = bind_inputs(nodes, input_name_to_node, user_inputs)
|
||||
|
||||
# Log final SOURCE node configs for debugging
|
||||
for nid, n in nodes.items():
|
||||
if n.get("node_type") == "SOURCE":
|
||||
logger.info(f"Final SOURCE node {nid}: config={n.get('config')}")
|
||||
|
||||
# Transform output to output_id
|
||||
if "output" in dag_copy:
|
||||
dag_copy["output_id"] = dag_copy.pop("output")
|
||||
|
||||
# Add metadata if not present
|
||||
if "metadata" not in dag_copy:
|
||||
dag_copy["metadata"] = {}
|
||||
|
||||
return json.dumps(dag_copy), warnings
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_recipe(
|
||||
file: UploadFile = File(...),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Upload a new recipe from S-expression or YAML file."""
|
||||
import yaml
|
||||
|
||||
# Read content from the uploaded file
|
||||
content = (await file.read()).decode("utf-8")
|
||||
|
||||
# Detect format (skip comments starting with ;)
|
||||
def is_sexp_format(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(')
|
||||
return False
|
||||
|
||||
is_sexp = is_sexp_format(content)
|
||||
|
||||
try:
|
||||
from artdag.sexp import compile_string, ParseError, CompileError
|
||||
SEXP_AVAILABLE = True
|
||||
except ImportError:
|
||||
SEXP_AVAILABLE = False
|
||||
|
||||
recipe_name = None
|
||||
recipe_version = "1.0"
|
||||
recipe_description = None
|
||||
variable_inputs = []
|
||||
fixed_inputs = []
|
||||
|
||||
if is_sexp:
|
||||
if not SEXP_AVAILABLE:
|
||||
raise HTTPException(500, "S-expression recipes require artdag.sexp module (not installed on server)")
|
||||
# Parse S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_name = compiled.name
|
||||
recipe_version = compiled.version
|
||||
recipe_description = compiled.description
|
||||
|
||||
for node in compiled.nodes:
|
||||
if node.get("type") == "SOURCE":
|
||||
config = node.get("config", {})
|
||||
if config.get("input"):
|
||||
variable_inputs.append(config.get("name", node.get("id")))
|
||||
elif config.get("asset"):
|
||||
fixed_inputs.append(config.get("asset"))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Parse error: {e}")
|
||||
else:
|
||||
# Parse YAML
|
||||
try:
|
||||
recipe_data = yaml.safe_load(content)
|
||||
recipe_name = recipe_data.get("name")
|
||||
recipe_version = recipe_data.get("version", "1.0")
|
||||
recipe_description = recipe_data.get("description")
|
||||
|
||||
inputs = recipe_data.get("inputs", {})
|
||||
for input_name, input_def in inputs.items():
|
||||
if isinstance(input_def, dict) and input_def.get("fixed"):
|
||||
fixed_inputs.append(input_name)
|
||||
else:
|
||||
variable_inputs.append(input_name)
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(400, f"Invalid YAML: {e}")
|
||||
|
||||
# Use filename as recipe name if not specified
|
||||
if not recipe_name and file.filename:
|
||||
recipe_name = file.filename.rsplit(".", 1)[0]
|
||||
|
||||
recipe_id, error = await recipe_service.upload_recipe(
|
||||
content=content,
|
||||
uploader=ctx.actor_id,
|
||||
name=recipe_name,
|
||||
description=recipe_description,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {
|
||||
"recipe_id": recipe_id,
|
||||
"name": recipe_name or "unnamed",
|
||||
"version": recipe_version,
|
||||
"variable_inputs": variable_inputs,
|
||||
"fixed_inputs": fixed_inputs,
|
||||
"message": "Recipe uploaded successfully",
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_recipes(
|
||||
request: Request,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""List available recipes."""
|
||||
recipes = await recipe_service.list_recipes(ctx.actor_id, offset=offset, limit=limit)
|
||||
has_more = len(recipes) >= limit
|
||||
|
||||
if wants_json(request):
|
||||
return {"recipes": recipes, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "recipes/list.html", request,
|
||||
recipes=recipes,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="recipes",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}")
|
||||
async def get_recipe(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get recipe details."""
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
# Add friendly name if available
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, recipe_id)
|
||||
if friendly:
|
||||
recipe["friendly_name"] = friendly["friendly_name"]
|
||||
recipe["base_name"] = friendly["base_name"]
|
||||
recipe["version_id"] = friendly["version_id"]
|
||||
|
||||
if wants_json(request):
|
||||
return recipe
|
||||
|
||||
# Build DAG elements for visualization and convert nodes to steps format
|
||||
dag_elements = []
|
||||
steps = []
|
||||
node_colors = {
|
||||
"SOURCE": "#3b82f6",
|
||||
"EFFECT": "#8b5cf6",
|
||||
"SEQUENCE": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
|
||||
# Debug: log recipe structure
|
||||
logger.info(f"Recipe keys: {list(recipe.keys())}")
|
||||
|
||||
# Get nodes from dag - can be list or dict, can be under "dag" or directly on recipe
|
||||
dag = recipe.get("dag", {})
|
||||
logger.info(f"DAG type: {type(dag)}, keys: {list(dag.keys()) if isinstance(dag, dict) else 'not dict'}")
|
||||
nodes = dag.get("nodes", []) if isinstance(dag, dict) else []
|
||||
logger.info(f"Nodes from dag.nodes: {type(nodes)}, len: {len(nodes) if hasattr(nodes, '__len__') else 'N/A'}")
|
||||
|
||||
# Also check for nodes directly on recipe (alternative formats)
|
||||
if not nodes:
|
||||
nodes = recipe.get("nodes", [])
|
||||
logger.info(f"Nodes from recipe.nodes: {type(nodes)}, len: {len(nodes) if hasattr(nodes, '__len__') else 'N/A'}")
|
||||
if not nodes:
|
||||
nodes = recipe.get("pipeline", [])
|
||||
logger.info(f"Nodes from recipe.pipeline: {type(nodes)}, len: {len(nodes) if hasattr(nodes, '__len__') else 'N/A'}")
|
||||
if not nodes:
|
||||
nodes = recipe.get("steps", [])
|
||||
logger.info(f"Nodes from recipe.steps: {type(nodes)}, len: {len(nodes) if hasattr(nodes, '__len__') else 'N/A'}")
|
||||
|
||||
logger.info(f"Final nodes count: {len(nodes) if hasattr(nodes, '__len__') else 'N/A'}")
|
||||
|
||||
# Convert list of nodes to steps format
|
||||
if isinstance(nodes, list):
|
||||
for node in nodes:
|
||||
node_id = node.get("id", "")
|
||||
node_type = node.get("type", "EFFECT")
|
||||
inputs = node.get("inputs", [])
|
||||
config = node.get("config", {})
|
||||
|
||||
steps.append({
|
||||
"id": node_id,
|
||||
"name": node_id,
|
||||
"type": node_type,
|
||||
"inputs": inputs,
|
||||
"params": config,
|
||||
})
|
||||
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"color": node_colors.get(node_type, "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in inputs:
|
||||
if isinstance(inp, str):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": node_id}
|
||||
})
|
||||
elif isinstance(nodes, dict):
|
||||
for node_id, node in nodes.items():
|
||||
node_type = node.get("type", "EFFECT")
|
||||
inputs = node.get("inputs", [])
|
||||
config = node.get("config", {})
|
||||
|
||||
steps.append({
|
||||
"id": node_id,
|
||||
"name": node_id,
|
||||
"type": node_type,
|
||||
"inputs": inputs,
|
||||
"params": config,
|
||||
})
|
||||
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"color": node_colors.get(node_type, "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in inputs:
|
||||
if isinstance(inp, str):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": node_id}
|
||||
})
|
||||
|
||||
# Add steps to recipe for template
|
||||
recipe["steps"] = steps
|
||||
|
||||
# Use S-expression source if available
|
||||
if "sexp" not in recipe:
|
||||
recipe["sexp"] = "; No S-expression source available"
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "recipes/detail.html", request,
|
||||
recipe=recipe,
|
||||
dag_elements=dag_elements,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="recipes",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}")
|
||||
async def delete_recipe(
|
||||
recipe_id: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Delete a recipe."""
|
||||
success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id)
|
||||
if error:
|
||||
raise HTTPException(400 if "Cannot" in error else 404, error)
|
||||
return {"deleted": True, "recipe_id": recipe_id}
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/run")
|
||||
async def run_recipe(
|
||||
recipe_id: str,
|
||||
req: RecipeRunRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Run a recipe with given inputs."""
|
||||
from ..services.run_service import RunService
|
||||
from ..dependencies import get_cache_manager
|
||||
import database
|
||||
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
try:
|
||||
# Create run using run service
|
||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||
|
||||
# Prepare DAG for execution (transform nodes, bind inputs, resolve friendly names)
|
||||
dag_json = None
|
||||
if recipe.get("dag"):
|
||||
dag_json, warnings = await prepare_dag_for_execution(recipe, req.inputs, actor_id=ctx.actor_id)
|
||||
for warning in warnings:
|
||||
logger.warning(warning)
|
||||
|
||||
run, error = await run_service.create_run(
|
||||
recipe=recipe_id, # Use recipe hash as primary identifier
|
||||
inputs=req.inputs,
|
||||
use_dag=True,
|
||||
dag_json=dag_json,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
recipe_name=recipe.get("name"), # Store name for display
|
||||
recipe_sexp=recipe.get("sexp"), # S-expression for code-addressed execution
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if not run:
|
||||
raise HTTPException(500, "Run creation returned no result")
|
||||
|
||||
return {
|
||||
"run_id": run["run_id"] if isinstance(run, dict) else run.run_id,
|
||||
"status": run.get("status", "pending") if isinstance(run, dict) else run.status,
|
||||
"message": "Recipe execution started",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error running recipe {recipe_id}")
|
||||
raise HTTPException(500, f"Run failed: {e}")
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/dag")
|
||||
async def recipe_dag(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Get recipe DAG visualization data."""
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
dag_elements = []
|
||||
node_colors = {
|
||||
"input": "#3b82f6",
|
||||
"effect": "#8b5cf6",
|
||||
"analyze": "#ec4899",
|
||||
"transform": "#10b981",
|
||||
"output": "#f59e0b",
|
||||
}
|
||||
|
||||
for i, step in enumerate(recipe.get("steps", [])):
|
||||
step_id = step.get("id", f"step-{i}")
|
||||
dag_elements.append({
|
||||
"data": {
|
||||
"id": step_id,
|
||||
"label": step.get("name", f"Step {i+1}"),
|
||||
"color": node_colors.get(step.get("type", "effect"), "#6b7280"),
|
||||
}
|
||||
})
|
||||
for inp in step.get("inputs", []):
|
||||
dag_elements.append({
|
||||
"data": {"source": inp, "target": step_id}
|
||||
})
|
||||
|
||||
return {"elements": dag_elements}
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}/ui", response_class=HTMLResponse)
|
||||
async def ui_discard_recipe(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""HTMX handler: discard a recipe."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>', status_code=401)
|
||||
|
||||
success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse(
|
||||
'<div class="text-green-400">Recipe deleted</div>'
|
||||
'<script>setTimeout(() => window.location.href = "/recipes", 1500);</script>'
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/publish")
|
||||
async def publish_recipe(
|
||||
recipe_id: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||
):
|
||||
"""Publish recipe to L2 and IPFS."""
|
||||
from ..services.cache_service import CacheService
|
||||
from ..dependencies import get_cache_manager
|
||||
import database
|
||||
|
||||
# Verify recipe exists
|
||||
recipe = await recipe_service.get_recipe(recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
# Use cache service to publish (recipes are stored in cache)
|
||||
cache_service = CacheService(database, get_cache_manager())
|
||||
ipfs_cid, error = await cache_service.publish_to_l2(
|
||||
cid=recipe_id,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
auth_token=request.cookies.get("auth_token"),
|
||||
)
|
||||
|
||||
if error:
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-green-400">Shared: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "published": True}
|
||||
1704
l1/app/routers/runs.py
Normal file
1704
l1/app/routers/runs.py
Normal file
File diff suppressed because it is too large
Load Diff
264
l1/app/routers/storage.py
Normal file
264
l1/app/routers/storage.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Storage provider routes for L1 server.
|
||||
|
||||
Manages user storage backends (Pinata, web3.storage, local, etc.)
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
|
||||
from ..dependencies import get_database, get_current_user, require_auth, get_templates
|
||||
from ..services.storage_service import StorageService, STORAGE_PROVIDERS_INFO, VALID_PROVIDER_TYPES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Import storage_providers module
|
||||
import storage_providers as sp_module
|
||||
|
||||
|
||||
def get_storage_service():
|
||||
"""Get storage service instance."""
|
||||
import database
|
||||
return StorageService(database, sp_module)
|
||||
|
||||
|
||||
class AddStorageRequest(BaseModel):
|
||||
provider_type: str
|
||||
config: Dict[str, Any]
|
||||
capacity_gb: int = 5
|
||||
provider_name: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateStorageRequest(BaseModel):
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
capacity_gb: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_storage(
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""List user's storage providers. HTML for browsers, JSON for API."""
|
||||
storages = await storage_service.list_storages(ctx.actor_id)
|
||||
|
||||
if wants_json(request):
|
||||
return {"storages": storages}
|
||||
|
||||
# Render HTML template
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "storage/list.html", request,
|
||||
storages=storages,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
providers_info=STORAGE_PROVIDERS_INFO,
|
||||
active_tab="storage",
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_storage(
|
||||
req: AddStorageRequest,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Add a storage provider via API."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
storage_id, error = await storage_service.add_storage(
|
||||
actor_id=ctx.actor_id,
|
||||
provider_type=req.provider_type,
|
||||
config=req.config,
|
||||
capacity_gb=req.capacity_gb,
|
||||
provider_name=req.provider_name,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"id": storage_id, "message": "Storage provider added"}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_storage_form(
|
||||
request: Request,
|
||||
provider_type: str = Form(...),
|
||||
provider_name: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
capacity_gb: int = Form(5),
|
||||
api_key: Optional[str] = Form(None),
|
||||
secret_key: Optional[str] = Form(None),
|
||||
api_token: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
project_secret: Optional[str] = Form(None),
|
||||
access_key: Optional[str] = Form(None),
|
||||
bucket: Optional[str] = Form(None),
|
||||
path: Optional[str] = Form(None),
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Add a storage provider via HTML form."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
|
||||
|
||||
# Build config from form
|
||||
form_data = {
|
||||
"api_key": api_key,
|
||||
"secret_key": secret_key,
|
||||
"api_token": api_token,
|
||||
"project_id": project_id,
|
||||
"project_secret": project_secret,
|
||||
"access_key": access_key,
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
}
|
||||
config, error = storage_service.build_config_from_form(provider_type, form_data)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
storage_id, error = await storage_service.add_storage(
|
||||
actor_id=ctx.actor_id,
|
||||
provider_type=provider_type,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
provider_name=provider_name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if error:
|
||||
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<div class="text-green-400 mb-2">Storage provider added successfully!</div>
|
||||
<script>setTimeout(() => window.location.href = '/storage/type/{provider_type}', 1500);</script>
|
||||
''')
|
||||
|
||||
|
||||
@router.get("/{storage_id}")
|
||||
async def get_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Get a specific storage provider."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
storage = await storage_service.get_storage(storage_id, ctx.actor_id)
|
||||
if not storage:
|
||||
raise HTTPException(404, "Storage provider not found")
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
@router.patch("/{storage_id}")
|
||||
async def update_storage(
|
||||
storage_id: int,
|
||||
req: UpdateStorageRequest,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Update a storage provider."""
|
||||
ctx = await require_auth(request)
|
||||
|
||||
success, error = await storage_service.update_storage(
|
||||
storage_id=storage_id,
|
||||
actor_id=ctx.actor_id,
|
||||
config=req.config,
|
||||
capacity_gb=req.capacity_gb,
|
||||
is_active=req.is_active,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
return {"message": "Storage provider updated"}
|
||||
|
||||
|
||||
@router.delete("/{storage_id}")
|
||||
async def delete_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Remove a storage provider."""
|
||||
success, error = await storage_service.delete_storage(storage_id, ctx.actor_id)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse("")
|
||||
|
||||
return {"message": "Storage provider removed"}
|
||||
|
||||
|
||||
@router.post("/{storage_id}/test")
|
||||
async def test_storage(
|
||||
storage_id: int,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
):
|
||||
"""Test storage provider connectivity."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
if wants_html(request):
|
||||
return HTMLResponse('<span class="text-red-400">Not authenticated</span>', status_code=401)
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
success, message = await storage_service.test_storage(storage_id, ctx.actor_id)
|
||||
|
||||
if wants_html(request):
|
||||
color = "green" if success else "red"
|
||||
return HTMLResponse(f'<span class="text-{color}-400">{message}</span>')
|
||||
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
@router.get("/type/{provider_type}")
|
||||
async def storage_type_page(
|
||||
provider_type: str,
|
||||
request: Request,
|
||||
storage_service: StorageService = Depends(get_storage_service),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Page for managing storage configs of a specific type."""
|
||||
if provider_type not in STORAGE_PROVIDERS_INFO:
|
||||
raise HTTPException(404, "Invalid provider type")
|
||||
|
||||
storages = await storage_service.list_by_type(ctx.actor_id, provider_type)
|
||||
provider_info = STORAGE_PROVIDERS_INFO[provider_type]
|
||||
|
||||
if wants_json(request):
|
||||
return {
|
||||
"provider_type": provider_type,
|
||||
"provider_info": provider_info,
|
||||
"storages": storages,
|
||||
}
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "storage/type.html", request,
|
||||
provider_type=provider_type,
|
||||
provider_info=provider_info,
|
||||
storages=storages,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="storage",
|
||||
)
|
||||
15
l1/app/services/__init__.py
Normal file
15
l1/app/services/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
L1 Server Services.
|
||||
|
||||
Business logic layer between routers and repositories.
|
||||
"""
|
||||
|
||||
from .run_service import RunService
|
||||
from .recipe_service import RecipeService
|
||||
from .cache_service import CacheService
|
||||
|
||||
__all__ = [
|
||||
"RunService",
|
||||
"RecipeService",
|
||||
"CacheService",
|
||||
]
|
||||
138
l1/app/services/auth_service.py
Normal file
138
l1/app/services/auth_service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Auth Service - token management and user verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
from ..config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import redis
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
# Token expiry (30 days to match token lifetime)
|
||||
TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
# Redis key prefixes
|
||||
REVOKED_KEY_PREFIX = "artdag:revoked:"
|
||||
USER_TOKENS_PREFIX = "artdag:user_tokens:"
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication and token management."""
|
||||
|
||||
def __init__(self, redis_client: "redis.Redis[bytes]") -> None:
|
||||
self.redis = redis_client
|
||||
|
||||
def register_user_token(self, username: str, token: str) -> None:
|
||||
"""Track a token for a user (for later revocation by username)."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{USER_TOKENS_PREFIX}{username}"
|
||||
self.redis.sadd(key, token_hash)
|
||||
self.redis.expire(key, TOKEN_EXPIRY_SECONDS)
|
||||
|
||||
def revoke_token(self, token: str) -> bool:
|
||||
"""Add token to revocation set. Returns True if newly revoked."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
result = self.redis.set(key, "1", ex=TOKEN_EXPIRY_SECONDS, nx=True)
|
||||
return result is not None
|
||||
|
||||
def revoke_token_hash(self, token_hash: str) -> bool:
|
||||
"""Add token hash to revocation set. Returns True if newly revoked."""
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
result = self.redis.set(key, "1", ex=TOKEN_EXPIRY_SECONDS, nx=True)
|
||||
return result is not None
|
||||
|
||||
def revoke_all_user_tokens(self, username: str) -> int:
|
||||
"""Revoke all tokens for a user. Returns count revoked."""
|
||||
key = f"{USER_TOKENS_PREFIX}{username}"
|
||||
token_hashes = self.redis.smembers(key)
|
||||
count = 0
|
||||
for token_hash in token_hashes:
|
||||
if self.revoke_token_hash(
|
||||
token_hash.decode() if isinstance(token_hash, bytes) else token_hash
|
||||
):
|
||||
count += 1
|
||||
self.redis.delete(key)
|
||||
return count
|
||||
|
||||
def is_token_revoked(self, token: str) -> bool:
|
||||
"""Check if token has been revoked."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{REVOKED_KEY_PREFIX}{token_hash}"
|
||||
return self.redis.exists(key) > 0
|
||||
|
||||
def decode_token_claims(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode JWT claims without verification."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload = parts[1]
|
||||
# Add padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
return json.loads(base64.urlsafe_b64decode(payload))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def get_user_context_from_token(self, token: str) -> Optional[UserContext]:
|
||||
"""Extract user context from a token."""
|
||||
if self.is_token_revoked(token):
|
||||
return None
|
||||
|
||||
claims = self.decode_token_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
username = claims.get("username") or claims.get("sub")
|
||||
actor_id = claims.get("actor_id") or claims.get("actor")
|
||||
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return UserContext(
|
||||
username=username,
|
||||
actor_id=actor_id or f"@{username}",
|
||||
token=token,
|
||||
l2_server=settings.l2_server,
|
||||
)
|
||||
|
||||
async def verify_token_with_l2(self, token: str) -> Optional[UserContext]:
|
||||
"""Verify token with L2 server."""
|
||||
ctx = self.get_user_context_from_token(token)
|
||||
if not ctx:
|
||||
return None
|
||||
|
||||
# If L2 server configured, verify token
|
||||
if settings.l2_server:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{settings.l2_server}/auth/verify",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
except httpx.RequestError:
|
||||
# L2 unavailable, trust the token
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
def get_user_from_cookie(self, request: "Request") -> Optional[UserContext]:
|
||||
"""Extract user context from auth cookie."""
|
||||
token = request.cookies.get("auth_token")
|
||||
if not token:
|
||||
return None
|
||||
return self.get_user_context_from_token(token)
|
||||
618
l1/app/services/cache_service.py
Normal file
618
l1/app/services/cache_service.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""
|
||||
Cache Service - business logic for cache and media management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from database import Database
|
||||
from cache_manager import L1CacheManager
|
||||
|
||||
|
||||
def detect_media_type(cache_path: Path) -> str:
|
||||
"""Detect if file is image, video, or audio based on magic bytes."""
|
||||
try:
|
||||
with open(cache_path, "rb") as f:
|
||||
header = f.read(32)
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
# Video signatures
|
||||
if header[:4] == b'\x1a\x45\xdf\xa3': # WebM/MKV
|
||||
return "video"
|
||||
if len(header) > 8 and header[4:8] == b'ftyp': # MP4/MOV
|
||||
return "video"
|
||||
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'AVI ': # AVI
|
||||
return "video"
|
||||
|
||||
# Image signatures
|
||||
if header[:8] == b'\x89PNG\r\n\x1a\n': # PNG
|
||||
return "image"
|
||||
if header[:2] == b'\xff\xd8': # JPEG
|
||||
return "image"
|
||||
if header[:6] in (b'GIF87a', b'GIF89a'): # GIF
|
||||
return "image"
|
||||
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'WEBP': # WebP
|
||||
return "image"
|
||||
|
||||
# Audio signatures
|
||||
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'WAVE': # WAV
|
||||
return "audio"
|
||||
if header[:3] == b'ID3' or header[:2] == b'\xff\xfb': # MP3
|
||||
return "audio"
|
||||
if header[:4] == b'fLaC': # FLAC
|
||||
return "audio"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_mime_type(path: Path) -> str:
|
||||
"""Get MIME type based on file magic bytes."""
|
||||
media_type = detect_media_type(path)
|
||||
if media_type == "video":
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if header[:4] == b'\x1a\x45\xdf\xa3':
|
||||
return "video/x-matroska"
|
||||
return "video/mp4"
|
||||
except Exception:
|
||||
return "video/mp4"
|
||||
elif media_type == "image":
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(8)
|
||||
if header[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
return "image/png"
|
||||
if header[:2] == b'\xff\xd8':
|
||||
return "image/jpeg"
|
||||
if header[:6] in (b'GIF87a', b'GIF89a'):
|
||||
return "image/gif"
|
||||
return "image/jpeg"
|
||||
except Exception:
|
||||
return "image/jpeg"
|
||||
elif media_type == "audio":
|
||||
return "audio/mpeg"
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Service for managing cached content.
|
||||
|
||||
Handles content retrieval, metadata, and media type detection.
|
||||
"""
|
||||
|
||||
def __init__(self, database: "Database", cache_manager: "L1CacheManager") -> None:
|
||||
self.db = database
|
||||
self.cache = cache_manager
|
||||
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
|
||||
|
||||
async def get_cache_item(self, cid: str, actor_id: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached item with full metadata for display."""
|
||||
# Get metadata from database first
|
||||
meta = await self.db.load_item_metadata(cid, actor_id)
|
||||
cache_item = await self.db.get_cache_item(cid)
|
||||
|
||||
# Check if content exists locally
|
||||
path = self.cache.get_by_cid(cid) if self.cache.has_content(cid) else None
|
||||
|
||||
if path and path.exists():
|
||||
# Local file exists - detect type from file
|
||||
media_type = detect_media_type(path)
|
||||
mime_type = get_mime_type(path)
|
||||
size = path.stat().st_size
|
||||
else:
|
||||
# File not local - check database for type info
|
||||
# Try to get type from item_types table
|
||||
media_type = "unknown"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
|
||||
if actor_id:
|
||||
try:
|
||||
item_types = await self.db.get_item_types(cid, actor_id)
|
||||
if item_types:
|
||||
media_type = item_types[0].get("type", "unknown")
|
||||
if media_type == "video":
|
||||
mime_type = "video/mp4"
|
||||
elif media_type == "image":
|
||||
mime_type = "image/png"
|
||||
elif media_type == "audio":
|
||||
mime_type = "audio/mpeg"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If no local path but we have IPFS CID, content is available remotely
|
||||
if not cache_item:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"cid": cid,
|
||||
"path": str(path) if path else None,
|
||||
"media_type": media_type,
|
||||
"mime_type": mime_type,
|
||||
"size": size,
|
||||
"ipfs_cid": cache_item.get("ipfs_cid") if cache_item else None,
|
||||
"meta": meta,
|
||||
"remote_only": path is None or not path.exists(),
|
||||
}
|
||||
|
||||
# Unpack meta fields to top level for template convenience
|
||||
if meta:
|
||||
result["title"] = meta.get("title")
|
||||
result["description"] = meta.get("description")
|
||||
result["tags"] = meta.get("tags", [])
|
||||
result["source_type"] = meta.get("source_type")
|
||||
result["source_note"] = meta.get("source_note")
|
||||
result["created_at"] = meta.get("created_at")
|
||||
result["filename"] = meta.get("filename")
|
||||
|
||||
# Get friendly name if actor_id provided
|
||||
if actor_id:
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly = await naming.get_by_cid(actor_id, cid)
|
||||
if friendly:
|
||||
result["friendly_name"] = friendly["friendly_name"]
|
||||
result["base_name"] = friendly["base_name"]
|
||||
result["version_id"] = friendly["version_id"]
|
||||
|
||||
return result
|
||||
|
||||
async def check_access(self, cid: str, actor_id: str, username: str) -> bool:
|
||||
"""Check if user has access to content."""
|
||||
user_hashes = await self._get_user_cache_hashes(username, actor_id)
|
||||
return cid in user_hashes
|
||||
|
||||
async def _get_user_cache_hashes(self, username: str, actor_id: Optional[str] = None) -> set:
|
||||
"""Get all cache hashes owned by or associated with a user."""
|
||||
match_values = [username]
|
||||
if actor_id:
|
||||
match_values.append(actor_id)
|
||||
|
||||
hashes = set()
|
||||
|
||||
# Query database for items owned by user
|
||||
if actor_id:
|
||||
try:
|
||||
db_items = await self.db.get_user_items(actor_id)
|
||||
for item in db_items:
|
||||
hashes.add(item["cid"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Legacy: Files uploaded by user (JSON metadata)
|
||||
if self.cache_dir.exists():
|
||||
for f in self.cache_dir.iterdir():
|
||||
if f.name.endswith('.meta.json'):
|
||||
try:
|
||||
with open(f, 'r') as mf:
|
||||
meta = json.load(mf)
|
||||
if meta.get("uploader") in match_values:
|
||||
hashes.add(f.name.replace('.meta.json', ''))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Files from user's runs (inputs and outputs)
|
||||
runs = await self._list_user_runs(username, actor_id)
|
||||
for run in runs:
|
||||
inputs = run.get("inputs", [])
|
||||
if isinstance(inputs, dict):
|
||||
inputs = list(inputs.values())
|
||||
hashes.update(inputs)
|
||||
if run.get("output_cid"):
|
||||
hashes.add(run["output_cid"])
|
||||
|
||||
return hashes
|
||||
|
||||
async def _list_user_runs(self, username: str, actor_id: Optional[str]) -> List[Dict]:
|
||||
"""List runs for a user (helper for access check)."""
|
||||
from ..dependencies import get_redis_client
|
||||
import json
|
||||
|
||||
redis = get_redis_client()
|
||||
runs = []
|
||||
cursor = 0
|
||||
prefix = "artdag:run:"
|
||||
|
||||
while True:
|
||||
cursor, keys = redis.scan(cursor=cursor, match=f"{prefix}*", count=100)
|
||||
for key in keys:
|
||||
data = redis.get(key)
|
||||
if data:
|
||||
run = json.loads(data)
|
||||
if run.get("actor_id") in (username, actor_id) or run.get("username") in (username, actor_id):
|
||||
runs.append(run)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return runs
|
||||
|
||||
async def get_raw_file(self, cid: str) -> Tuple[Optional[Path], Optional[str], Optional[str]]:
|
||||
"""Get raw file path, media type, and filename for download."""
|
||||
if not self.cache.has_content(cid):
|
||||
return None, None, None
|
||||
|
||||
path = self.cache.get_by_cid(cid)
|
||||
if not path or not path.exists():
|
||||
return None, None, None
|
||||
|
||||
media_type = detect_media_type(path)
|
||||
mime = get_mime_type(path)
|
||||
|
||||
# Determine extension
|
||||
ext = "bin"
|
||||
if media_type == "video":
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if header[:4] == b'\x1a\x45\xdf\xa3':
|
||||
ext = "mkv"
|
||||
else:
|
||||
ext = "mp4"
|
||||
except Exception:
|
||||
ext = "mp4"
|
||||
elif media_type == "image":
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(8)
|
||||
if header[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
ext = "png"
|
||||
else:
|
||||
ext = "jpg"
|
||||
except Exception:
|
||||
ext = "jpg"
|
||||
|
||||
filename = f"{cid}.{ext}"
|
||||
return path, mime, filename
|
||||
|
||||
async def get_as_mp4(self, cid: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Get content as MP4, transcoding if necessary. Returns (path, error)."""
|
||||
if not self.cache.has_content(cid):
|
||||
return None, f"Content {cid} not in cache"
|
||||
|
||||
path = self.cache.get_by_cid(cid)
|
||||
if not path or not path.exists():
|
||||
return None, f"Content {cid} not in cache"
|
||||
|
||||
# Check if video
|
||||
media_type = detect_media_type(path)
|
||||
if media_type != "video":
|
||||
return None, "Content is not a video"
|
||||
|
||||
# Check for cached MP4
|
||||
mp4_path = self.cache_dir / f"{cid}.mp4"
|
||||
if mp4_path.exists():
|
||||
return mp4_path, None
|
||||
|
||||
# Check if already MP4 format
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "format=format_name", "-of", "csv=p=0", str(path)],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if "mp4" in result.stdout.lower() or "mov" in result.stdout.lower():
|
||||
return path, None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Transcode to MP4
|
||||
transcode_path = self.cache_dir / f"{cid}.transcoding.mp4"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", str(path),
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
str(transcode_path)],
|
||||
capture_output=True, text=True, timeout=600
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None, f"Transcoding failed: {result.stderr[:200]}"
|
||||
|
||||
transcode_path.rename(mp4_path)
|
||||
return mp4_path, None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
if transcode_path.exists():
|
||||
transcode_path.unlink()
|
||||
return None, "Transcoding timed out"
|
||||
except Exception as e:
|
||||
if transcode_path.exists():
|
||||
transcode_path.unlink()
|
||||
return None, f"Transcoding failed: {e}"
|
||||
|
||||
async def get_metadata(self, cid: str, actor_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get content metadata."""
|
||||
if not self.cache.has_content(cid):
|
||||
return None
|
||||
return await self.db.load_item_metadata(cid, actor_id)
|
||||
|
||||
async def update_metadata(
|
||||
self,
|
||||
cid: str,
|
||||
actor_id: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
custom: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Update content metadata. Returns (success, error)."""
|
||||
if not self.cache.has_content(cid):
|
||||
return False, "Content not found"
|
||||
|
||||
# Build update dict
|
||||
updates = {}
|
||||
if title is not None:
|
||||
updates["title"] = title
|
||||
if description is not None:
|
||||
updates["description"] = description
|
||||
if tags is not None:
|
||||
updates["tags"] = tags
|
||||
if custom is not None:
|
||||
updates["custom"] = custom
|
||||
|
||||
try:
|
||||
await self.db.update_item_metadata(cid, actor_id, **updates)
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def publish_to_l2(
|
||||
self,
|
||||
cid: str,
|
||||
actor_id: str,
|
||||
l2_server: str,
|
||||
auth_token: str,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Publish content to L2 and IPFS. Returns (ipfs_cid, error)."""
|
||||
if not self.cache.has_content(cid):
|
||||
return None, "Content not found"
|
||||
|
||||
# Get IPFS CID
|
||||
cache_item = await self.db.get_cache_item(cid)
|
||||
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
|
||||
|
||||
# Get metadata for origin info
|
||||
meta = await self.db.load_item_metadata(cid, actor_id)
|
||||
origin = meta.get("origin") if meta else None
|
||||
|
||||
if not origin or "type" not in origin:
|
||||
return None, "Origin must be set before publishing"
|
||||
|
||||
if not auth_token:
|
||||
return None, "Authentication token required"
|
||||
|
||||
# Call L2 publish-cache endpoint
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{l2_server}/assets/publish-cache",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"cid": cid,
|
||||
"ipfs_cid": ipfs_cid,
|
||||
"asset_name": meta.get("title") or cid[:16],
|
||||
"asset_type": detect_media_type(self.cache.get_by_cid(cid)),
|
||||
"origin": origin,
|
||||
"description": meta.get("description"),
|
||||
"tags": meta.get("tags", []),
|
||||
}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
l2_result = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = str(e)
|
||||
try:
|
||||
error_detail = e.response.json().get("detail", str(e))
|
||||
except Exception:
|
||||
pass
|
||||
return None, f"L2 publish failed: {error_detail}"
|
||||
except Exception as e:
|
||||
return None, f"L2 publish failed: {e}"
|
||||
|
||||
# Update local metadata with publish status
|
||||
await self.db.save_l2_share(
|
||||
cid=cid,
|
||||
actor_id=actor_id,
|
||||
l2_server=l2_server,
|
||||
asset_name=meta.get("title") or cid[:16],
|
||||
content_type=detect_media_type(self.cache.get_by_cid(cid))
|
||||
)
|
||||
await self.db.update_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=actor_id,
|
||||
pinned=True,
|
||||
pin_reason="published"
|
||||
)
|
||||
|
||||
return l2_result.get("ipfs_cid") or ipfs_cid, None
|
||||
|
||||
async def delete_content(self, cid: str, actor_id: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Remove user's ownership link to cached content.
|
||||
|
||||
This removes the item_types entry linking the user to the content.
|
||||
The cached file is only deleted if no other users own it.
|
||||
Returns (success, error).
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if pinned for this user
|
||||
meta = await self.db.load_item_metadata(cid, actor_id)
|
||||
if meta and meta.get("pinned"):
|
||||
pin_reason = meta.get("pin_reason", "unknown")
|
||||
return False, f"Cannot discard pinned item (reason: {pin_reason})"
|
||||
|
||||
# Get the item type to delete the right ownership entry
|
||||
item_types = await self.db.get_item_types(cid, actor_id)
|
||||
if not item_types:
|
||||
return False, "You don't own this content"
|
||||
|
||||
# Remove user's ownership links (all types for this user)
|
||||
for item in item_types:
|
||||
item_type = item.get("type", "media")
|
||||
await self.db.delete_item_type(cid, actor_id, item_type)
|
||||
|
||||
# Remove friendly name
|
||||
await self.db.delete_friendly_name(actor_id, cid)
|
||||
|
||||
# Check if anyone else still owns this content
|
||||
remaining_owners = await self.db.get_item_types(cid)
|
||||
|
||||
# Only delete the actual file if no one owns it anymore
|
||||
if not remaining_owners:
|
||||
# Check deletion rules via cache_manager
|
||||
can_delete, reason = self.cache.can_delete(cid)
|
||||
if can_delete:
|
||||
# Delete via cache_manager
|
||||
self.cache.delete_by_cid(cid)
|
||||
|
||||
# Clean up legacy metadata files
|
||||
meta_path = self.cache_dir / f"{cid}.meta.json"
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
mp4_path = self.cache_dir / f"{cid}.mp4"
|
||||
if mp4_path.exists():
|
||||
mp4_path.unlink()
|
||||
|
||||
# Delete from database
|
||||
await self.db.delete_cache_item(cid)
|
||||
|
||||
logger.info(f"Garbage collected content {cid[:16]}... (no remaining owners)")
|
||||
else:
|
||||
logger.info(f"Content {cid[:16]}... orphaned but cannot delete: {reason}")
|
||||
|
||||
logger.info(f"Removed content {cid[:16]}... ownership for {actor_id}")
|
||||
return True, None
|
||||
|
||||
async def import_from_ipfs(self, ipfs_cid: str, actor_id: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Import content from IPFS. Returns (cid, error)."""
|
||||
try:
|
||||
import ipfs_client
|
||||
|
||||
# Download from IPFS
|
||||
legacy_dir = self.cache_dir / "legacy"
|
||||
legacy_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = legacy_dir / f"import-{ipfs_cid[:16]}"
|
||||
|
||||
if not ipfs_client.get_file(ipfs_cid, str(tmp_path)):
|
||||
return None, f"Could not fetch CID {ipfs_cid} from IPFS"
|
||||
|
||||
# Detect media type before storing
|
||||
media_type = detect_media_type(tmp_path)
|
||||
|
||||
# Store in cache
|
||||
cached, new_ipfs_cid = self.cache.put(tmp_path, node_type="import", move=True)
|
||||
cid = new_ipfs_cid or cached.cid # Prefer IPFS CID
|
||||
|
||||
# Save to database with detected media type
|
||||
await self.db.create_cache_item(cid, new_ipfs_cid)
|
||||
await self.db.save_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=actor_id,
|
||||
item_type=media_type, # Use detected type for filtering
|
||||
filename=f"ipfs-{ipfs_cid[:16]}"
|
||||
)
|
||||
|
||||
return cid, None
|
||||
except Exception as e:
|
||||
return None, f"Import failed: {e}"
|
||||
|
||||
async def upload_content(
|
||||
self,
|
||||
content: bytes,
|
||||
filename: str,
|
||||
actor_id: str,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Upload content to cache. Returns (cid, ipfs_cid, error).
|
||||
|
||||
Files are stored locally first for fast response, then uploaded
|
||||
to IPFS in the background.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
# Write to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Detect media type (video/image/audio) before moving file
|
||||
media_type = detect_media_type(tmp_path)
|
||||
|
||||
# Store locally AND upload to IPFS synchronously
|
||||
# This ensures the IPFS CID is available immediately for distributed access
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="upload", move=True, skip_ipfs=False)
|
||||
cid = ipfs_cid or cached.cid # Prefer IPFS CID, fall back to local hash
|
||||
|
||||
# Save to database with media category type
|
||||
await self.db.create_cache_item(cached.cid, ipfs_cid)
|
||||
await self.db.save_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=actor_id,
|
||||
item_type=media_type,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
if ipfs_cid:
|
||||
logger.info(f"Uploaded to IPFS: {ipfs_cid[:16]}...")
|
||||
else:
|
||||
logger.warning(f"IPFS upload failed, using local hash: {cid[:16]}...")
|
||||
|
||||
return cid, ipfs_cid, None
|
||||
except Exception as e:
|
||||
return None, None, f"Upload failed: {e}"
|
||||
|
||||
async def list_media(
|
||||
self,
|
||||
actor_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 24,
|
||||
media_type: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List media items in cache."""
|
||||
# Get items from database (uses item_types table)
|
||||
items = await self.db.get_user_items(
|
||||
actor_id=actor_id or username,
|
||||
item_type=media_type, # "video", "image", "audio", or None for all
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# Add friendly names to items
|
||||
if actor_id:
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
for item in items:
|
||||
cid = item.get("cid")
|
||||
if cid:
|
||||
friendly = await naming.get_by_cid(actor_id, cid)
|
||||
if friendly:
|
||||
item["friendly_name"] = friendly["friendly_name"]
|
||||
item["base_name"] = friendly["base_name"]
|
||||
|
||||
return items
|
||||
|
||||
# Legacy compatibility methods
|
||||
def has_content(self, cid: str) -> bool:
|
||||
"""Check if content exists in cache."""
|
||||
return self.cache.has_content(cid)
|
||||
|
||||
def get_ipfs_cid(self, cid: str) -> Optional[str]:
|
||||
"""Get IPFS CID for cached content."""
|
||||
return self.cache.get_ipfs_cid(cid)
|
||||
234
l1/app/services/naming_service.py
Normal file
234
l1/app/services/naming_service.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Naming service for friendly names.
|
||||
|
||||
Handles:
|
||||
- Name normalization (My Cool Effect -> my-cool-effect)
|
||||
- Version ID generation (server-signed timestamps)
|
||||
- Friendly name assignment and resolution
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import database
|
||||
|
||||
|
||||
# Base32 Crockford alphabet (excludes I, L, O, U to avoid confusion)
|
||||
CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
|
||||
|
||||
def _get_server_secret() -> bytes:
|
||||
"""Get server secret for signing version IDs."""
|
||||
secret = os.environ.get("SERVER_SECRET", "")
|
||||
if not secret:
|
||||
# Fall back to a derived secret from other env vars
|
||||
# In production, SERVER_SECRET should be set explicitly
|
||||
secret = os.environ.get("SECRET_KEY", "default-dev-secret")
|
||||
return secret.encode("utf-8")
|
||||
|
||||
|
||||
def _base32_crockford_encode(data: bytes) -> str:
|
||||
"""Encode bytes as base32-crockford (lowercase)."""
|
||||
# Convert bytes to integer
|
||||
num = int.from_bytes(data, "big")
|
||||
if num == 0:
|
||||
return CROCKFORD_ALPHABET[0]
|
||||
|
||||
result = []
|
||||
while num > 0:
|
||||
result.append(CROCKFORD_ALPHABET[num % 32])
|
||||
num //= 32
|
||||
|
||||
return "".join(reversed(result))
|
||||
|
||||
|
||||
def generate_version_id() -> str:
|
||||
"""
|
||||
Generate a version ID that is:
|
||||
- Always increasing (timestamp-based prefix)
|
||||
- Verifiable as originating from this server (HMAC suffix)
|
||||
- Short and URL-safe (13 chars)
|
||||
|
||||
Format: 6 bytes timestamp (ms) + 2 bytes HMAC = 8 bytes = 13 base32 chars
|
||||
"""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
timestamp_bytes = timestamp_ms.to_bytes(6, "big")
|
||||
|
||||
# HMAC the timestamp with server secret
|
||||
secret = _get_server_secret()
|
||||
sig = hmac.new(secret, timestamp_bytes, "sha256").digest()
|
||||
|
||||
# Combine: 6 bytes timestamp + 2 bytes HMAC signature
|
||||
combined = timestamp_bytes + sig[:2]
|
||||
|
||||
# Encode as base32-crockford
|
||||
return _base32_crockford_encode(combined)
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""
|
||||
Normalize a display name to a base name.
|
||||
|
||||
- Lowercase
|
||||
- Replace spaces and underscores with dashes
|
||||
- Remove special characters (keep alphanumeric and dashes)
|
||||
- Collapse multiple dashes
|
||||
- Strip leading/trailing dashes
|
||||
|
||||
Examples:
|
||||
"My Cool Effect" -> "my-cool-effect"
|
||||
"Brightness_V2" -> "brightness-v2"
|
||||
"Test!!!Effect" -> "test-effect"
|
||||
"""
|
||||
# Lowercase
|
||||
name = name.lower()
|
||||
|
||||
# Replace spaces and underscores with dashes
|
||||
name = re.sub(r"[\s_]+", "-", name)
|
||||
|
||||
# Remove anything that's not alphanumeric or dash
|
||||
name = re.sub(r"[^a-z0-9-]", "", name)
|
||||
|
||||
# Collapse multiple dashes
|
||||
name = re.sub(r"-+", "-", name)
|
||||
|
||||
# Strip leading/trailing dashes
|
||||
name = name.strip("-")
|
||||
|
||||
return name or "unnamed"
|
||||
|
||||
|
||||
def parse_friendly_name(friendly_name: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Parse a friendly name into base name and optional version.
|
||||
|
||||
Args:
|
||||
friendly_name: Name like "my-effect" or "my-effect 01hw3x9k"
|
||||
|
||||
Returns:
|
||||
Tuple of (base_name, version_id or None)
|
||||
"""
|
||||
parts = friendly_name.strip().split(" ", 1)
|
||||
base_name = parts[0]
|
||||
version_id = parts[1] if len(parts) > 1 else None
|
||||
return base_name, version_id
|
||||
|
||||
|
||||
def format_friendly_name(base_name: str, version_id: str) -> str:
|
||||
"""Format a base name and version into a full friendly name."""
|
||||
return f"{base_name} {version_id}"
|
||||
|
||||
|
||||
def format_l2_name(actor_id: str, base_name: str, version_id: str) -> str:
|
||||
"""
|
||||
Format a friendly name for L2 sharing.
|
||||
|
||||
Format: @user@domain base-name version-id
|
||||
"""
|
||||
return f"{actor_id} {base_name} {version_id}"
|
||||
|
||||
|
||||
class NamingService:
|
||||
"""Service for managing friendly names."""
|
||||
|
||||
async def assign_name(
|
||||
self,
|
||||
cid: str,
|
||||
actor_id: str,
|
||||
item_type: str,
|
||||
display_name: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Assign a friendly name to content.
|
||||
|
||||
Args:
|
||||
cid: Content ID
|
||||
actor_id: User ID
|
||||
item_type: Type (recipe, effect, media)
|
||||
display_name: Human-readable name (optional)
|
||||
filename: Original filename (used as fallback for media)
|
||||
|
||||
Returns:
|
||||
Friendly name entry dict
|
||||
"""
|
||||
# Determine display name
|
||||
if not display_name:
|
||||
if filename:
|
||||
# Use filename without extension
|
||||
display_name = os.path.splitext(filename)[0]
|
||||
else:
|
||||
display_name = f"unnamed-{item_type}"
|
||||
|
||||
# Normalize to base name
|
||||
base_name = normalize_name(display_name)
|
||||
|
||||
# Generate version ID
|
||||
version_id = generate_version_id()
|
||||
|
||||
# Create database entry
|
||||
entry = await database.create_friendly_name(
|
||||
actor_id=actor_id,
|
||||
base_name=base_name,
|
||||
version_id=version_id,
|
||||
cid=cid,
|
||||
item_type=item_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
async def get_by_cid(self, actor_id: str, cid: str) -> Optional[dict]:
|
||||
"""Get friendly name entry by CID."""
|
||||
return await database.get_friendly_name_by_cid(actor_id, cid)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
actor_id: str,
|
||||
name: str,
|
||||
item_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve a friendly name to a CID.
|
||||
|
||||
Args:
|
||||
actor_id: User ID
|
||||
name: Friendly name ("base-name" or "base-name version")
|
||||
item_type: Optional type filter
|
||||
|
||||
Returns:
|
||||
CID or None if not found
|
||||
"""
|
||||
return await database.resolve_friendly_name(actor_id, name, item_type)
|
||||
|
||||
async def list_names(
|
||||
self,
|
||||
actor_id: str,
|
||||
item_type: Optional[str] = None,
|
||||
latest_only: bool = False,
|
||||
) -> list:
|
||||
"""List friendly names for a user."""
|
||||
return await database.list_friendly_names(
|
||||
actor_id=actor_id,
|
||||
item_type=item_type,
|
||||
latest_only=latest_only,
|
||||
)
|
||||
|
||||
async def delete(self, actor_id: str, cid: str) -> bool:
|
||||
"""Delete a friendly name entry."""
|
||||
return await database.delete_friendly_name(actor_id, cid)
|
||||
|
||||
|
||||
# Module-level instance
|
||||
_naming_service: Optional[NamingService] = None
|
||||
|
||||
|
||||
def get_naming_service() -> NamingService:
|
||||
"""Get the naming service singleton."""
|
||||
global _naming_service
|
||||
if _naming_service is None:
|
||||
_naming_service = NamingService()
|
||||
return _naming_service
|
||||
337
l1/app/services/recipe_service.py
Normal file
337
l1/app/services/recipe_service.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Recipe Service - business logic for recipe management.
|
||||
|
||||
Recipes are S-expressions stored in the content-addressed cache (and IPFS).
|
||||
The recipe ID is the content hash of the file.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
|
||||
|
||||
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import redis
|
||||
from cache_manager import L1CacheManager
|
||||
|
||||
from ..types import Recipe, CompiledDAG, VisualizationDAG, VisNode, VisEdge
|
||||
|
||||
|
||||
class RecipeService:
|
||||
"""
|
||||
Service for managing recipes.
|
||||
|
||||
Recipes are S-expressions stored in the content-addressed cache.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: "redis.Redis", cache: "L1CacheManager") -> None:
|
||||
# Redis kept for compatibility but not used for recipe storage
|
||||
self.redis = redis
|
||||
self.cache = cache
|
||||
|
||||
async def get_recipe(self, recipe_id: str) -> Optional[Recipe]:
|
||||
"""Get a recipe by ID (content hash)."""
|
||||
import yaml
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get from cache (content-addressed storage)
|
||||
logger.info(f"get_recipe: Looking up recipe_id={recipe_id[:16]}...")
|
||||
path = self.cache.get_by_cid(recipe_id)
|
||||
logger.info(f"get_recipe: cache.get_by_cid returned path={path}")
|
||||
if not path or not path.exists():
|
||||
logger.warning(f"get_recipe: Recipe {recipe_id[:16]}... not found in cache")
|
||||
return None
|
||||
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Detect format - check if it starts with ( after skipping comments
|
||||
def is_sexp_format(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(')
|
||||
return False
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if is_sexp_format(content):
|
||||
# Detect if this is a streaming recipe (starts with (stream ...))
|
||||
def is_streaming_recipe(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(stream')
|
||||
return False
|
||||
|
||||
if is_streaming_recipe(content):
|
||||
# Streaming recipes have different format - parse manually
|
||||
import re
|
||||
name_match = re.search(r'\(stream\s+"([^"]+)"', content)
|
||||
recipe_name = name_match.group(1) if name_match else "streaming"
|
||||
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"sexp": content,
|
||||
"format": "sexp",
|
||||
"type": "streaming",
|
||||
"dag": {"nodes": []}, # Streaming recipes don't have traditional DAG
|
||||
}
|
||||
logger.info(f"Parsed streaming recipe {recipe_id[:16]}..., name: {recipe_name}")
|
||||
else:
|
||||
# Parse traditional (recipe ...) S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_data = compiled.to_dict()
|
||||
recipe_data["sexp"] = content
|
||||
recipe_data["format"] = "sexp"
|
||||
logger.info(f"Parsed sexp recipe {recipe_id[:16]}..., keys: {list(recipe_data.keys())}")
|
||||
except (ParseError, CompileError) as e:
|
||||
logger.warning(f"Failed to parse sexp recipe {recipe_id[:16]}...: {e}")
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
else:
|
||||
# Parse YAML
|
||||
try:
|
||||
recipe_data = yaml.safe_load(content)
|
||||
if not isinstance(recipe_data, dict):
|
||||
return {"error": "Invalid YAML: expected dictionary", "recipe_id": recipe_id}
|
||||
recipe_data["yaml"] = content
|
||||
recipe_data["format"] = "yaml"
|
||||
except yaml.YAMLError as e:
|
||||
return {"error": f"YAML parse error: {e}", "recipe_id": recipe_id}
|
||||
|
||||
# Add the recipe_id to the data for convenience
|
||||
recipe_data["recipe_id"] = recipe_id
|
||||
|
||||
# Get IPFS CID if available
|
||||
ipfs_cid = self.cache.get_ipfs_cid(recipe_id)
|
||||
if ipfs_cid:
|
||||
recipe_data["ipfs_cid"] = ipfs_cid
|
||||
|
||||
# Compute step_count from nodes (handle both formats)
|
||||
if recipe_data.get("format") == "sexp":
|
||||
nodes = recipe_data.get("dag", {}).get("nodes", [])
|
||||
else:
|
||||
# YAML format: nodes might be at top level or under dag
|
||||
nodes = recipe_data.get("nodes", recipe_data.get("dag", {}).get("nodes", []))
|
||||
recipe_data["step_count"] = len(nodes) if isinstance(nodes, (list, dict)) else 0
|
||||
|
||||
return recipe_data
|
||||
|
||||
async def list_recipes(self, actor_id: Optional[str] = None, offset: int = 0, limit: int = 20) -> List[Recipe]:
|
||||
"""
|
||||
List recipes owned by a user.
|
||||
|
||||
Queries item_types table for user's recipe links.
|
||||
"""
|
||||
import logging
|
||||
import database
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
recipes = []
|
||||
|
||||
if not actor_id:
|
||||
logger.warning("list_recipes called without actor_id")
|
||||
return []
|
||||
|
||||
# Get user's recipe CIDs from item_types
|
||||
user_items = await database.get_user_items(actor_id, item_type="recipe", limit=1000)
|
||||
recipe_cids = [item["cid"] for item in user_items]
|
||||
logger.info(f"Found {len(recipe_cids)} recipe CIDs for user {actor_id}")
|
||||
|
||||
for cid in recipe_cids:
|
||||
recipe = await self.get_recipe(cid)
|
||||
if recipe and not recipe.get("error"):
|
||||
recipes.append(recipe)
|
||||
elif recipe and recipe.get("error"):
|
||||
logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}")
|
||||
|
||||
# Add friendly names
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
for recipe in recipes:
|
||||
recipe_id = recipe.get("recipe_id")
|
||||
if recipe_id:
|
||||
friendly = await naming.get_by_cid(actor_id, recipe_id)
|
||||
if friendly:
|
||||
recipe["friendly_name"] = friendly["friendly_name"]
|
||||
recipe["base_name"] = friendly["base_name"]
|
||||
|
||||
# Sort by name
|
||||
recipes.sort(key=lambda r: r.get("name", ""))
|
||||
|
||||
return recipes[offset:offset + limit]
|
||||
|
||||
async def upload_recipe(
|
||||
self,
|
||||
content: str,
|
||||
uploader: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Upload a recipe from S-expression content.
|
||||
|
||||
The recipe is stored in the cache and pinned to IPFS.
|
||||
Returns (recipe_id, error_message).
|
||||
"""
|
||||
# Validate S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
except ParseError as e:
|
||||
return None, f"Parse error: {e}"
|
||||
except CompileError as e:
|
||||
return None, f"Compile error: {e}"
|
||||
|
||||
# Write to temp file for caching
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Store in cache (content-addressed, auto-pins to IPFS)
|
||||
logger.info(f"upload_recipe: Storing recipe in cache from {tmp_path}")
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True)
|
||||
recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID
|
||||
logger.info(f"upload_recipe: Stored recipe, cached.cid={cached.cid[:16]}..., ipfs_cid={ipfs_cid[:16] if ipfs_cid else None}, recipe_id={recipe_id[:16]}...")
|
||||
|
||||
# Track ownership in item_types and assign friendly name
|
||||
if uploader:
|
||||
import database
|
||||
display_name = name or compiled.name or "unnamed-recipe"
|
||||
|
||||
# Create item_types entry (ownership link)
|
||||
await database.save_item_metadata(
|
||||
cid=recipe_id,
|
||||
actor_id=uploader,
|
||||
item_type="recipe",
|
||||
description=description,
|
||||
filename=f"{display_name}.sexp",
|
||||
)
|
||||
|
||||
# Assign friendly name
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
await naming.assign_name(
|
||||
cid=recipe_id,
|
||||
actor_id=uploader,
|
||||
item_type="recipe",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return recipe_id, None
|
||||
|
||||
except Exception as e:
|
||||
return None, f"Failed to cache recipe: {e}"
|
||||
|
||||
async def delete_recipe(self, recipe_id: str, actor_id: str = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Remove user's ownership link to a recipe.
|
||||
|
||||
This removes the item_types entry linking the user to the recipe.
|
||||
The cached file is only deleted if no other users own it.
|
||||
Returns (success, error_message).
|
||||
"""
|
||||
import database
|
||||
|
||||
if not actor_id:
|
||||
return False, "actor_id required"
|
||||
|
||||
# Remove user's ownership link
|
||||
try:
|
||||
await database.delete_item_type(recipe_id, actor_id, "recipe")
|
||||
|
||||
# Also remove friendly name
|
||||
await database.delete_friendly_name(actor_id, recipe_id)
|
||||
|
||||
# Try to garbage collect if no one owns it anymore
|
||||
# (delete_cache_item only deletes if no item_types remain)
|
||||
await database.delete_cache_item(recipe_id)
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Failed to delete: {e}"
|
||||
|
||||
def parse_recipe(self, content: str) -> CompiledDAG:
|
||||
"""Parse recipe S-expression content."""
|
||||
compiled = compile_string(content)
|
||||
return compiled.to_dict()
|
||||
|
||||
def build_dag(self, recipe: Recipe) -> VisualizationDAG:
|
||||
"""
|
||||
Build DAG visualization data from recipe.
|
||||
|
||||
Returns nodes and edges for Cytoscape.js.
|
||||
"""
|
||||
vis_nodes: List[VisNode] = []
|
||||
edges: List[VisEdge] = []
|
||||
|
||||
dag = recipe.get("dag", {})
|
||||
dag_nodes = dag.get("nodes", [])
|
||||
output_node = dag.get("output")
|
||||
|
||||
# Handle list format (compiled S-expression)
|
||||
if isinstance(dag_nodes, list):
|
||||
for node_def in dag_nodes:
|
||||
node_id = node_def.get("id")
|
||||
node_type = node_def.get("type", "EFFECT")
|
||||
|
||||
vis_nodes.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"nodeType": node_type,
|
||||
"isOutput": node_id == output_node,
|
||||
}
|
||||
})
|
||||
|
||||
for input_ref in node_def.get("inputs", []):
|
||||
if isinstance(input_ref, dict):
|
||||
source = input_ref.get("node") or input_ref.get("input")
|
||||
else:
|
||||
source = input_ref
|
||||
|
||||
if source:
|
||||
edges.append({
|
||||
"data": {
|
||||
"source": source,
|
||||
"target": node_id,
|
||||
}
|
||||
})
|
||||
|
||||
# Handle dict format
|
||||
elif isinstance(dag_nodes, dict):
|
||||
for node_id, node_def in dag_nodes.items():
|
||||
node_type = node_def.get("type", "EFFECT")
|
||||
|
||||
vis_nodes.append({
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"nodeType": node_type,
|
||||
"isOutput": node_id == output_node,
|
||||
}
|
||||
})
|
||||
|
||||
for input_ref in node_def.get("inputs", []):
|
||||
if isinstance(input_ref, dict):
|
||||
source = input_ref.get("node") or input_ref.get("input")
|
||||
else:
|
||||
source = input_ref
|
||||
|
||||
if source:
|
||||
edges.append({
|
||||
"data": {
|
||||
"source": source,
|
||||
"target": node_id,
|
||||
}
|
||||
})
|
||||
|
||||
return {"nodes": vis_nodes, "edges": edges}
|
||||
1001
l1/app/services/run_service.py
Normal file
1001
l1/app/services/run_service.py
Normal file
File diff suppressed because it is too large
Load Diff
232
l1/app/services/storage_service.py
Normal file
232
l1/app/services/storage_service.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Storage Service - business logic for storage provider management.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from database import Database
|
||||
from storage_providers import StorageProvidersModule
|
||||
|
||||
|
||||
STORAGE_PROVIDERS_INFO = {
|
||||
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
|
||||
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
|
||||
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
|
||||
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
|
||||
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
|
||||
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
|
||||
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
|
||||
}
|
||||
|
||||
VALID_PROVIDER_TYPES = list(STORAGE_PROVIDERS_INFO.keys())
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""Service for managing user storage providers."""
|
||||
|
||||
def __init__(self, database: "Database", storage_providers_module: "StorageProvidersModule") -> None:
|
||||
self.db = database
|
||||
self.providers = storage_providers_module
|
||||
|
||||
async def list_storages(self, actor_id: str) -> List[Dict[str, Any]]:
|
||||
"""List all storage providers for a user with usage stats."""
|
||||
storages = await self.db.get_user_storage(actor_id)
|
||||
|
||||
for storage in storages:
|
||||
usage = await self.db.get_storage_usage(storage["id"])
|
||||
storage["used_bytes"] = usage["used_bytes"]
|
||||
storage["pin_count"] = usage["pin_count"]
|
||||
storage["donated_gb"] = storage["capacity_gb"] // 2
|
||||
|
||||
# Mask sensitive config keys for display
|
||||
if storage.get("config"):
|
||||
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
masked = {}
|
||||
for k, v in config.items():
|
||||
if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower():
|
||||
masked[k] = v[:4] + "..." + v[-4:] if len(str(v)) > 8 else "****"
|
||||
else:
|
||||
masked[k] = v
|
||||
storage["config_display"] = masked
|
||||
|
||||
return storages
|
||||
|
||||
async def get_storage(self, storage_id: int, actor_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific storage provider."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return None
|
||||
if storage["actor_id"] != actor_id:
|
||||
return None
|
||||
|
||||
usage = await self.db.get_storage_usage(storage_id)
|
||||
storage["used_bytes"] = usage["used_bytes"]
|
||||
storage["pin_count"] = usage["pin_count"]
|
||||
storage["donated_gb"] = storage["capacity_gb"] // 2
|
||||
|
||||
return storage
|
||||
|
||||
async def add_storage(
|
||||
self,
|
||||
actor_id: str,
|
||||
provider_type: str,
|
||||
config: Dict[str, Any],
|
||||
capacity_gb: int = 5,
|
||||
provider_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""Add a new storage provider. Returns (storage_id, error_message)."""
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
return None, f"Invalid provider type: {provider_type}"
|
||||
|
||||
# Test connection before saving
|
||||
provider = self.providers.create_provider(provider_type, {
|
||||
**config,
|
||||
"capacity_gb": capacity_gb
|
||||
})
|
||||
if not provider:
|
||||
return None, "Failed to create provider with given config"
|
||||
|
||||
success, message = await provider.test_connection()
|
||||
if not success:
|
||||
return None, f"Provider connection failed: {message}"
|
||||
|
||||
# Generate name if not provided
|
||||
if not provider_name:
|
||||
existing = await self.db.get_user_storage_by_type(actor_id, provider_type)
|
||||
provider_name = f"{provider_type}-{len(existing) + 1}"
|
||||
|
||||
storage_id = await self.db.add_user_storage(
|
||||
actor_id=actor_id,
|
||||
provider_type=provider_type,
|
||||
provider_name=provider_name,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
description=description
|
||||
)
|
||||
|
||||
if not storage_id:
|
||||
return None, "Failed to save storage provider"
|
||||
|
||||
return storage_id, None
|
||||
|
||||
async def update_storage(
|
||||
self,
|
||||
storage_id: int,
|
||||
actor_id: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
capacity_gb: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Update a storage provider. Returns (success, error_message)."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return False, "Storage provider not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
# Test new config if provided
|
||||
if config:
|
||||
existing_config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
new_config = {**existing_config, **config}
|
||||
provider = self.providers.create_provider(storage["provider_type"], {
|
||||
**new_config,
|
||||
"capacity_gb": capacity_gb or storage["capacity_gb"]
|
||||
})
|
||||
if provider:
|
||||
success, message = await provider.test_connection()
|
||||
if not success:
|
||||
return False, f"Provider connection failed: {message}"
|
||||
|
||||
success = await self.db.update_user_storage(
|
||||
storage_id,
|
||||
config=config,
|
||||
capacity_gb=capacity_gb,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
return success, None if success else "Failed to update storage provider"
|
||||
|
||||
async def delete_storage(self, storage_id: int, actor_id: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Delete a storage provider. Returns (success, error_message)."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return False, "Storage provider not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
success = await self.db.remove_user_storage(storage_id)
|
||||
return success, None if success else "Failed to remove storage provider"
|
||||
|
||||
async def test_storage(self, storage_id: int, actor_id: str) -> Tuple[bool, str]:
|
||||
"""Test storage provider connectivity. Returns (success, message)."""
|
||||
storage = await self.db.get_storage_by_id(storage_id)
|
||||
if not storage:
|
||||
return False, "Storage not found"
|
||||
if storage["actor_id"] != actor_id:
|
||||
return False, "Not authorized"
|
||||
|
||||
config = storage["config"] if isinstance(storage["config"], dict) else json.loads(storage["config"])
|
||||
provider = self.providers.create_provider(storage["provider_type"], {
|
||||
**config,
|
||||
"capacity_gb": storage["capacity_gb"]
|
||||
})
|
||||
|
||||
if not provider:
|
||||
return False, "Failed to create provider"
|
||||
|
||||
return await provider.test_connection()
|
||||
|
||||
async def list_by_type(self, actor_id: str, provider_type: str) -> List[Dict[str, Any]]:
|
||||
"""List storage providers of a specific type."""
|
||||
return await self.db.get_user_storage_by_type(actor_id, provider_type)
|
||||
|
||||
def build_config_from_form(self, provider_type: str, form_data: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Build provider config from form data. Returns (config, error)."""
|
||||
api_key = form_data.get("api_key")
|
||||
secret_key = form_data.get("secret_key")
|
||||
api_token = form_data.get("api_token")
|
||||
project_id = form_data.get("project_id")
|
||||
project_secret = form_data.get("project_secret")
|
||||
access_key = form_data.get("access_key")
|
||||
bucket = form_data.get("bucket")
|
||||
path = form_data.get("path")
|
||||
|
||||
if provider_type == "pinata":
|
||||
if not api_key or not secret_key:
|
||||
return None, "Pinata requires API Key and Secret Key"
|
||||
return {"api_key": api_key, "secret_key": secret_key}, None
|
||||
|
||||
elif provider_type == "web3storage":
|
||||
if not api_token:
|
||||
return None, "web3.storage requires API Token"
|
||||
return {"api_token": api_token}, None
|
||||
|
||||
elif provider_type == "nftstorage":
|
||||
if not api_token:
|
||||
return None, "NFT.Storage requires API Token"
|
||||
return {"api_token": api_token}, None
|
||||
|
||||
elif provider_type == "infura":
|
||||
if not project_id or not project_secret:
|
||||
return None, "Infura requires Project ID and Project Secret"
|
||||
return {"project_id": project_id, "project_secret": project_secret}, None
|
||||
|
||||
elif provider_type == "filebase":
|
||||
if not access_key or not secret_key or not bucket:
|
||||
return None, "Filebase requires Access Key, Secret Key, and Bucket"
|
||||
return {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}, None
|
||||
|
||||
elif provider_type == "storj":
|
||||
if not access_key or not secret_key or not bucket:
|
||||
return None, "Storj requires Access Key, Secret Key, and Bucket"
|
||||
return {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}, None
|
||||
|
||||
elif provider_type == "local":
|
||||
if not path:
|
||||
return None, "Local storage requires a path"
|
||||
return {"path": path}, None
|
||||
|
||||
return None, f"Unknown provider type: {provider_type}"
|
||||
14
l1/app/templates/404.html
Normal file
14
l1/app/templates/404.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto text-center py-16">
|
||||
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
|
||||
<h2 class="text-2xl font-semibold mb-4">Page Not Found</h2>
|
||||
<p class="text-gray-400 mb-8">The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
46
l1/app/templates/base.html
Normal file
46
l1/app/templates/base.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
{% 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="/runs" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'runs' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
|
||||
<a href="/recipes" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'recipes' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
|
||||
<a href="/effects" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'effects' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
|
||||
<a href="/media" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'media' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</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{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
|
||||
<a href="/download/client" class="whitespace-nowrap px-3 py-1.5 rounded text-stone-700 hover:bg-stone-300" title="Download CLI client">Client</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
182
l1/app/templates/cache/detail.html
vendored
Normal file
182
l1/app/templates/cache/detail.html
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ cache.cid[:16] }} - Cache - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/media" class="text-gray-400 hover:text-white">← Media</a>
|
||||
<h1 class="text-xl font-bold font-mono">{{ cache.cid[:24] }}...</h1>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 overflow-hidden">
|
||||
{% if cache.mime_type and cache.mime_type.startswith('image/') %}
|
||||
{% if cache.remote_only and cache.ipfs_cid %}
|
||||
<img src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" alt=""
|
||||
class="w-full max-h-96 object-contain bg-gray-900">
|
||||
{% else %}
|
||||
<img src="/cache/{{ cache.cid }}/raw" alt=""
|
||||
class="w-full max-h-96 object-contain bg-gray-900">
|
||||
{% endif %}
|
||||
|
||||
{% elif cache.mime_type and cache.mime_type.startswith('video/') %}
|
||||
{% if cache.remote_only and cache.ipfs_cid %}
|
||||
<video src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" controls
|
||||
class="w-full max-h-96 bg-gray-900">
|
||||
</video>
|
||||
{% else %}
|
||||
<video src="/cache/{{ cache.cid }}/raw" controls
|
||||
class="w-full max-h-96 bg-gray-900">
|
||||
</video>
|
||||
{% endif %}
|
||||
|
||||
{% elif cache.mime_type and cache.mime_type.startswith('audio/') %}
|
||||
<div class="p-8 bg-gray-900">
|
||||
{% if cache.remote_only and cache.ipfs_cid %}
|
||||
<audio src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" controls class="w-full"></audio>
|
||||
{% else %}
|
||||
<audio src="/cache/{{ cache.cid }}/raw" controls class="w-full"></audio>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif cache.mime_type == 'application/json' %}
|
||||
<div class="p-4 bg-gray-900 max-h-96 overflow-auto">
|
||||
<pre class="text-sm text-gray-300">{{ cache.content_preview }}</pre>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="p-8 bg-gray-900 text-center text-gray-500">
|
||||
<div class="text-4xl mb-2">{{ cache.mime_type or 'Unknown type' }}</div>
|
||||
<div>{{ cache.size | filesizeformat if cache.size else 'Unknown size' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Friendly Name -->
|
||||
<div id="friendly-name-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-gray-500 text-sm">Friendly Name</span>
|
||||
<button hx-get="/cache/{{ cache.cid }}/name-form"
|
||||
hx-target="#friendly-name-section"
|
||||
hx-swap="innerHTML"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{% if cache.friendly_name %}
|
||||
<p class="text-blue-400 font-medium text-lg">{{ cache.friendly_name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm">No friendly name assigned. Click Edit to add one.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- User Metadata (editable) -->
|
||||
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold">Details</h3>
|
||||
<button hx-get="/cache/{{ cache.cid }}/meta-form"
|
||||
hx-target="#metadata-section"
|
||||
hx-swap="innerHTML"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{% if cache.title or cache.description or cache.filename %}
|
||||
<div class="space-y-2 mb-4">
|
||||
{% if cache.title %}
|
||||
<h4 class="text-white font-medium">{{ cache.title }}</h4>
|
||||
{% elif cache.filename %}
|
||||
<h4 class="text-white font-medium">{{ cache.filename }}</h4>
|
||||
{% endif %}
|
||||
{% if cache.description %}
|
||||
<p class="text-gray-400">{{ cache.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm mb-4">No title or description set. Click Edit to add metadata.</p>
|
||||
{% endif %}
|
||||
{% if cache.tags %}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for tag in cache.tags %}
|
||||
<span class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cache.source_type or cache.source_note %}
|
||||
<div class="text-sm text-gray-500">
|
||||
{% if cache.source_type %}Source: {{ cache.source_type }}{% endif %}
|
||||
{% if cache.source_note %} - {{ cache.source_note }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Technical Metadata -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">CID</div>
|
||||
<div class="font-mono text-sm text-white break-all">{{ cache.cid }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Content Type</div>
|
||||
<div class="text-white">{{ cache.mime_type or 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Size</div>
|
||||
<div class="text-white">{{ cache.size | filesizeformat if cache.size else 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Created</div>
|
||||
<div class="text-white">{{ cache.created_at or 'Unknown' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPFS -->
|
||||
{% if cache.ipfs_cid %}
|
||||
<div class="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<div class="text-gray-500 text-sm mb-1">IPFS CID</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-sm text-white">{{ cache.ipfs_cid }}</span>
|
||||
<a href="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
View on IPFS Gateway →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Related Runs -->
|
||||
{% if cache.runs %}
|
||||
<h2 class="text-lg font-semibold mb-4">Related Runs</h2>
|
||||
<div class="space-y-2">
|
||||
{% for run in cache.runs %}
|
||||
<a href="/runs/{{ run.run_id }}"
|
||||
class="block bg-gray-800 rounded p-3 hover:bg-gray-750 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-sm">{{ run.run_id[:16] }}...</span>
|
||||
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4 mt-8">
|
||||
<a href="/cache/{{ cache.cid }}/raw"
|
||||
download
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Download
|
||||
</a>
|
||||
<button hx-post="/cache/{{ cache.cid }}/publish"
|
||||
hx-target="#share-result"
|
||||
class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded font-medium">
|
||||
Share to L2
|
||||
</button>
|
||||
<span id="share-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
325
l1/app/templates/cache/media_list.html
vendored
Normal file
325
l1/app/templates/cache/media_list.html
vendored
Normal file
@@ -0,0 +1,325 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Media - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Media</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload Media
|
||||
</button>
|
||||
<select id="type-filter" onchange="filterMedia()"
|
||||
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<option value="">All Types</option>
|
||||
<option value="image">Images</option>
|
||||
<option value="video">Videos</option>
|
||||
<option value="audio">Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">Upload Media</h2>
|
||||
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Files</label>
|
||||
<input type="file" name="files" id="upload-file" required multiple
|
||||
accept="image/*,video/*,audio/*"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||
<p class="text-gray-500 text-xs mt-1">Select one or more files to upload</p>
|
||||
</div>
|
||||
|
||||
<div id="single-name-field">
|
||||
<label class="block text-gray-400 text-sm mb-1">Name (optional, for single file)</label>
|
||||
<input type="text" name="display_name" id="upload-name" placeholder="e.g., my-background-video"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A friendly name to reference this media in recipes</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-progress" class="hidden">
|
||||
<div class="bg-gray-700 rounded-full h-2">
|
||||
<div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
|
||||
</div>
|
||||
<p id="progress-text" class="text-gray-400 text-sm mt-1">Uploading...</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="hidden max-h-48 overflow-y-auto"></div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="upload-btn"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="media-grid">
|
||||
{% for item in items %}
|
||||
{# Determine media category from type or filename #}
|
||||
{% set is_image = item.type in ('image', 'image/jpeg', 'image/png', 'image/gif', 'image/webp') or (item.filename and item.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp'))) %}
|
||||
{% set is_video = item.type in ('video', 'video/mp4', 'video/webm', 'video/x-matroska') or (item.filename and item.filename.lower().endswith(('.mp4', '.mkv', '.webm', '.mov'))) %}
|
||||
{% set is_audio = item.type in ('audio', 'audio/mpeg', 'audio/wav', 'audio/flac') or (item.filename and item.filename.lower().endswith(('.mp3', '.wav', '.flac', '.ogg'))) %}
|
||||
|
||||
<a href="/cache/{{ item.cid }}"
|
||||
class="media-item bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
data-type="{% if is_image %}image{% elif is_video %}video{% elif is_audio %}audio{% else %}other{% endif %}">
|
||||
|
||||
{% if is_image %}
|
||||
<img src="/cache/{{ item.cid }}/raw"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="w-full h-40 object-cover">
|
||||
|
||||
{% elif is_video %}
|
||||
<div class="relative">
|
||||
<video src="/cache/{{ item.cid }}/raw"
|
||||
class="w-full h-40 object-cover"
|
||||
muted
|
||||
onmouseover="this.play()"
|
||||
onmouseout="this.pause(); this.currentTime=0;">
|
||||
</video>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-black bg-opacity-50 rounded-full p-2">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif is_audio %}
|
||||
<div class="w-full h-40 bg-gray-900 flex flex-col items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
<span class="text-gray-500 text-sm">Audio</span>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
|
||||
<span class="text-gray-600 text-sm">{{ item.type or 'Media' }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-3">
|
||||
{% if item.friendly_name %}
|
||||
<div class="text-xs text-blue-400 font-medium truncate">{{ item.friendly_name }}</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-xs text-gray-500 truncate">{{ item.cid[:16] }}...</div>
|
||||
{% endif %}
|
||||
{% if item.filename %}
|
||||
<div class="text-xs text-gray-600 truncate">{{ item.filename }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/media?offset={{ offset + limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="beforeend"
|
||||
hx-target="#media-grid"
|
||||
hx-select=".media-item"
|
||||
class="h-20 flex items-center justify-center text-gray-500 mt-4">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
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>
|
||||
<p class="text-gray-500 mb-4">No media files yet</p>
|
||||
<p class="text-gray-600 text-sm">Run a recipe to generate media artifacts.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterMedia() {
|
||||
const filter = document.getElementById('type-filter').value;
|
||||
document.querySelectorAll('.media-item').forEach(item => {
|
||||
if (!filter || item.dataset.type === filter) {
|
||||
item.classList.remove('hidden');
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide name field based on file count
|
||||
document.getElementById('upload-file').addEventListener('change', function(e) {
|
||||
const nameField = document.getElementById('single-name-field');
|
||||
if (e.target.files.length > 1) {
|
||||
nameField.style.display = 'none';
|
||||
} else {
|
||||
nameField.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle upload form
|
||||
document.getElementById('upload-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const fileInput = document.getElementById('upload-file');
|
||||
const files = fileInput.files;
|
||||
const displayName = document.getElementById('upload-name').value;
|
||||
const progressDiv = document.getElementById('upload-progress');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
// Show progress
|
||||
progressDiv.classList.remove('hidden');
|
||||
resultDiv.classList.add('hidden');
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const uploadId = crypto.randomUUID();
|
||||
const useChunked = file.size > CHUNK_SIZE * 2; // Use chunked for files > 2MB
|
||||
|
||||
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name}`;
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (useChunked && totalChunks > 1) {
|
||||
// Chunked upload for large files
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const chunkForm = new FormData();
|
||||
chunkForm.append('chunk', chunk);
|
||||
chunkForm.append('upload_id', uploadId);
|
||||
chunkForm.append('chunk_index', chunkIndex);
|
||||
chunkForm.append('total_chunks', totalChunks);
|
||||
chunkForm.append('filename', file.name);
|
||||
if (files.length === 1 && displayName) {
|
||||
chunkForm.append('display_name', displayName);
|
||||
}
|
||||
|
||||
const chunkProgress = ((i + (chunkIndex + 1) / totalChunks) / files.length) * 100;
|
||||
progressBar.style.width = `${chunkProgress}%`;
|
||||
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name} (${chunkIndex + 1}/${totalChunks} chunks)`;
|
||||
|
||||
const response = await fetch('/media/upload/chunk', {
|
||||
method: 'POST',
|
||||
body: chunkForm,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Chunk upload failed');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular upload for small files
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (files.length === 1 && displayName) {
|
||||
formData.append('display_name', displayName);
|
||||
}
|
||||
|
||||
progressBar.style.width = `${((i + 0.5) / files.length) * 100}%`;
|
||||
|
||||
const response = await fetch('/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ filename: file.name, friendly_name: data.friendly_name, cid: data.cid });
|
||||
} catch (err) {
|
||||
errors.push({ filename: file.name, error: err.message });
|
||||
}
|
||||
|
||||
progressBar.style.width = `${((i + 1) / files.length) * 100}%`;
|
||||
}
|
||||
|
||||
progressText.textContent = 'Upload complete!';
|
||||
|
||||
// Show results
|
||||
let html = '';
|
||||
if (results.length > 0) {
|
||||
html += '<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300 mb-2">';
|
||||
html += `<p class="font-medium">${results.length} file(s) uploaded successfully!</p>`;
|
||||
for (const r of results) {
|
||||
html += `<p class="text-sm mt-1">${r.filename} → <span class="font-mono">${r.friendly_name}</span></p>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
html += '<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">';
|
||||
html += `<p class="font-medium">${errors.length} file(s) failed:</p>`;
|
||||
for (const e of errors) {
|
||||
html += `<p class="text-sm mt-1">${e.filename}: ${e.error}</p>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (results.length > 0) {
|
||||
// Reload page after 2 seconds
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
21
l1/app/templates/cache/not_found.html
vendored
Normal file
21
l1/app/templates/cache/not_found.html
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Content Not Found - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto text-center py-16">
|
||||
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
|
||||
<h2 class="text-2xl font-semibold mb-4">Content Not Found</h2>
|
||||
<p class="text-gray-400 mb-8">
|
||||
The content with hash <code class="bg-gray-800 px-2 py-1 rounded">{{ cid[:24] if cid else 'unknown' }}...</code> was not found in the cache.
|
||||
</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<a href="/cache/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">
|
||||
Browse Media
|
||||
</a>
|
||||
<a href="/" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
203
l1/app/templates/effects/detail.html
Normal file
203
l1/app/templates/effects/detail.html
Normal file
@@ -0,0 +1,203 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set meta = effect.meta or effect %}
|
||||
|
||||
{% block title %}{{ meta.name or 'Effect' }} - Effects - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/lisp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/scheme.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/effects" class="text-gray-400 hover:text-white">← Effects</a>
|
||||
<h1 class="text-2xl font-bold">{{ meta.name or 'Unnamed Effect' }}</h1>
|
||||
<span class="text-gray-500">v{{ meta.version or '1.0.0' }}</span>
|
||||
{% if meta.temporal %}
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">temporal</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if meta.author %}
|
||||
<p class="text-gray-500 mb-2">by {{ meta.author }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if meta.description %}
|
||||
<p class="text-gray-400 mb-6">{{ meta.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Friendly Name & CID Info -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
|
||||
{% if effect.friendly_name %}
|
||||
<div class="mb-4 pb-4 border-b border-gray-700">
|
||||
<span class="text-gray-500 text-sm">Friendly Name</span>
|
||||
<p class="text-blue-400 font-medium text-lg mt-1">{{ effect.friendly_name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">(effect {{ effect.base_name }})</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">Content ID (CID)</span>
|
||||
<p class="font-mono text-sm text-gray-300 mt-1" id="effect-cid">{{ effect.cid }}</p>
|
||||
</div>
|
||||
<button onclick="copyToClipboard('{{ effect.cid }}')"
|
||||
class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded text-sm">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{% if effect.uploaded_at %}
|
||||
<div class="mt-3 text-gray-500 text-sm">
|
||||
Uploaded: {{ effect.uploaded_at }}
|
||||
{% if effect.uploader %}
|
||||
by {{ effect.uploader }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Parameters & Dependencies -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<!-- Parameters -->
|
||||
{% if meta.params %}
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2">
|
||||
<span class="text-gray-400 text-sm font-medium">Parameters</span>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
{% for param in meta.params %}
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="font-medium text-white">{{ param.name }}</span>
|
||||
<span class="bg-blue-900 text-blue-300 px-2 py-0.5 rounded text-xs">{{ param.type }}</span>
|
||||
</div>
|
||||
{% if param.description %}
|
||||
<p class="text-gray-400 text-sm">{{ param.description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2 mt-1 text-xs">
|
||||
{% if param.range %}
|
||||
<span class="text-gray-500">range: {{ param.range[0] }} - {{ param.range[1] }}</span>
|
||||
{% endif %}
|
||||
{% if param.default is defined %}
|
||||
<span class="text-gray-500">default: {{ param.default }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Usage in Recipe -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2">
|
||||
<span class="text-gray-400 text-sm font-medium">Usage in Recipe</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{% if effect.base_name %}
|
||||
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">({{ effect.base_name }} ...)</code></pre>
|
||||
<p class="text-gray-500 text-xs mt-2">
|
||||
Use the friendly name to reference this effect.
|
||||
</p>
|
||||
{% else %}
|
||||
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect :cid "{{ effect.cid }}")</code></pre>
|
||||
<p class="text-gray-500 text-xs mt-2">
|
||||
Reference this effect by CID in your recipe.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Source Code -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
|
||||
<span class="text-gray-400 text-sm font-medium">Source Code (S-expression)</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="/effects/{{ effect.cid }}/source"
|
||||
class="text-gray-400 hover:text-white text-sm"
|
||||
download="{{ meta.name or 'effect' }}.sexp">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-lisp" id="source-code">Loading...</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4 mt-8">
|
||||
{% if effect.cid.startswith('Qm') or effect.cid.startswith('bafy') %}
|
||||
<a href="https://ipfs.io/ipfs/{{ effect.cid }}"
|
||||
target="_blank"
|
||||
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
|
||||
View on IPFS
|
||||
</a>
|
||||
{% endif %}
|
||||
<button hx-post="/effects/{{ effect.cid }}/publish"
|
||||
hx-target="#action-result"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Share to L2
|
||||
</button>
|
||||
<button onclick="deleteEffect('{{ effect.cid }}')"
|
||||
class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<span id="action-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load source code
|
||||
fetch('/effects/{{ effect.cid }}/source')
|
||||
.then(response => response.text())
|
||||
.then(source => {
|
||||
const codeEl = document.getElementById('source-code');
|
||||
codeEl.textContent = source;
|
||||
hljs.highlightElement(codeEl);
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('source-code').textContent = 'Failed to load source code';
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = originalText; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteEffect(cid) {
|
||||
if (!confirm('Delete this effect from local cache? IPFS copies will persist.')) return;
|
||||
|
||||
fetch('/effects/' + cid, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Delete failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('action-result').innerHTML =
|
||||
'<span class="text-green-400">Deleted. Redirecting...</span>';
|
||||
setTimeout(() => { window.location.href = '/effects'; }, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('action-result').innerHTML =
|
||||
'<span class="text-red-400">' + error.message + '</span>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
200
l1/app/templates/effects/list.html
Normal file
200
l1/app/templates/effects/list.html
Normal file
@@ -0,0 +1,200 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Effects - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Effects</h1>
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload Effect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">Upload Effect</h2>
|
||||
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Effect File (.sexp)</label>
|
||||
<input type="file" name="file" id="upload-file" required
|
||||
accept=".sexp,.lisp"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Friendly Name (optional)</label>
|
||||
<input type="text" name="display_name" id="upload-name" placeholder="e.g., color-shift"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A name to reference this effect in recipes</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="hidden"></div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="upload-btn"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Effects are S-expression files that define video processing operations.
|
||||
Each effect is stored in IPFS and can be referenced by name in recipes.
|
||||
</p>
|
||||
|
||||
{% if effects %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="effects-list">
|
||||
{% for effect in effects %}
|
||||
{% set meta = effect.meta or effect %}
|
||||
<a href="/effects/{{ effect.cid }}"
|
||||
class="effect-card bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-white">{{ meta.name or 'Unnamed' }}</span>
|
||||
<span class="text-gray-500 text-sm">v{{ meta.version or '1.0.0' }}</span>
|
||||
</div>
|
||||
|
||||
{% if meta.description %}
|
||||
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ meta.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
{% if meta.author %}
|
||||
<span class="text-gray-500">by {{ meta.author }}</span>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if meta.temporal %}
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">temporal</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if meta.params %}
|
||||
<div class="text-gray-500 text-sm">
|
||||
{{ meta.params | length }} parameter{{ 's' if meta.params | length != 1 else '' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 text-xs">
|
||||
{% if effect.friendly_name %}
|
||||
<span class="text-blue-400 font-medium">{{ effect.friendly_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-600 font-mono truncate">{{ effect.cid[:24] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/effects?offset={{ offset + limit }}&limit={{ limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
hx-select="#effects-list > *"
|
||||
class="h-20 flex items-center justify-center text-gray-500">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
|
||||
<p class="text-gray-600 text-sm mb-6">
|
||||
Effects are S-expression files with metadata in comment headers.
|
||||
</p>
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium">
|
||||
Upload Your First Effect
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle upload form
|
||||
document.getElementById('upload-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const fileInput = document.getElementById('upload-file');
|
||||
const displayName = document.getElementById('upload-name').value;
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (displayName) {
|
||||
formData.append('display_name', displayName);
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Uploading...';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/effects/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300">
|
||||
<p class="font-medium">Effect uploaded!</p>
|
||||
<p class="text-sm mt-1">${data.name} <span class="font-mono">${data.friendly_name}</span></p>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
|
||||
<p class="font-medium">Upload failed</p>
|
||||
<p class="text-sm mt-1">${data.detail || 'Unknown error'}</p>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
|
||||
<p class="font-medium">Upload failed</p>
|
||||
<p class="text-sm mt-1">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
22
l1/app/templates/fragments/link_card.html
Normal file
22
l1/app/templates/fragments/link_card.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="artdag" data-hx-disable>
|
||||
<div class="flex flex-row items-center gap-3 p-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded bg-stone-100 flex items-center justify-center text-stone-500">
|
||||
{% if content_type == "recipe" %}
|
||||
<i class="fas fa-scroll text-sm"></i>
|
||||
{% elif content_type == "effect" %}
|
||||
<i class="fas fa-magic text-sm"></i>
|
||||
{% elif content_type == "run" %}
|
||||
<i class="fas fa-play-circle text-sm"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-cube text-sm"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-stone-900 text-sm truncate">{{ title }}</div>
|
||||
{% if description %}
|
||||
<div class="text-xs text-stone-500 clamp-2">{{ description }}</div>
|
||||
{% endif %}
|
||||
<div class="text-xs text-stone-400 mt-0.5">{{ content_type }} · {{ cid[:12] }}…</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
7
l1/app/templates/fragments/nav_item.html
Normal file
7
l1/app/templates/fragments/nav_item.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ artdag_url }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
data-hx-disable>
|
||||
<i class="fas fa-project-diagram text-sm"></i> art-dag
|
||||
</a>
|
||||
</div>
|
||||
51
l1/app/templates/home.html
Normal file
51
l1/app/templates/home.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto text-center py-12">
|
||||
<h1 class="text-4xl font-bold mb-4">Art-DAG L1</h1>
|
||||
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media Processing</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl mx-auto mb-12">
|
||||
<a href="/runs"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-blue-500 transition-colors">
|
||||
<div class="text-blue-400 text-3xl font-bold mb-2">{{ stats.runs or 0 }}</div>
|
||||
<div class="text-gray-400">Execution Runs</div>
|
||||
</a>
|
||||
<a href="/recipes"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-green-500 transition-colors">
|
||||
<div class="text-green-400 text-3xl font-bold mb-2">{{ stats.recipes or 0 }}</div>
|
||||
<div class="text-gray-400">Recipes</div>
|
||||
</a>
|
||||
<a href="/effects"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-cyan-500 transition-colors">
|
||||
<div class="text-cyan-400 text-3xl font-bold mb-2">{{ stats.effects or 0 }}</div>
|
||||
<div class="text-gray-400">Effects</div>
|
||||
</a>
|
||||
<a href="/media"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-purple-500 transition-colors">
|
||||
<div class="text-purple-400 text-3xl font-bold mb-2">{{ stats.media or 0 }}</div>
|
||||
<div class="text-gray-400">Media Files</div>
|
||||
</a>
|
||||
<a href="/storage"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-orange-500 transition-colors">
|
||||
<div class="text-orange-400 text-3xl font-bold mb-2">{{ stats.storage or 0 }}</div>
|
||||
<div class="text-gray-400">Storage Providers</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not user %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 max-w-md mx-auto mb-12">
|
||||
<p class="text-gray-400 mb-4">Sign in through your L2 server to access all features.</p>
|
||||
<a href="/auth" class="text-blue-400 hover:text-blue-300">Sign In →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if readme_html %}
|
||||
<div class="text-left bg-gray-800 border border-gray-700 rounded-lg p-8 prose prose-invert max-w-none">
|
||||
{{ readme_html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
265
l1/app/templates/recipes/detail.html
Normal file
265
l1/app/templates/recipes/detail.html
Normal file
@@ -0,0 +1,265 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ recipe.name }} - Recipe - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/recipes" class="text-gray-400 hover:text-white">← Recipes</a>
|
||||
<h1 class="text-2xl font-bold">{{ recipe.name or 'Unnamed Recipe' }}</h1>
|
||||
{% if recipe.version %}
|
||||
<span class="text-gray-500">v{{ recipe.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-gray-400 mb-4">{{ recipe.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Recipe ID</span>
|
||||
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.recipe_id }}">{{ recipe.recipe_id[:16] }}...</p>
|
||||
</div>
|
||||
{% if recipe.ipfs_cid %}
|
||||
<div>
|
||||
<span class="text-gray-500">IPFS CID</span>
|
||||
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.ipfs_cid }}">{{ recipe.ipfs_cid[:16] }}...</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<span class="text-gray-500">Steps</span>
|
||||
<p class="text-gray-300">{{ recipe.step_count or recipe.steps|length }}</p>
|
||||
</div>
|
||||
{% if recipe.author %}
|
||||
<div>
|
||||
<span class="text-gray-500">Author</span>
|
||||
<p class="text-gray-300">{{ recipe.author }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recipe.type == 'streaming' %}
|
||||
<!-- Streaming Recipe Info -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 p-4">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">Streaming Recipe</span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
This recipe uses frame-by-frame streaming rendering. The pipeline is defined as an S-expression that generates frames dynamically.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- DAG Visualization -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
|
||||
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
|
||||
<span class="text-gray-400 text-sm">Pipeline DAG</span>
|
||||
<span class="text-gray-500 text-sm">{{ recipe.steps | length }} steps</span>
|
||||
</div>
|
||||
<div id="dag-container" class="h-80"></div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<h2 class="text-lg font-semibold mb-4">Steps</h2>
|
||||
<div class="space-y-3 mb-8">
|
||||
{% for step in recipe.steps %}
|
||||
{% set colors = {
|
||||
'effect': 'blue',
|
||||
'analyze': 'purple',
|
||||
'transform': 'green',
|
||||
'combine': 'orange',
|
||||
'output': 'cyan'
|
||||
} %}
|
||||
{% set color = colors.get(step.type, 'gray') %}
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-8 h-8 rounded bg-{{ color }}-900 text-{{ color }}-300 flex items-center justify-center font-mono text-sm">
|
||||
{{ loop.index }}
|
||||
</span>
|
||||
<span class="font-medium">{{ step.name }}</span>
|
||||
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs">
|
||||
{{ step.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if step.inputs %}
|
||||
<div class="text-sm text-gray-400 mb-1">
|
||||
Inputs: {{ step.inputs | join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step.params %}
|
||||
<div class="mt-2 bg-gray-900 rounded p-2">
|
||||
<code class="text-xs text-gray-400">{{ step.params | tojson }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Source Code -->
|
||||
<h2 class="text-lg font-semibold mb-4">Recipe (S-expression)</h2>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
{% if recipe.sexp %}
|
||||
<pre class="text-sm font-mono text-gray-300 overflow-x-auto whitespace-pre-wrap sexp-code">{{ recipe.sexp }}</pre>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No source available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Single-pass S-expression syntax highlighter (avoids regex corruption)
|
||||
function highlightSexp(text) {
|
||||
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
|
||||
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
|
||||
|
||||
let out = '', i = 0, len = text.length;
|
||||
while (i < len) {
|
||||
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
|
||||
let end = text.indexOf('\n', i);
|
||||
if (end === -1) end = len;
|
||||
out += span('text-gray-500', text.slice(i, end));
|
||||
i = end;
|
||||
}
|
||||
else if (text[i] === '"') {
|
||||
let j = i + 1;
|
||||
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
|
||||
if (j < len) j++;
|
||||
out += span('text-green-400', text.slice(i, j));
|
||||
i = j;
|
||||
}
|
||||
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
|
||||
let j = i + 1;
|
||||
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
|
||||
out += span('text-purple-400', text.slice(i, j));
|
||||
i = j;
|
||||
}
|
||||
else if (text[i] === '(') {
|
||||
out += span('text-yellow-500', '(');
|
||||
i++;
|
||||
let ws = '';
|
||||
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
|
||||
out += esc(ws);
|
||||
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
|
||||
let j = i;
|
||||
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
|
||||
let word = text.slice(i, j);
|
||||
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
|
||||
else if (PRIMS.has(word)) out += span('text-blue-400', word);
|
||||
else out += esc(word);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
else if (text[i] === ')') {
|
||||
out += span('text-yellow-500', ')');
|
||||
i++;
|
||||
}
|
||||
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
|
||||
let j = i;
|
||||
while (j < len && /[0-9.]/.test(text[j])) j++;
|
||||
out += span('text-orange-300', text.slice(i, j));
|
||||
i = j;
|
||||
}
|
||||
else {
|
||||
let j = i;
|
||||
while (j < len && !'(;":)'.includes(text[j])) {
|
||||
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
|
||||
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
|
||||
j++;
|
||||
}
|
||||
if (j === i) { out += esc(text[i]); i++; }
|
||||
else { out += esc(text.slice(i, j)); i = j; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sexp-code').forEach(el => {
|
||||
el.innerHTML = highlightSexp(el.textContent);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4 mt-8">
|
||||
<button hx-post="/runs/rerun/{{ recipe.recipe_id }}"
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded font-medium">
|
||||
Run Recipe
|
||||
</button>
|
||||
{% if recipe.ipfs_cid %}
|
||||
<a href="https://ipfs.io/ipfs/{{ recipe.ipfs_cid }}"
|
||||
target="_blank"
|
||||
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
|
||||
View on IPFS
|
||||
</a>
|
||||
{% elif recipe.recipe_id.startswith('Qm') or recipe.recipe_id.startswith('bafy') %}
|
||||
<a href="https://ipfs.io/ipfs/{{ recipe.recipe_id }}"
|
||||
target="_blank"
|
||||
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
|
||||
View on IPFS
|
||||
</a>
|
||||
{% endif %}
|
||||
<button hx-post="/recipes/{{ recipe.recipe_id }}/publish"
|
||||
hx-target="#action-result"
|
||||
class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded font-medium">
|
||||
Share to L2
|
||||
</button>
|
||||
<button hx-delete="/recipes/{{ recipe.recipe_id }}/ui"
|
||||
hx-target="#action-result"
|
||||
hx-confirm="Delete this recipe? This cannot be undone."
|
||||
class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<span id="action-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cy = cytoscape({
|
||||
container: document.getElementById('dag-container'),
|
||||
style: [
|
||||
{ selector: 'node', style: {
|
||||
'label': 'data(label)',
|
||||
'background-color': 'data(color)',
|
||||
'color': '#fff',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '11px',
|
||||
'width': 'label',
|
||||
'height': 35,
|
||||
'padding': '10px',
|
||||
'shape': 'round-rectangle'
|
||||
}},
|
||||
{ selector: 'edge', style: {
|
||||
'width': 2,
|
||||
'line-color': '#4b5563',
|
||||
'target-arrow-color': '#4b5563',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier'
|
||||
}}
|
||||
],
|
||||
elements: {{ dag_elements | tojson }},
|
||||
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
136
l1/app/templates/recipes/list.html
Normal file
136
l1/app/templates/recipes/list.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Recipes - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Recipes</h1>
|
||||
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
|
||||
Upload Recipe
|
||||
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Recipes define processing pipelines for audio and media. Each recipe is a DAG of effects.
|
||||
</p>
|
||||
|
||||
{% if recipes %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="recipes-list">
|
||||
{% for recipe in recipes %}
|
||||
<a href="/recipes/{{ recipe.recipe_id }}"
|
||||
class="recipe-card bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-white">{{ recipe.name }}</span>
|
||||
{% if recipe.version %}
|
||||
<span class="text-gray-500 text-sm">v{{ recipe.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ recipe.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">{{ recipe.step_count or 0 }} steps</span>
|
||||
{% if recipe.last_run %}
|
||||
<span class="text-gray-500">Last run: {{ recipe.last_run }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if recipe.tags %}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{% for tag in recipe.tags %}
|
||||
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 text-xs">
|
||||
{% if recipe.friendly_name %}
|
||||
<span class="text-blue-400 font-medium">{{ recipe.friendly_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-600 font-mono truncate">{{ recipe.recipe_id[:24] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/recipes?offset={{ offset + limit }}&limit={{ limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
hx-select="#recipes-list > *"
|
||||
class="h-20 flex items-center justify-center text-gray-500">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<p class="text-gray-500 mb-4">No recipes available.</p>
|
||||
<p class="text-gray-600 text-sm mb-6">
|
||||
Recipes are S-expression files (.sexp) that define processing pipelines.
|
||||
</p>
|
||||
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
|
||||
Upload Your First Recipe
|
||||
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload-empty" />
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
|
||||
|
||||
<script>
|
||||
function handleRecipeUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
fetch('/recipes/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
|
||||
<p class="text-green-300 font-medium">Recipe uploaded!</p>
|
||||
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
|
||||
<p class="text-gray-400 text-xs mt-2 font-mono">${data.recipe_id}</p>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
})
|
||||
.catch(error => {
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
|
||||
<p class="text-red-300 font-medium">Upload failed</p>
|
||||
<p class="text-red-400 text-sm mt-1">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
document.getElementById('recipe-upload')?.addEventListener('change', function() {
|
||||
handleRecipeUpload(this);
|
||||
});
|
||||
document.getElementById('recipe-upload-empty')?.addEventListener('change', function() {
|
||||
handleRecipeUpload(this);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
89
l1/app/templates/runs/_run_card.html
Normal file
89
l1/app/templates/runs/_run_card.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{# Run card partial - expects 'run' variable #}
|
||||
{% set status_colors = {
|
||||
'completed': 'green',
|
||||
'running': 'blue',
|
||||
'pending': 'yellow',
|
||||
'failed': 'red',
|
||||
'cached': 'purple'
|
||||
} %}
|
||||
{% set color = status_colors.get(run.status, 'gray') %}
|
||||
|
||||
<a href="/runs/{{ run.run_id }}"
|
||||
class="block bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="font-mono text-sm text-gray-400">{{ run.run_id[:12] }}...</span>
|
||||
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs uppercase">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
{% if run.cached %}
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">cached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
<span class="text-gray-400">
|
||||
Recipe: <span class="text-white">{{ run.recipe_name or (run.recipe[:12] ~ '...' if run.recipe and run.recipe|length > 12 else run.recipe) or 'Unknown' }}</span>
|
||||
</span>
|
||||
{% if run.total_steps %}
|
||||
<span class="text-gray-400">
|
||||
Steps: <span class="text-white">{{ run.executed or 0 }}/{{ run.total_steps }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Media previews row #}
|
||||
<div class="flex items-center space-x-4">
|
||||
{# Input previews #}
|
||||
{% if run.input_previews %}
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-xs text-gray-500 mr-1">In:</span>
|
||||
{% for inp in run.input_previews %}
|
||||
{% if inp.media_type and inp.media_type.startswith('image/') %}
|
||||
<img src="/cache/{{ inp.cid }}/raw" alt="" class="w-10 h-10 object-cover rounded">
|
||||
{% elif inp.media_type and inp.media_type.startswith('video/') %}
|
||||
<video src="/cache/{{ inp.cid }}/raw" class="w-10 h-10 object-cover rounded" muted></video>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if run.inputs and run.inputs|length > 3 %}
|
||||
<span class="text-xs text-gray-500">+{{ run.inputs|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif run.inputs %}
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ run.inputs|length }} input(s)
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Arrow #}
|
||||
<span class="text-gray-600">-></span>
|
||||
|
||||
{# Output preview - prefer IPFS URLs when available #}
|
||||
{% if run.output_cid %}
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-xs text-gray-500 mr-1">Out:</span>
|
||||
{% if run.output_media_type and run.output_media_type.startswith('image/') %}
|
||||
<img src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" alt="" class="w-10 h-10 object-cover rounded">
|
||||
{% elif run.output_media_type and run.output_media_type.startswith('video/') %}
|
||||
<video src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" class="w-10 h-10 object-cover rounded" muted></video>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500">No output yet</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
{% if run.output_cid %}
|
||||
<span class="font-mono text-xs text-gray-600">{{ run.output_cid[:12] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
62
l1/app/templates/runs/artifacts.html
Normal file
62
l1/app/templates/runs/artifacts.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Run Artifacts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/runs/{{ run_id }}/detail" 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 Run
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Run Artifacts</h1>
|
||||
|
||||
{% if artifacts %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for artifact in artifacts %}
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="px-2 py-1 text-xs rounded
|
||||
{% if artifact.role == 'input' %}bg-blue-600
|
||||
{% elif artifact.role == 'output' %}bg-green-600
|
||||
{% else %}bg-purple-600{% endif %}">
|
||||
{{ artifact.role }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-400">{{ artifact.step_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<p class="text-xs text-gray-500 mb-1">Content Hash</p>
|
||||
<p class="font-mono text-xs text-gray-300 truncate">{{ artifact.hash }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-400">
|
||||
{% if artifact.media_type == 'video' %}Video
|
||||
{% elif artifact.media_type == 'image' %}Image
|
||||
{% elif artifact.media_type == 'audio' %}Audio
|
||||
{% else %}File{% endif %}
|
||||
</span>
|
||||
<span class="text-gray-500">{{ (artifact.size_bytes / 1024)|round(1) }} KB</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a href="/cache/{{ artifact.hash }}" class="flex-1 px-3 py-1 bg-gray-700 hover:bg-gray-600 text-center text-sm rounded transition-colors">
|
||||
View
|
||||
</a>
|
||||
<a href="/cache/{{ artifact.hash }}/raw" class="flex-1 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-center text-sm rounded transition-colors">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-800 rounded-lg p-6 text-center">
|
||||
<p class="text-gray-400">No artifacts found for this run.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
1073
l1/app/templates/runs/detail.html
Normal file
1073
l1/app/templates/runs/detail.html
Normal file
File diff suppressed because it is too large
Load Diff
45
l1/app/templates/runs/list.html
Normal file
45
l1/app/templates/runs/list.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Runs - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Execution Runs</h1>
|
||||
<a href="/recipes" class="text-gray-400 hover:text-white">Browse Recipes →</a>
|
||||
</div>
|
||||
|
||||
{% if runs %}
|
||||
<div class="space-y-4" id="runs-list">
|
||||
{% for run in runs %}
|
||||
{% include "runs/_run_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/runs?offset={{ offset + limit }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
hx-select="#runs-list > *"
|
||||
class="h-20 flex items-center justify-center text-gray-500">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<div class="text-gray-500 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<p class="text-xl">No runs yet</p>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6">Execute a recipe to see your runs here.</p>
|
||||
<a href="/recipes" class="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded font-medium">
|
||||
Browse Recipes
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
99
l1/app/templates/runs/plan.html
Normal file
99
l1/app/templates/runs/plan.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Run Plan - {{ run_id[:16] }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/runs/{{ run_id }}/detail" 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 Run
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Execution Plan</h1>
|
||||
|
||||
{% if plan %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- DAG Visualization -->
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">DAG Visualization</h2>
|
||||
<div id="dag-container" class="w-full h-96 bg-gray-900 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Steps List -->
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Steps ({{ plan.steps|length if plan.steps else 0 }})</h2>
|
||||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||
{% for step in plan.get('steps', []) %}
|
||||
<div class="bg-gray-900 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-white">{{ step.name or step.id or 'Step ' ~ loop.index }}</span>
|
||||
<span class="px-2 py-0.5 text-xs rounded {% if step.status == 'completed' %}bg-green-600{% elif step.cached %}bg-blue-600{% else %}bg-gray-600{% endif %}">
|
||||
{{ step.status or ('cached' if step.cached else 'pending') }}
|
||||
</span>
|
||||
</div>
|
||||
{% if step.cache_id %}
|
||||
<div class="text-xs text-gray-400 font-mono truncate">
|
||||
{{ step.cache_id[:24] }}...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No steps defined</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const elements = {{ dag_elements | tojson | safe }};
|
||||
|
||||
if (elements.length > 0) {
|
||||
cytoscape({
|
||||
container: document.getElementById('dag-container'),
|
||||
elements: elements,
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': 'data(color)',
|
||||
'label': 'data(label)',
|
||||
'color': '#fff',
|
||||
'text-valign': 'bottom',
|
||||
'text-margin-y': 5,
|
||||
'font-size': '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 2,
|
||||
'line-color': '#6b7280',
|
||||
'target-arrow-color': '#6b7280',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier'
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
name: 'breadthfirst',
|
||||
directed: true,
|
||||
padding: 20
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="bg-gray-800 rounded-lg p-6 text-center">
|
||||
<p class="text-gray-400">No execution plan available for this run.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
99
l1/app/templates/runs/plan_node.html
Normal file
99
l1/app/templates/runs/plan_node.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{# Plan node detail panel - loaded via HTMX #}
|
||||
{% set status_color = 'green' if status in ('cached', 'completed') else 'yellow' %}
|
||||
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-white">{{ step.name or step.step_id[:20] }}</h4>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="px-2 py-0.5 rounded text-xs text-white" style="background-color: {{ node_color }}">
|
||||
{{ step.node_type or 'EFFECT' }}
|
||||
</span>
|
||||
<span class="text-{{ status_color }}-400 text-xs">{{ status }}</span>
|
||||
<span class="text-gray-500 text-xs">Level {{ step.level or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeNodeDetail()" class="text-gray-400 hover:text-white p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Output preview #}
|
||||
{% if output_preview %}
|
||||
<div class="mb-4">
|
||||
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
|
||||
{% if output_media_type == 'video' %}
|
||||
<video src="/cache/{{ cache_id }}/raw" controls muted class="w-full max-h-48 rounded-lg"></video>
|
||||
{% elif output_media_type == 'image' %}
|
||||
<img src="/cache/{{ cache_id }}/raw" class="w-full max-h-48 rounded-lg object-contain">
|
||||
{% elif output_media_type == 'audio' %}
|
||||
<audio src="/cache/{{ cache_id }}/raw" controls class="w-full"></audio>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif ipfs_cid %}
|
||||
<div class="mb-4">
|
||||
<h5 class="text-sm font-medium text-gray-400 mb-2">Output (IPFS)</h5>
|
||||
<video src="{{ ipfs_gateway }}/{{ ipfs_cid }}" controls muted class="w-full max-h-48 rounded-lg"></video>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Output link #}
|
||||
{% if ipfs_cid %}
|
||||
<a href="/ipfs/{{ ipfs_cid }}" class="flex items-center justify-between bg-gray-800 rounded p-2 hover:bg-gray-700 transition-colors text-xs mb-4">
|
||||
<span class="font-mono text-gray-300 truncate">{{ ipfs_cid[:24] }}...</span>
|
||||
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
|
||||
</a>
|
||||
{% elif has_cached and cache_id %}
|
||||
<a href="/cache/{{ cache_id }}" class="flex items-center justify-between bg-gray-800 rounded p-2 hover:bg-gray-700 transition-colors text-xs mb-4">
|
||||
<span class="font-mono text-gray-300 truncate">{{ cache_id[:24] }}...</span>
|
||||
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Input media previews #}
|
||||
{% if inputs %}
|
||||
<div class="mt-4">
|
||||
<h5 class="text-sm font-medium text-gray-400 mb-2">Inputs ({{ inputs|length }})</h5>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{% for inp in inputs %}
|
||||
<a href="/cache/{{ inp.cache_id }}" class="block bg-gray-800 rounded-lg overflow-hidden hover:bg-gray-700 transition-colors">
|
||||
{% if inp.media_type == 'video' %}
|
||||
<video src="/cache/{{ inp.cache_id }}/raw" class="w-full h-20 object-cover rounded-t" muted></video>
|
||||
{% elif inp.media_type == 'image' %}
|
||||
<img src="/cache/{{ inp.cache_id }}/raw" class="w-full h-20 object-cover rounded-t">
|
||||
{% else %}
|
||||
<div class="w-full h-20 bg-gray-700 rounded-t flex items-center justify-center text-xs text-gray-400">
|
||||
{{ inp.media_type or 'File' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-2">
|
||||
<div class="text-xs text-white truncate">{{ inp.name }}</div>
|
||||
<div class="text-xs text-gray-500 font-mono truncate">{{ inp.cache_id[:12] }}...</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Parameters/Config #}
|
||||
{% if config %}
|
||||
<div class="mt-4">
|
||||
<h5 class="text-sm font-medium text-gray-400 mb-2">Parameters</h5>
|
||||
<div class="bg-gray-800 rounded p-3 text-xs space-y-1">
|
||||
{% for key, value in config.items() %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">{{ key }}:</span>
|
||||
<span class="text-white">{{ value if value is string else value|tojson }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Metadata #}
|
||||
<div class="mt-4 text-xs text-gray-500 space-y-1">
|
||||
<div><span class="text-gray-400">Step ID:</span> <span class="font-mono">{{ step.step_id[:32] }}...</span></div>
|
||||
<div><span class="text-gray-400">Cache ID:</span> <span class="font-mono">{{ cache_id[:32] }}...</span></div>
|
||||
</div>
|
||||
90
l1/app/templates/storage/list.html
Normal file
90
l1/app/templates/storage/list.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Storage Providers - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Storage Providers</h1>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Configure your IPFS pinning services. Data is pinned to your accounts, giving you full control.
|
||||
</p>
|
||||
|
||||
<!-- Provider Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{% for key, info in providers_info.items() %}
|
||||
<a href="/storage/type/{{ key }}"
|
||||
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-{{ info.color }}-500 transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-lg font-medium text-{{ info.color }}-400">{{ info.name }}</span>
|
||||
{% set count = storages | selectattr('provider_type', 'equalto', key) | list | length %}
|
||||
{% if count > 0 %}
|
||||
<span class="bg-{{ info.color }}-900 text-{{ info.color }}-300 px-2 py-0.5 rounded text-sm">
|
||||
{{ count }} configured
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">{{ info.desc }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Configured Providers -->
|
||||
{% if storages %}
|
||||
<h2 class="text-xl font-semibold mb-4">Your Storage Providers</h2>
|
||||
<div class="space-y-4">
|
||||
{% for storage in storages %}
|
||||
{% set info = providers_info.get(storage.provider_type, {'name': storage.provider_type, 'color': 'gray'}) %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-{{ info.color }}-400 font-medium">{{ storage.provider_name or info.name }}</span>
|
||||
{% if storage.is_active %}
|
||||
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
|
||||
{% else %}
|
||||
<span class="bg-gray-700 text-gray-400 px-2 py-0.5 rounded text-xs">Inactive</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button hx-post="/storage/{{ storage.id }}/test"
|
||||
hx-target="#test-result-{{ storage.id }}"
|
||||
class="text-gray-400 hover:text-white text-sm">
|
||||
Test
|
||||
</button>
|
||||
<button hx-delete="/storage/{{ storage.id }}"
|
||||
hx-target="#storage-{{ storage.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this storage provider?"
|
||||
class="text-red-400 hover:text-red-300 text-sm">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Capacity:</span>
|
||||
<span class="text-gray-300">{{ storage.capacity_gb }} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Used:</span>
|
||||
<span class="text-gray-300">{{ (storage.used_bytes / 1024 / 1024 / 1024) | round(2) }} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Pins:</span>
|
||||
<span class="text-gray-300">{{ storage.pin_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="test-result-{{ storage.id }}" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-400 mb-4">No storage providers configured yet.</p>
|
||||
<p class="text-gray-500 text-sm">Click on a provider above to add your first one.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
152
l1/app/templates/storage/type.html
Normal file
152
l1/app/templates/storage/type.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ provider_info.name }} - Storage - Art-DAG L1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<a href="/storage" class="text-gray-400 hover:text-white">← All Providers</a>
|
||||
<h1 class="text-2xl font-bold text-{{ provider_info.color }}-400">{{ provider_info.name }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">{{ provider_info.desc }}</p>
|
||||
|
||||
<!-- Add New -->
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4">Add {{ provider_info.name }} Account</h2>
|
||||
|
||||
<form hx-post="/storage/add"
|
||||
hx-target="#add-result"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="provider_type" value="{{ provider_type }}">
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Name (optional)</label>
|
||||
<input type="text" name="provider_name"
|
||||
placeholder="{{ provider_type }}-1"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% if provider_type == 'pinata' %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">API Key *</label>
|
||||
<input type="text" name="api_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
|
||||
<input type="password" name="secret_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif provider_type in ['web3storage', 'nftstorage'] %}
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">API Token *</label>
|
||||
<input type="password" name="api_token" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% elif provider_type == 'infura' %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Project ID *</label>
|
||||
<input type="text" name="project_id" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Project Secret *</label>
|
||||
<input type="password" name="project_secret" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif provider_type in ['filebase', 'storj'] %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Access Key *</label>
|
||||
<input type="text" name="access_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
|
||||
<input type="password" name="secret_key" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Bucket *</label>
|
||||
<input type="text" name="bucket" required
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
{% elif provider_type == 'local' %}
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Path *</label>
|
||||
<input type="text" name="path" required placeholder="/data/ipfs"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Capacity (GB)</label>
|
||||
<input type="number" name="capacity_gb" value="5" min="1"
|
||||
class="w-32 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit"
|
||||
class="bg-{{ provider_info.color }}-600 hover:bg-{{ provider_info.color }}-700 px-4 py-2 rounded font-medium">
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="add-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Configs -->
|
||||
{% if storages %}
|
||||
<h2 class="text-lg font-semibold mb-4">Configured Accounts</h2>
|
||||
<div class="space-y-4">
|
||||
{% for storage in storages %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="font-medium">{{ storage.provider_name }}</span>
|
||||
{% if storage.is_active %}
|
||||
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button hx-post="/storage/{{ storage.id }}/test"
|
||||
hx-target="#test-{{ storage.id }}"
|
||||
class="text-gray-400 hover:text-white text-sm">
|
||||
Test Connection
|
||||
</button>
|
||||
<button hx-delete="/storage/{{ storage.id }}"
|
||||
hx-target="#storage-{{ storage.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this storage provider?"
|
||||
class="text-red-400 hover:text-red-300 text-sm">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if storage.config_display %}
|
||||
<div class="text-sm text-gray-400 space-x-4">
|
||||
{% for key, value in storage.config_display.items() %}
|
||||
<span>{{ key }}: <code class="text-gray-300">{{ value }}</code></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="test-{{ storage.id }}" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
197
l1/app/types.py
Normal file
197
l1/app/types.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Type definitions for Art DAG L1 server.
|
||||
|
||||
Uses TypedDict for configuration structures to enable mypy checking.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
|
||||
# === Node Config Types ===
|
||||
|
||||
class SourceConfig(TypedDict, total=False):
|
||||
"""Config for SOURCE nodes."""
|
||||
cid: str # Content ID (IPFS CID or SHA3-256 hash)
|
||||
asset: str # Asset name from registry
|
||||
input: bool # True if this is a variable input
|
||||
name: str # Human-readable name for variable inputs
|
||||
description: str # Description for variable inputs
|
||||
|
||||
|
||||
class EffectConfig(TypedDict, total=False):
|
||||
"""Config for EFFECT nodes."""
|
||||
effect: str # Effect name
|
||||
cid: str # Effect CID (for cached/IPFS effects)
|
||||
# Effect parameters are additional keys
|
||||
intensity: float
|
||||
level: float
|
||||
|
||||
|
||||
class SequenceConfig(TypedDict, total=False):
|
||||
"""Config for SEQUENCE nodes."""
|
||||
transition: Dict[str, Any] # Transition config
|
||||
|
||||
|
||||
class SegmentConfig(TypedDict, total=False):
|
||||
"""Config for SEGMENT nodes."""
|
||||
start: float
|
||||
end: float
|
||||
duration: float
|
||||
|
||||
|
||||
# Union of all config types
|
||||
NodeConfig = Union[SourceConfig, EffectConfig, SequenceConfig, SegmentConfig, Dict[str, Any]]
|
||||
|
||||
|
||||
# === Node Types ===
|
||||
|
||||
class CompiledNode(TypedDict):
|
||||
"""Node as produced by the S-expression compiler."""
|
||||
id: str
|
||||
type: str # "SOURCE", "EFFECT", "SEQUENCE", etc.
|
||||
config: Dict[str, Any]
|
||||
inputs: List[str]
|
||||
name: NotRequired[str]
|
||||
|
||||
|
||||
class TransformedNode(TypedDict):
|
||||
"""Node after transformation for artdag execution."""
|
||||
node_id: str
|
||||
node_type: str
|
||||
config: Dict[str, Any]
|
||||
inputs: List[str]
|
||||
name: NotRequired[str]
|
||||
|
||||
|
||||
# === DAG Types ===
|
||||
|
||||
class CompiledDAG(TypedDict):
|
||||
"""DAG as produced by the S-expression compiler."""
|
||||
nodes: List[CompiledNode]
|
||||
output: str
|
||||
|
||||
|
||||
class TransformedDAG(TypedDict):
|
||||
"""DAG after transformation for artdag execution."""
|
||||
nodes: Dict[str, TransformedNode]
|
||||
output_id: str
|
||||
metadata: NotRequired[Dict[str, Any]]
|
||||
|
||||
|
||||
# === Registry Types ===
|
||||
|
||||
class AssetEntry(TypedDict, total=False):
|
||||
"""Asset in the recipe registry."""
|
||||
cid: str
|
||||
url: str
|
||||
|
||||
|
||||
class EffectEntry(TypedDict, total=False):
|
||||
"""Effect in the recipe registry."""
|
||||
cid: str
|
||||
url: str
|
||||
temporal: bool
|
||||
|
||||
|
||||
class Registry(TypedDict):
|
||||
"""Recipe registry containing assets and effects."""
|
||||
assets: Dict[str, AssetEntry]
|
||||
effects: Dict[str, EffectEntry]
|
||||
|
||||
|
||||
# === Visualization Types ===
|
||||
|
||||
class VisNodeData(TypedDict, total=False):
|
||||
"""Data for a visualization node (Cytoscape.js format)."""
|
||||
id: str
|
||||
label: str
|
||||
nodeType: str
|
||||
isOutput: bool
|
||||
|
||||
|
||||
class VisNode(TypedDict):
|
||||
"""Visualization node wrapper."""
|
||||
data: VisNodeData
|
||||
|
||||
|
||||
class VisEdgeData(TypedDict):
|
||||
"""Data for a visualization edge."""
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class VisEdge(TypedDict):
|
||||
"""Visualization edge wrapper."""
|
||||
data: VisEdgeData
|
||||
|
||||
|
||||
class VisualizationDAG(TypedDict):
|
||||
"""DAG structure for Cytoscape.js visualization."""
|
||||
nodes: List[VisNode]
|
||||
edges: List[VisEdge]
|
||||
|
||||
|
||||
# === Recipe Types ===
|
||||
|
||||
class Recipe(TypedDict, total=False):
|
||||
"""Compiled recipe structure."""
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
owner: str
|
||||
registry: Registry
|
||||
dag: CompiledDAG
|
||||
recipe_id: str
|
||||
ipfs_cid: str
|
||||
sexp: str
|
||||
step_count: int
|
||||
error: str
|
||||
|
||||
|
||||
# === API Request/Response Types ===
|
||||
|
||||
class RecipeRunInputs(TypedDict):
|
||||
"""Mapping of input names to CIDs for recipe execution."""
|
||||
# Keys are input names, values are CIDs
|
||||
pass # Actually just Dict[str, str]
|
||||
|
||||
|
||||
class RunResult(TypedDict, total=False):
|
||||
"""Result of a recipe run."""
|
||||
run_id: str
|
||||
status: str # "pending", "running", "completed", "failed"
|
||||
recipe: str
|
||||
recipe_name: str
|
||||
inputs: List[str]
|
||||
output_cid: str
|
||||
ipfs_cid: str
|
||||
provenance_cid: str
|
||||
error: str
|
||||
created_at: str
|
||||
completed_at: str
|
||||
actor_id: str
|
||||
celery_task_id: str
|
||||
output_name: str
|
||||
|
||||
|
||||
# === Helper functions for type narrowing ===
|
||||
|
||||
def is_source_node(node: TransformedNode) -> bool:
|
||||
"""Check if node is a SOURCE node."""
|
||||
return node.get("node_type") == "SOURCE"
|
||||
|
||||
|
||||
def is_effect_node(node: TransformedNode) -> bool:
|
||||
"""Check if node is an EFFECT node."""
|
||||
return node.get("node_type") == "EFFECT"
|
||||
|
||||
|
||||
def is_variable_input(config: Dict[str, Any]) -> bool:
|
||||
"""Check if a SOURCE node config represents a variable input."""
|
||||
return bool(config.get("input"))
|
||||
|
||||
|
||||
def get_effect_cid(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Get effect CID from config, checking both 'cid' and 'hash' keys."""
|
||||
return config.get("cid") or config.get("hash")
|
||||
0
l1/app/utils/__init__.py
Normal file
0
l1/app/utils/__init__.py
Normal file
84
l1/app/utils/http_signatures.py
Normal file
84
l1/app/utils/http_signatures.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""HTTP Signature verification for incoming AP-style inbox requests.
|
||||
|
||||
Implements the same RSA-SHA256 / PKCS1v15 scheme used by the coop's
|
||||
shared/utils/http_signatures.py, but only the verification side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
|
||||
def verify_request_signature(
|
||||
public_key_pem: str,
|
||||
signature_header: str,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str],
|
||||
) -> bool:
|
||||
"""Verify an incoming HTTP Signature.
|
||||
|
||||
Args:
|
||||
public_key_pem: PEM-encoded public key of the sender.
|
||||
signature_header: Value of the ``Signature`` header.
|
||||
method: HTTP method (GET, POST, etc.).
|
||||
path: Request path (e.g. ``/inbox``).
|
||||
headers: All request headers (case-insensitive keys).
|
||||
|
||||
Returns:
|
||||
True if the signature is valid.
|
||||
"""
|
||||
parts = _parse_signature_header(signature_header)
|
||||
signed_headers = parts.get("headers", "date").split()
|
||||
signature_b64 = parts.get("signature", "")
|
||||
|
||||
# Reconstruct the signed string
|
||||
lc_headers = {k.lower(): v for k, v in headers.items()}
|
||||
lines: list[str] = []
|
||||
for h in signed_headers:
|
||||
if h == "(request-target)":
|
||||
lines.append(f"(request-target): {method.lower()} {path}")
|
||||
else:
|
||||
lines.append(f"{h}: {lc_headers.get(h, '')}")
|
||||
|
||||
signed_string = "\n".join(lines)
|
||||
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
||||
try:
|
||||
public_key.verify(
|
||||
base64.b64decode(signature_b64),
|
||||
signed_string.encode(),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def parse_key_id(signature_header: str) -> str:
|
||||
"""Extract the keyId from a Signature header.
|
||||
|
||||
keyId is typically ``https://domain/users/username#main-key``.
|
||||
Returns the actor URL (strips ``#main-key``).
|
||||
"""
|
||||
parts = _parse_signature_header(signature_header)
|
||||
key_id = parts.get("keyId", "")
|
||||
return re.sub(r"#.*$", "", key_id)
|
||||
|
||||
|
||||
def _parse_signature_header(header: str) -> dict[str, str]:
|
||||
"""Parse a Signature header into its component parts."""
|
||||
parts: dict[str, str] = {}
|
||||
for part in header.split(","):
|
||||
part = part.strip()
|
||||
eq = part.find("=")
|
||||
if eq < 0:
|
||||
continue
|
||||
key = part[:eq]
|
||||
val = part[eq + 1:].strip('"')
|
||||
parts[key] = val
|
||||
return parts
|
||||
Reference in New Issue
Block a user