mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	fix: truncate slugs when too long (#5633)
This commit is contained in:
		| @@ -7,7 +7,6 @@ from uuid import UUID | |||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| from fastapi import HTTPException | from fastapi import HTTPException | ||||||
| from pydantic import UUID4 | from pydantic import UUID4 | ||||||
| from slugify import slugify |  | ||||||
| from sqlalchemy import orm | from sqlalchemy import orm | ||||||
| from sqlalchemy.exc import IntegrityError | 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.db.models.users.users import User | ||||||
| from mealie.schema.cookbook.cookbook import ReadCookBook | from mealie.schema.cookbook.cookbook import ReadCookBook | ||||||
| from mealie.schema.recipe import Recipe | 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_ingredient import IngredientFood | ||||||
| from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem | from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem | ||||||
| from mealie.schema.recipe.recipe_tool import RecipeToolOut | from mealie.schema.recipe.recipe_tool import RecipeToolOut | ||||||
| @@ -98,7 +97,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): | |||||||
|             except IntegrityError: |             except IntegrityError: | ||||||
|                 self.session.rollback() |                 self.session.rollback() | ||||||
|                 document.name = f"{original_name} ({i})" |                 document.name = f"{original_name} ({i})" | ||||||
|                 document.slug = slugify(document.name) |                 document.slug = create_recipe_slug(document.name) | ||||||
|  |  | ||||||
|                 if i >= max_retries: |                 if i >= max_retries: | ||||||
|                     raise |                     raise | ||||||
|   | |||||||
| @@ -36,6 +36,22 @@ from .recipe_step import RecipeStep | |||||||
| app_dirs = get_app_dirs() | 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): | class RecipeTag(MealieModel): | ||||||
|     id: UUID4 | None = None |     id: UUID4 | None = None | ||||||
|     group_id: UUID4 | None = None |     group_id: UUID4 | None = None | ||||||
| @@ -229,7 +245,7 @@ class Recipe(RecipeSummary): | |||||||
|         if not info.data.get("name"): |         if not info.data.get("name"): | ||||||
|             return slug |             return slug | ||||||
|  |  | ||||||
|         return slugify(info.data["name"]) |         return create_recipe_slug(info.data["name"]) | ||||||
|  |  | ||||||
|     @field_validator("recipe_ingredient", mode="before") |     @field_validator("recipe_ingredient", mode="before") | ||||||
|     def validate_ingredients(recipe_ingredient): |     def validate_ingredients(recipe_ingredient): | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ from uuid import UUID, uuid4 | |||||||
| from zipfile import ZipFile | from zipfile import ZipFile | ||||||
|  |  | ||||||
| from fastapi import UploadFile | from fastapi import UploadFile | ||||||
| from slugify import slugify |  | ||||||
|  |  | ||||||
| from mealie.core import exceptions | from mealie.core import exceptions | ||||||
| from mealie.core.config import get_app_settings | 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.repos.repository_generic import RepositoryGeneric | ||||||
| from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate | from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate | ||||||
| from mealie.schema.openai.recipe import OpenAIRecipe | 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_ingredient import RecipeIngredient | ||||||
| from mealie.schema.recipe.recipe_notes import RecipeNote | from mealie.schema.recipe.recipe_notes import RecipeNote | ||||||
| from mealie.schema.recipe.recipe_settings import RecipeSettings | 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_name = dup_data.name if dup_data.name else old_recipe.name or "" | ||||||
|         new_recipe.id = uuid4() |         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.image = cache.cache_key.new_key() if old_recipe.image else None | ||||||
|         new_recipe.recipe_instructions = ( |         new_recipe.recipe_instructions = ( | ||||||
|             None |             None | ||||||
| @@ -447,7 +446,7 @@ class OpenAIRecipeService(RecipeServiceBase): | |||||||
|             group_id=self.user.group_id, |             group_id=self.user.group_id, | ||||||
|             household_id=self.household.id, |             household_id=self.household.id, | ||||||
|             name=openai_recipe.name, |             name=openai_recipe.name, | ||||||
|             slug=slugify(openai_recipe.name), |             slug=create_recipe_slug(openai_recipe.name), | ||||||
|             description=openai_recipe.description, |             description=openai_recipe.description, | ||||||
|             recipe_yield=openai_recipe.recipe_yield, |             recipe_yield=openai_recipe.recipe_yield, | ||||||
|             total_time=openai_recipe.total_time, |             total_time=openai_recipe.total_time, | ||||||
|   | |||||||
| @@ -900,3 +900,50 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse | |||||||
|         assert recipe.id in fetched_recipe_ids |         assert recipe.id in fetched_recipe_ids | ||||||
|     for recipe in other_recipes: |     for recipe in other_recipes: | ||||||
|         assert recipe.id not in fetched_recipe_ids |         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 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user