fix: truncate slugs when too long (#5633)

This commit is contained in:
Hayden 2025-07-04 15:43:53 -05:00 committed by GitHub
parent e794c6b525
commit c9e22892a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 69 additions and 8 deletions

View file

@ -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

View file

@ -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):

View file

@ -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,

View file

@ -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