Complete L1 router and template migration

- Full implementation of runs, recipes, cache routers with templates
- Auth and storage routers fully migrated
- Jinja2 templates for all L1 pages
- Service layer for auth and storage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-01-11 07:46:15 +00:00
parent 383dbf6e03
commit 022f88bf0c
20 changed files with 2771 additions and 135 deletions

View File

@@ -0,0 +1,141 @@
"""
Auth Service - token management and user verification.
"""
import hashlib
import base64
import json
from typing import Optional
from dataclasses import dataclass
import httpx
from ..config import settings
# 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:"
@dataclass
class UserContext:
"""User context from token."""
username: str
actor_id: str
token: Optional[str] = None
class AuthService:
"""Service for authentication and token management."""
def __init__(self, redis_client):
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]:
"""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,
)
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) -> 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)

View File

@@ -0,0 +1,228 @@
"""
Storage Service - business logic for storage provider management.
"""
import json
from typing import Optional, List, Dict, Any
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, storage_providers_module):
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], 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}"