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 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 19:40:39 +00:00
parent a5a718e387
commit 427de25e13

View File

@@ -96,45 +96,42 @@ class RecipeService:
async def list_recipes(self, actor_id: Optional[str] = None, offset: int = 0, limit: int = 20) -> List[Recipe]: 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 logging
import database
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
recipes = [] recipes = []
if hasattr(self.cache, 'list_by_type'): if not actor_id:
items = self.cache.list_by_type('recipe') logger.warning("list_recipes called without actor_id")
logger.info(f"Found {len(items)} recipe CIDs in cache: {items[:5]}...") return []
for cid in items:
logger.debug(f"Attempting to get recipe {cid[:16]}...") # Get user's recipe CIDs from item_types
recipe = await self.get_recipe(cid) user_items = await database.get_user_items(actor_id, item_type="recipe", limit=1000)
if recipe and not recipe.get("error"): recipe_cids = [item["cid"] for item in user_items]
# Don't trust owner from recipe content - could be spoofed logger.info(f"Found {len(recipe_cids)} recipe CIDs for user {actor_id}")
# For L1, recipes in cache are visible to all authenticated users
# (ownership is tracked via naming service, not recipe content) for cid in recipe_cids:
recipes.append(recipe) recipe = await self.get_recipe(cid)
logger.info(f"Recipe {cid[:16]}... included") if recipe and not recipe.get("error"):
elif recipe and recipe.get("error"): recipes.append(recipe)
logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}") elif recipe and recipe.get("error"):
else: logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}")
logger.warning(f"Recipe {cid[:16]}... returned None")
else:
logger.warning("Cache does not have list_by_type method")
# Add friendly names # Add friendly names
if actor_id: from .naming_service import get_naming_service
from .naming_service import get_naming_service naming = get_naming_service()
naming = get_naming_service() for recipe in recipes:
for recipe in recipes: recipe_id = recipe.get("recipe_id")
recipe_id = recipe.get("recipe_id") if recipe_id:
if recipe_id: friendly = await naming.get_by_cid(actor_id, recipe_id)
friendly = await naming.get_by_cid(actor_id, recipe_id) if friendly:
if friendly: recipe["friendly_name"] = friendly["friendly_name"]
recipe["friendly_name"] = friendly["friendly_name"] recipe["base_name"] = friendly["base_name"]
recipe["base_name"] = friendly["base_name"]
# Sort by name # Sort by name
recipes.sort(key=lambda r: r.get("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) cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True)
recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID
# Assign friendly name # Track ownership in item_types and assign friendly name
if uploader: 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 from .naming_service import get_naming_service
naming = get_naming_service() naming = get_naming_service()
display_name = name or compiled.name or "unnamed-recipe"
await naming.assign_name( await naming.assign_name(
cid=recipe_id, cid=recipe_id,
actor_id=uploader, 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]]: 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). Returns (success, error_message).
""" """
recipe = await self.get_recipe(recipe_id) import database
if not recipe:
return False, "Recipe not found"
# Note: We don't check ownership from recipe content as it could be spoofed. if not actor_id:
# For L1, any authenticated user can delete recipes in the cache. return False, "actor_id required"
# (Ownership tracking should use naming service or cache metadata, not recipe content)
# Delete from cache # Remove user's ownership link
try: try:
if hasattr(self.cache, 'delete_by_cid'): await database.delete_item_type(recipe_id, actor_id, "recipe")
success, msg = self.cache.delete_by_cid(recipe_id)
if not success: # Also remove friendly name
return False, msg await database.delete_friendly_name(actor_id, recipe_id)
else:
path = self.cache.get_by_cid(recipe_id) # Try to garbage collect if no one owns it anymore
if path and path.exists(): # (delete_cache_item only deletes if no item_types remain)
path.unlink() await database.delete_cache_item(recipe_id)
return True, None return True, None
except Exception as e: except Exception as e: