From c41a4a52ed9475bbe5043e2488ad0a573dc73a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20D=C5=BEoi=C4=87?= <37804613+mariodz95@users.noreply.github.com> Date: Sat, 16 Aug 2025 10:41:46 +0200 Subject: [PATCH] fix: error when trying to change recipe image (#5771) Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com> --- .../RecipePageInstructions.vue | 4 +- frontend/composables/api/static-routes.ts | 18 +++---- mealie/repos/repository_generic.py | 7 ++- mealie/routes/recipe/recipe_crud_routes.py | 20 ++++---- mealie/services/recipe/recipe_service.py | 10 ++++ .../test_recipe_cross_household.py | 51 +++++++++++++++++++ .../user_recipe_tests/test_recipe_owner.py | 31 +++++++++++ 7 files changed, 115 insertions(+), 26 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue index 0eb71ceae..2867b9d62 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInstructions.vue @@ -390,8 +390,6 @@ const props = defineProps({ const emit = defineEmits(["click-instruction-field", "update:assets"]); -const BASE_URL = useRequestURL().origin; - const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug); const dialog = ref(false); @@ -695,7 +693,7 @@ async function handleImageDrop(index: number, files: File[]) { } emit("update:assets", [...assets.value, data]); - const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string); + const assetUrl = recipeAssetPath(props.recipe.id, data.fileName as string); const text = ``; instructionList.value[index].text += text; } diff --git a/frontend/composables/api/static-routes.ts b/frontend/composables/api/static-routes.ts index 3b65d183d..31eda446e 100644 --- a/frontend/composables/api/static-routes.ts +++ b/frontend/composables/api/static-routes.ts @@ -4,43 +4,39 @@ function UnknownToString(ukn: string | unknown) { export const useStaticRoutes = () => { const { $config } = useNuxtApp(); - const serverBase = useRequestURL().origin; - const prefix = `${$config.public.SUB_PATH}/api`.replace("//", "/"); - const fullBase = serverBase + prefix; - // Methods to Generate reference urls for assets/images * function recipeImage(recipeId: string, version: string | unknown = "", key: string | number = 1) { - return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${UnknownToString(version)}`; + return `${prefix}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${UnknownToString(version)}`; } function recipeSmallImage(recipeId: string, version: string | unknown = "", key: string | number = 1) { - return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${UnknownToString( + return `${prefix}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${UnknownToString( version, )}`; } function recipeTinyImage(recipeId: string, version: string | unknown = "", key: string | number = 1) { - return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${UnknownToString( + return `${prefix}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${UnknownToString( version, )}`; } function recipeTimelineEventImage(recipeId: string, timelineEventId: string) { - return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`; + return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`; } function recipeTimelineEventSmallImage(recipeId: string, timelineEventId: string) { - return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`; + return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`; } function recipeTimelineEventTinyImage(recipeId: string, timelineEventId: string) { - return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`; + return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`; } function recipeAssetPath(recipeId: string, assetName: string) { - return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`; + return `${prefix}/media/recipes/${recipeId}/assets/${assetName}`; } return { diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index f8ab3f1b4..e0c9e5d8f 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -85,7 +85,6 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]: def _filter_builder(self, **kwargs) -> dict[str, Any]: dct = {} - if self.group_id: dct["group_id"] = self.group_id if self.household_id: @@ -146,7 +145,11 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]: return self.session.execute(self._query().filter_by(**fltr)).unique().scalars().one() def get_one( - self, value: str | int | UUID4, key: str | None = None, any_case=False, override_schema=None + self, + value: str | int | UUID4, + key: str | None = None, + any_case=False, + override_schema=None, ) -> Schema | None: key = key or self.primary_key eff_schema = override_schema or self.schema diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 0504966c8..c5946c49b 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -523,12 +523,12 @@ class RecipeController(BaseRecipeController): @router.put("/{slug}/image", response_model=UpdateImageResponse, tags=["Recipe: Images and Assets"]) def update_recipe_image(self, slug: str, image: bytes = File(...), extension: str = Form(...)): - recipe = self.mixins.get_one(slug) - data_service = RecipeDataService(recipe.id) - data_service.write_image(image, extension) - - new_version = self.recipes.update_image(slug, extension) - return UpdateImageResponse(image=new_version) + try: + new_version = self.service.update_recipe_image(slug, image, extension) + return UpdateImageResponse(image=new_version) + except Exception as e: + self.handle_exceptions(e) + return None @router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"]) def upload_recipe_asset( @@ -550,7 +550,7 @@ class RecipeController(BaseRecipeController): file_name = f"{file_slug}.{extension}" asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) - recipe = self.mixins.get_one(slug) + recipe = self.service.get_one(slug) dest = recipe.asset_dir / file_name @@ -567,9 +567,9 @@ class RecipeController(BaseRecipeController): if not dest.is_file(): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) - recipe = self.mixins.get_one(slug) - recipe.assets.append(asset_in) + if recipe.assets is not None: + recipe.assets.append(asset_in) - self.mixins.update_one(recipe, slug) + self.service.update_one(slug, recipe) return asset_in diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 42d4f1e88..381793eb3 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -408,6 +408,16 @@ class RecipeService(RecipeServiceBase): self.check_assets(new_data, recipe.slug) return new_data + def update_recipe_image(self, slug: str, image: bytes, extension: str): + recipe = self.get_one(slug) + if not self.can_update(recipe): + raise exceptions.PermissionDenied("You do not have permission to edit this recipe.") + + data_service = RecipeDataService(recipe.id) + data_service.write_image(image, extension) + + return self.group_recipes.update_image(slug, extension) + def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe: recipe: Recipe = self._pre_update_check(slug_or_id, patch_data) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py index 4334c0e55..5b649e151 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_cross_household.py @@ -7,6 +7,7 @@ from fastapi.testclient import TestClient from mealie.schema.cookbook.cookbook import SaveCookBook from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe_category import TagSave +from tests import data from tests.utils import api_routes from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -371,3 +372,53 @@ def test_cookbooks_from_other_households(api_client: TestClient, unique_user: Te response = api_client.get(api_routes.recipes, params={"cookbook": h2_cookbook.slug}, headers=unique_user.token) assert response.status_code == 200 + + +@pytest.mark.parametrize("is_private_household", [True, False]) +@pytest.mark.parametrize("household_lock_recipe_edits", [True, False]) +def test_update_recipe_image_from_other_households( + api_client: TestClient, + unique_user: TestUser, + h2_user: TestUser, + is_private_household: bool, + household_lock_recipe_edits: bool, +): + household = unique_user.repos.households.get_one(h2_user.household_id) + assert household and household.preferences + household.preferences.private_household = is_private_household + household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits + unique_user.repos.household_preferences.update(household.id, household.preferences) + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token) + assert response.status_code == 201 + h2_recipe = h2_user.repos.recipes.get_one(response.json()) + assert h2_recipe and h2_recipe.id + h2_recipe_id = str(h2_recipe.id) + + response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token) + assert response.status_code == 200 + recipe_json = response.json() + assert recipe_json["id"] == h2_recipe_id + image_version = response.json()["image"] + + data_payload = {"extension": "jpg"} + file_payload = {"image": data.images_test_image_1.read_bytes()} + + response = api_client.put( + api_routes.recipes_slug_image(recipe_json["slug"]), + data=data_payload, + files=file_payload, + headers=unique_user.token, + ) + + if household_lock_recipe_edits: + assert response.status_code == 403 + response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] == image_version + + else: + assert response.status_code == 200 + response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] is not None diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index f1b1c1ec9..3bfe0191a 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe_settings import RecipeSettings +from tests import data from tests.utils import api_routes from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -180,3 +181,33 @@ def test_admin_can_delete_locked_recipe_owned_by_another_user( response = api_client.delete(api_routes.recipes_slug(slug), headers=admin_user.token) assert response.status_code == 200 + + +def test_user_can_update_recipe_image(api_client: TestClient, unique_user: TestUser): + data_payload = {"extension": "jpg"} + file_payload = {"image": data.images_test_image_1.read_bytes()} + + household = unique_user.repos.households.get_one(unique_user.household_id) + assert household and household.preferences + household.preferences.private_household = True + household.preferences.lock_recipe_edits_from_other_households = True + unique_user.repos.household_preferences.update(household.id, household.preferences) + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + assert response.status_code == 201 + recipe_json = unique_user.repos.recipes.get_one(response.json()) + assert recipe_json and recipe_json.id + assert recipe_json.image is None + recipe_id = str(recipe_json.id) + + response = api_client.put( + api_routes.recipes_slug_image(recipe_json.slug), + data=data_payload, + files=file_payload, + headers=unique_user.token, + ) + assert response.status_code == 200 + + response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] is not None