Import L1 (celery) as l1/
This commit is contained in:
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}"
|
||||
Reference in New Issue
Block a user