diff --git a/app/routers/cache.py b/app/routers/cache.py index de678a1..69afa05 100644 --- a/app/routers/cache.py +++ b/app/routers/cache.py @@ -224,9 +224,21 @@ async def upload_content( 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", + filename=file.filename, + ) + return { - "cid": ipfs_cid or cid, + "cid": final_cid, "content_hash": cid, # Legacy, for backwards compatibility + "friendly_name": friendly_entry["friendly_name"], "filename": file.filename, "size": len(content), "uploaded": True, diff --git a/app/routers/effects.py b/app/routers/effects.py index 9bb9a0f..3316ee3 100644 --- a/app/routers/effects.py +++ b/app/routers/effects.py @@ -200,12 +200,24 @@ async def upload_effect( # Also store metadata in IPFS for discoverability meta_cid = ipfs_client.add_json(full_meta) - logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} by {ctx.actor_id}") + # Assign friendly name + 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=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", []), @@ -244,6 +256,15 @@ async def get_effect( 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 @@ -295,6 +316,10 @@ async def list_effects( effects_dir = get_effects_dir() effects = [] + # Get naming service for friendly name lookup + from ..services.naming_service import get_naming_service + naming = get_naming_service() + if effects_dir.exists(): for effect_dir in effects_dir.iterdir(): if effect_dir.is_dir(): @@ -302,6 +327,13 @@ async def list_effects( if metadata_path.exists(): try: meta = json.loads(metadata_path.read_text()) + # Add friendly name if available + cid = meta.get("cid") + if cid: + 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 diff --git a/app/routers/recipes.py b/app/routers/recipes.py index cbab80d..ff4db6e 100644 --- a/app/routers/recipes.py +++ b/app/routers/recipes.py @@ -158,13 +158,59 @@ def bind_inputs( return warnings -def prepare_dag_for_execution( +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") @@ -177,6 +223,11 @@ def prepare_dag_for_execution( # 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 {} @@ -506,10 +557,10 @@ async def run_recipe( # Create run using run service run_service = RunService(database, get_redis_client(), get_cache_manager()) - # Prepare DAG for execution (transform nodes, bind inputs) + # Prepare DAG for execution (transform nodes, bind inputs, resolve friendly names) dag_json = None if recipe.get("dag"): - dag_json, warnings = prepare_dag_for_execution(recipe, req.inputs) + dag_json, warnings = await prepare_dag_for_execution(recipe, req.inputs, actor_id=ctx.actor_id) for warning in warnings: logger.warning(warning) diff --git a/app/services/naming_service.py b/app/services/naming_service.py new file mode 100644 index 0000000..5678ab2 --- /dev/null +++ b/app/services/naming_service.py @@ -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 diff --git a/app/services/recipe_service.py b/app/services/recipe_service.py index 914e2a4..05a53f6 100644 --- a/app/services/recipe_service.py +++ b/app/services/recipe_service.py @@ -124,6 +124,18 @@ class RecipeService: cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True) recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID + # Assign friendly name + if uploader: + from .naming_service import get_naming_service + naming = get_naming_service() + display_name = name or compiled.name or "unnamed-recipe" + 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: diff --git a/app/templates/effects/detail.html b/app/templates/effects/detail.html index fb389bf..a7d9403 100644 --- a/app/templates/effects/detail.html +++ b/app/templates/effects/detail.html @@ -31,8 +31,15 @@
{{ meta.description }}
{% endif %} - +{{ effect.friendly_name }}
+Use in recipes: (effect {{ effect.base_name }})