From 427de25e134e3f4159b2aeee5fb1a6a5583b5c22 Mon Sep 17 00:00:00 2001 From: gilesb Date: Mon, 12 Jan 2026 19:40:39 +0000 Subject: [PATCH] Fix recipe ownership tracking via item_types table - Upload now creates item_types entry linking user to recipe - List queries item_types for user's recipes (not all cached) - Delete removes item_types entry (not the file) - File only deleted when no users own it (garbage collection) This allows multiple users to "own" the same recipe CID. Co-Authored-By: Claude Opus 4.5 --- app/services/recipe_service.py | 105 ++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/app/services/recipe_service.py b/app/services/recipe_service.py index 2ad7f37..c7f855c 100644 --- a/app/services/recipe_service.py +++ b/app/services/recipe_service.py @@ -96,45 +96,42 @@ class RecipeService: async def list_recipes(self, actor_id: Optional[str] = None, offset: int = 0, limit: int = 20) -> List[Recipe]: """ - List available recipes for a user. + List recipes owned by a user. - L1 data is isolated per-user - only shows recipes owned by actor_id. + Queries item_types table for user's recipe links. """ import logging + import database logger = logging.getLogger(__name__) recipes = [] - if hasattr(self.cache, 'list_by_type'): - items = self.cache.list_by_type('recipe') - logger.info(f"Found {len(items)} recipe CIDs in cache: {items[:5]}...") - for cid in items: - logger.debug(f"Attempting to get recipe {cid[:16]}...") - recipe = await self.get_recipe(cid) - if recipe and not recipe.get("error"): - # Don't trust owner from recipe content - could be spoofed - # For L1, recipes in cache are visible to all authenticated users - # (ownership is tracked via naming service, not recipe content) - recipes.append(recipe) - logger.info(f"Recipe {cid[:16]}... included") - elif recipe and recipe.get("error"): - logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}") - else: - logger.warning(f"Recipe {cid[:16]}... returned None") - else: - logger.warning("Cache does not have list_by_type method") + 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 - if actor_id: - 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"] + 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", "")) @@ -172,11 +169,23 @@ 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 + # 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() - display_name = name or compiled.name or "unnamed-recipe" await naming.assign_name( cid=recipe_id, actor_id=uploader, @@ -191,29 +200,27 @@ class RecipeService: async def delete_recipe(self, recipe_id: str, actor_id: str = None) -> Tuple[bool, Optional[str]]: """ - Delete a recipe. + Remove user's ownership link to a recipe. - Note: This only removes from local cache. IPFS copies persist. + 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). """ - recipe = await self.get_recipe(recipe_id) - if not recipe: - return False, "Recipe not found" + import database - # Note: We don't check ownership from recipe content as it could be spoofed. - # For L1, any authenticated user can delete recipes in the cache. - # (Ownership tracking should use naming service or cache metadata, not recipe content) + if not actor_id: + return False, "actor_id required" - # Delete from cache + # Remove user's ownership link try: - if hasattr(self.cache, 'delete_by_cid'): - success, msg = self.cache.delete_by_cid(recipe_id) - if not success: - return False, msg - else: - path = self.cache.get_by_cid(recipe_id) - if path and path.exists(): - path.unlink() + 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: