diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 64a153c83..7250e681b 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -7,7 +7,6 @@ from uuid import UUID import sqlalchemy as sa from fastapi import HTTPException from pydantic import UUID4 -from slugify import slugify from sqlalchemy import orm from sqlalchemy.exc import IntegrityError @@ -22,7 +21,7 @@ from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.users import User from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.recipe import Recipe -from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary +from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, create_recipe_slug from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem from mealie.schema.recipe.recipe_tool import RecipeToolOut @@ -98,7 +97,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): except IntegrityError: self.session.rollback() document.name = f"{original_name} ({i})" - document.slug = slugify(document.name) + document.slug = create_recipe_slug(document.name) if i >= max_retries: raise diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 9cf1db0d5..b6c96e098 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -36,6 +36,22 @@ from .recipe_step import RecipeStep app_dirs = get_app_dirs() +def create_recipe_slug(name: str, max_length: int = 250) -> str: + """Generate a slug from a recipe name, truncating to a reasonable length. + + Args: + name: The recipe name to create a slug from + max_length: Maximum length for the slug (default: 250) + + Returns: + A truncated slug string + """ + generated_slug = slugify(name) + if len(generated_slug) > max_length: + generated_slug = generated_slug[:max_length] + return generated_slug + + class RecipeTag(MealieModel): id: UUID4 | None = None group_id: UUID4 | None = None @@ -229,7 +245,7 @@ class Recipe(RecipeSummary): if not info.data.get("name"): return slug - return slugify(info.data["name"]) + return create_recipe_slug(info.data["name"]) @field_validator("recipe_ingredient", mode="before") def validate_ingredients(recipe_ingredient): diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 0e995bab7..7ca2bb150 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -9,7 +9,6 @@ from uuid import UUID, uuid4 from zipfile import ZipFile from fastapi import UploadFile -from slugify import slugify from mealie.core import exceptions from mealie.core.config import get_app_settings @@ -21,7 +20,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_generic import RepositoryGeneric from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate from mealie.schema.openai.recipe import OpenAIRecipe -from mealie.schema.recipe.recipe import CreateRecipe, Recipe +from mealie.schema.recipe.recipe import CreateRecipe, Recipe, create_recipe_slug from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_notes import RecipeNote from mealie.schema.recipe.recipe_settings import RecipeSettings @@ -332,7 +331,7 @@ class RecipeService(RecipeServiceBase): new_name = dup_data.name if dup_data.name else old_recipe.name or "" new_recipe.id = uuid4() - new_recipe.slug = slugify(new_name) + new_recipe.slug = create_recipe_slug(new_name) new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None new_recipe.recipe_instructions = ( None @@ -447,7 +446,7 @@ class OpenAIRecipeService(RecipeServiceBase): group_id=self.user.group_id, household_id=self.household.id, name=openai_recipe.name, - slug=slugify(openai_recipe.name), + slug=create_recipe_slug(openai_recipe.name), description=openai_recipe.description, recipe_yield=openai_recipe.recipe_yield, total_time=openai_recipe.total_time, diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index f675d6da2..b87200fc6 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -900,3 +900,50 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse assert recipe.id in fetched_recipe_ids for recipe in other_recipes: assert recipe.id not in fetched_recipe_ids + + +def test_create_recipe_with_extremely_long_slug(api_client: TestClient, unique_user: TestUser): + """Test creating a recipe with an extremely long name that would generate a very long slug. + This reproduces the issue where long slugs cause 500 internal server errors. + """ + # Create a recipe name that's extremely long like the one in the GitHub issue + long_recipe_name = "giallozafferano-on-instagram-il-piatto-vincente-di-simone-barlaam-medaglia-d-oro-e-d-argento-a-parigi-2024-paccheri-tricolore-se-ve-li-siete-persi-dovete-assolutamente-rimediare-lulugargari-ingredienti-paccheri-320-gr-spinacini-500-gr-nocciole-50-gr-ricotta-350-gr-olio-evo-q-b-limone-non-trattato-con-buccia-edibile-q-b-menta-q-b-peperoncino-fresco-q-b-10-pomodorini-ciliegino-preparazione-saltiamo-gli-spinaci-in-padella-lasciamo-raffreddare-e-frulliamo-insieme-a-ricotta-olio-sale-pepe-e-peperoncino-fresco-cuociamo-la-pasta-al-dente-e-mantechiamo-fuori-dal-fuoco-con-la-crema-tostiamo-a-parte-noci-o-nocciole-e-frulliamo-con-scorza-di-limone-impiattiamo-i-paccheri-con-qualche-spinacino-fresco-ciuffetti-di-ricotta-pomodorini-tagliati-in-4-e-la-polvere-di-nocciole-e-limone-buon-appetito-dmtc-pr-finp-nuotoparalimpico-giallozafferano-ricette-olimpiadi-paralimpiadi-atleti-simonebarlaam-cucina-paccheri-pasta-spinaci" # noqa: E501 + + # Create the recipe + response = api_client.post(api_routes.recipes, json={"name": long_recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + created_slug = json.loads(response.text) + + assert created_slug is not None + assert len(created_slug) > 0 + + new_name = "Pasta" + response = api_client.patch( + api_routes.recipes_slug(created_slug), json={"name": new_name}, headers=unique_user.token + ) + + assert response.status_code == 200 + + updated_recipe = json.loads(response.text) + assert updated_recipe["name"] == new_name + assert updated_recipe["slug"] == slugify(new_name) + + +def test_create_recipe_slug_length_validation(api_client: TestClient, unique_user: TestUser): + """Test that recipe slugs are properly truncated to a reasonable length.""" + very_long_name = "A" * 500 # 500 character name + + response = api_client.post(api_routes.recipes, json={"name": very_long_name}, headers=unique_user.token) + assert response.status_code == 201 + + created_slug = json.loads(response.text) + + # The slug should be truncated to a reasonable length + # Using 250 characters as a reasonable limit, leaving room for collision suffixes + assert len(created_slug) <= 250 + + assert created_slug is not None + assert len(created_slug) > 0 + + response = api_client.get(api_routes.recipes_slug(created_slug), headers=unique_user.token) + assert response.status_code == 200