mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	fix: Fix file not found error with individual recipe export/download. (#3579)
This commit is contained in:
		| @@ -1,12 +1,13 @@ | |||||||
| import shutil |  | ||||||
| import tempfile | import tempfile | ||||||
| from collections.abc import AsyncGenerator, Callable, Generator | from collections.abc import Callable, Generator | ||||||
|  | from contextlib import contextmanager | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  | from shutil import rmtree | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| import fastapi | import fastapi | ||||||
| import jwt | import jwt | ||||||
| from fastapi import BackgroundTasks, Depends, HTTPException, Request, status | from fastapi import Depends, HTTPException, Request, status | ||||||
| from fastapi.security import OAuth2PasswordBearer | from fastapi.security import OAuth2PasswordBearer | ||||||
| from jwt.exceptions import PyJWTError | from jwt.exceptions import PyJWTError | ||||||
| from sqlalchemy.orm.session import Session | from sqlalchemy.orm.session import Session | ||||||
| @@ -205,24 +206,26 @@ def validate_recipe_token(token: str | None = None) -> str: | |||||||
|     return slug |     return slug | ||||||
|  |  | ||||||
|  |  | ||||||
| async def temporary_zip_path() -> AsyncGenerator[Path, None]: | @contextmanager | ||||||
|  | def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]: | ||||||
|     app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True) |     app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True) | ||||||
|     temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip") |     temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip") | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         yield temp_path |         yield temp_path | ||||||
|     finally: |     finally: | ||||||
|         temp_path.unlink(missing_ok=True) |         if auto_unlink: | ||||||
|  |             temp_path.unlink(missing_ok=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def temporary_dir(background_tasks: BackgroundTasks) -> AsyncGenerator[Path, None]: | @contextmanager | ||||||
|  | def get_temporary_path(auto_unlink=True) -> Generator[Path, None, None]: | ||||||
|     temp_path = app_dirs.TEMP_DIR.joinpath(uuid4().hex) |     temp_path = app_dirs.TEMP_DIR.joinpath(uuid4().hex) | ||||||
|     temp_path.mkdir(exist_ok=True, parents=True) |     temp_path.mkdir(exist_ok=True, parents=True) | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         yield temp_path |         yield temp_path | ||||||
|     finally: |     finally: | ||||||
|         background_tasks.add_task(shutil.rmtree, temp_path) |         if auto_unlink: | ||||||
|  |             rmtree(temp_path) | ||||||
|  |  | ||||||
|  |  | ||||||
| def temporary_file(ext: str = "") -> Callable[[], Generator[tempfile._TemporaryFileWrapper, None, None]]: | def temporary_file(ext: str = "") -> Callable[[], Generator[tempfile._TemporaryFileWrapper, None, None]]: | ||||||
|   | |||||||
| @@ -1,10 +1,9 @@ | |||||||
| import shutil | import shutil | ||||||
| from pathlib import Path |  | ||||||
|  |  | ||||||
| from fastapi import Depends, File, Form | from fastapi import File, Form | ||||||
| from fastapi.datastructures import UploadFile | from fastapi.datastructures import UploadFile | ||||||
|  |  | ||||||
| from mealie.core.dependencies import temporary_zip_path | from mealie.core.dependencies import get_temporary_zip_path | ||||||
| from mealie.routes._base import BaseUserController, controller | from mealie.routes._base import BaseUserController, controller | ||||||
| from mealie.routes._base.routers import UserAPIRouter | from mealie.routes._base.routers import UserAPIRouter | ||||||
| from mealie.schema.group.group_migration import SupportedMigrations | from mealie.schema.group.group_migration import SupportedMigrations | ||||||
| @@ -32,38 +31,39 @@ class GroupMigrationController(BaseUserController): | |||||||
|         add_migration_tag: bool = Form(False), |         add_migration_tag: bool = Form(False), | ||||||
|         migration_type: SupportedMigrations = Form(...), |         migration_type: SupportedMigrations = Form(...), | ||||||
|         archive: UploadFile = File(...), |         archive: UploadFile = File(...), | ||||||
|         temp_path: Path = Depends(temporary_zip_path), |  | ||||||
|     ): |     ): | ||||||
|         # Save archive to temp_path |         with get_temporary_zip_path() as temp_path: | ||||||
|         with temp_path.open("wb") as buffer: |             # Save archive to temp_path | ||||||
|             shutil.copyfileobj(archive.file, buffer) |             with temp_path.open("wb") as buffer: | ||||||
|  |                 shutil.copyfileobj(archive.file, buffer) | ||||||
|  |  | ||||||
|         args = { |             args = { | ||||||
|             "archive": temp_path, |                 "archive": temp_path, | ||||||
|             "db": self.repos, |                 "db": self.repos, | ||||||
|             "session": self.session, |                 "session": self.session, | ||||||
|             "user_id": self.user.id, |                 "user_id": self.user.id, | ||||||
|             "group_id": self.group_id, |                 "group_id": self.group_id, | ||||||
|             "add_migration_tag": add_migration_tag, |                 "add_migration_tag": add_migration_tag, | ||||||
|             "translator": self.translator, |                 "translator": self.translator, | ||||||
|         } |             } | ||||||
|  |  | ||||||
|         table: dict[SupportedMigrations, type[BaseMigrator]] = { |             table: dict[SupportedMigrations, type[BaseMigrator]] = { | ||||||
|             SupportedMigrations.chowdown: ChowdownMigrator, |                 SupportedMigrations.chowdown: ChowdownMigrator, | ||||||
|             SupportedMigrations.copymethat: CopyMeThatMigrator, |                 SupportedMigrations.copymethat: CopyMeThatMigrator, | ||||||
|             SupportedMigrations.mealie_alpha: MealieAlphaMigrator, |                 SupportedMigrations.mealie_alpha: MealieAlphaMigrator, | ||||||
|             SupportedMigrations.nextcloud: NextcloudMigrator, |                 SupportedMigrations.nextcloud: NextcloudMigrator, | ||||||
|             SupportedMigrations.paprika: PaprikaMigrator, |                 SupportedMigrations.paprika: PaprikaMigrator, | ||||||
|             SupportedMigrations.tandoor: TandoorMigrator, |                 SupportedMigrations.tandoor: TandoorMigrator, | ||||||
|             SupportedMigrations.plantoeat: PlanToEatMigrator, |                 SupportedMigrations.plantoeat: PlanToEatMigrator, | ||||||
|             SupportedMigrations.myrecipebox: MyRecipeBoxMigrator, |                 SupportedMigrations.myrecipebox: MyRecipeBoxMigrator, | ||||||
|         } |             } | ||||||
|  |  | ||||||
|         constructor = table.get(migration_type, None) |             constructor = table.get(migration_type, None) | ||||||
|  |  | ||||||
|         if constructor is None: |             if constructor is None: | ||||||
|             raise ValueError(f"Unsupported migration type: {migration_type}") |                 raise ValueError(f"Unsupported migration type: {migration_type}") | ||||||
|  |  | ||||||
|         migrator = constructor(**args) |             migrator = constructor(**args) | ||||||
|  |  | ||||||
|         return migrator.migrate(f"{migration_type.value.title()} Migration") |             migration_result = migrator.migrate(f"{migration_type.value.title()} Migration") | ||||||
|  |         return migration_result | ||||||
|   | |||||||
| @@ -1,9 +1,9 @@ | |||||||
| from functools import cached_property | from functools import cached_property | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from fastapi import APIRouter, Depends, HTTPException | from fastapi import APIRouter, HTTPException | ||||||
|  |  | ||||||
| from mealie.core.dependencies.dependencies import temporary_zip_path | from mealie.core.dependencies.dependencies import get_temporary_zip_path | ||||||
| from mealie.core.security import create_file_token | from mealie.core.security import create_file_token | ||||||
| from mealie.routes._base import BaseUserController, controller | from mealie.routes._base import BaseUserController, controller | ||||||
| from mealie.schema.group.group_exports import GroupDataExport | from mealie.schema.group.group_exports import GroupDataExport | ||||||
| @@ -44,8 +44,9 @@ class RecipeBulkActionsController(BaseUserController): | |||||||
|         self.service.delete_recipes(delete_recipes.recipes) |         self.service.delete_recipes(delete_recipes.recipes) | ||||||
|  |  | ||||||
|     @router.post("/export", status_code=202) |     @router.post("/export", status_code=202) | ||||||
|     def bulk_export_recipes(self, export_recipes: ExportRecipes, temp_path=Depends(temporary_zip_path)): |     def bulk_export_recipes(self, export_recipes: ExportRecipes): | ||||||
|         self.service.export_recipes(temp_path, export_recipes.recipes) |         with get_temporary_zip_path() as temp_path: | ||||||
|  |             self.service.export_recipes(temp_path, export_recipes.recipes) | ||||||
|  |  | ||||||
|     @router.get("/export/download") |     @router.get("/export/download") | ||||||
|     def get_exported_data_token(self, path: Path): |     def get_exported_data_token(self, path: Path): | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| from functools import cached_property | from functools import cached_property | ||||||
| from shutil import copyfileobj | from shutil import copyfileobj, rmtree | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
| from zipfile import ZipFile | from zipfile import ZipFile | ||||||
|  |  | ||||||
| @@ -10,11 +10,11 @@ from fastapi.datastructures import UploadFile | |||||||
| from fastapi.responses import JSONResponse | from fastapi.responses import JSONResponse | ||||||
| from pydantic import UUID4, BaseModel, Field | from pydantic import UUID4, BaseModel, Field | ||||||
| from slugify import slugify | from slugify import slugify | ||||||
|  | from starlette.background import BackgroundTask | ||||||
| from starlette.responses import FileResponse | from starlette.responses import FileResponse | ||||||
|  |  | ||||||
| from mealie.core import exceptions | from mealie.core import exceptions | ||||||
| from mealie.core.dependencies import temporary_zip_path | from mealie.core.dependencies import get_temporary_path, get_temporary_zip_path, validate_recipe_token | ||||||
| from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token |  | ||||||
| from mealie.core.security import create_recipe_slug_token | from mealie.core.security import create_recipe_slug_token | ||||||
| from mealie.db.models.group.cookbook import CookBook | from mealie.db.models.group.cookbook import CookBook | ||||||
| from mealie.pkgs import cache | from mealie.pkgs import cache | ||||||
| @@ -103,7 +103,7 @@ class RecipeExportController(BaseRecipeController): | |||||||
|         return RecipeZipTokenResponse(token=create_recipe_slug_token(slug)) |         return RecipeZipTokenResponse(token=create_recipe_slug_token(slug)) | ||||||
|  |  | ||||||
|     @router_exports.get("/{slug}/exports", response_class=FileResponse) |     @router_exports.get("/{slug}/exports", response_class=FileResponse) | ||||||
|     def get_recipe_as_format(self, slug: str, template_name: str, temp_dir=Depends(temporary_dir)): |     def get_recipe_as_format(self, slug: str, template_name: str): | ||||||
|         """ |         """ | ||||||
|         ## Parameters |         ## Parameters | ||||||
|         `template_name`: The name of the template to use to use in the exports listed. Template type will automatically |         `template_name`: The name of the template to use to use in the exports listed. Template type will automatically | ||||||
| @@ -111,27 +111,31 @@ class RecipeExportController(BaseRecipeController): | |||||||
|         names and formats in the /api/recipes/exports endpoint. |         names and formats in the /api/recipes/exports endpoint. | ||||||
|  |  | ||||||
|         """ |         """ | ||||||
|         recipe = self.mixins.get_one(slug) |         with get_temporary_path(auto_unlink=False) as temp_path: | ||||||
|         file = self.service.render_template(recipe, temp_dir, template_name) |             recipe = self.mixins.get_one(slug) | ||||||
|         return FileResponse(file) |             file = self.service.render_template(recipe, temp_path, template_name) | ||||||
|  |             return FileResponse(file, background=BackgroundTask(rmtree, temp_path)) | ||||||
|  |  | ||||||
|     @router_exports.get("/{slug}/exports/zip") |     @router_exports.get("/{slug}/exports/zip") | ||||||
|     def get_recipe_as_zip(self, slug: str, token: str, temp_path=Depends(temporary_zip_path)): |     def get_recipe_as_zip(self, slug: str, token: str): | ||||||
|         """Get a Recipe and It's Original Image as a Zip File""" |         """Get a Recipe and Its Original Image as a Zip File""" | ||||||
|         slug = validate_recipe_token(token) |         with get_temporary_zip_path(auto_unlink=False) as temp_path: | ||||||
|  |             validated_slug = validate_recipe_token(token) | ||||||
|  |  | ||||||
|         if slug != slug: |             if validated_slug != slug: | ||||||
|             raise HTTPException(status_code=400, detail="Invalid Slug") |                 raise HTTPException(status_code=400, detail="Invalid Slug") | ||||||
|  |  | ||||||
|         recipe: Recipe = self.mixins.get_one(slug) |             recipe: Recipe = self.mixins.get_one(validated_slug) | ||||||
|         image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) |             image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) | ||||||
|         with ZipFile(temp_path, "w") as myzip: |             with ZipFile(temp_path, "w") as myzip: | ||||||
|             myzip.writestr(f"{slug}.json", recipe.model_dump_json()) |                 myzip.writestr(f"{slug}.json", recipe.model_dump_json()) | ||||||
|  |  | ||||||
|             if image_asset.is_file(): |                 if image_asset.is_file(): | ||||||
|                 myzip.write(image_asset, arcname=image_asset.name) |                     myzip.write(image_asset, arcname=image_asset.name) | ||||||
|  |  | ||||||
|         return FileResponse(temp_path, filename=f"{slug}.zip") |             return FileResponse( | ||||||
|  |                 temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
| router = UserAPIRouter(prefix="/recipes", tags=["Recipe: CRUD"], route_class=MealieCrudRoute) | router = UserAPIRouter(prefix="/recipes", tags=["Recipe: CRUD"], route_class=MealieCrudRoute) | ||||||
| @@ -219,13 +223,14 @@ class RecipeController(BaseRecipeController): | |||||||
|         return "recipe_scrapers was unable to scrape this URL" |         return "recipe_scrapers was unable to scrape this URL" | ||||||
|  |  | ||||||
|     @router.post("/create-from-zip", status_code=201) |     @router.post("/create-from-zip", status_code=201) | ||||||
|     def create_recipe_from_zip(self, temp_path=Depends(temporary_zip_path), archive: UploadFile = File(...)): |     def create_recipe_from_zip(self, archive: UploadFile = File(...)): | ||||||
|         """Create recipe from archive""" |         """Create recipe from archive""" | ||||||
|         recipe = self.service.create_from_zip(archive, temp_path) |         with get_temporary_zip_path() as temp_path: | ||||||
|         self.publish_event( |             recipe = self.service.create_from_zip(archive, temp_path) | ||||||
|             event_type=EventTypes.recipe_created, |             self.publish_event( | ||||||
|             document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug), |                 event_type=EventTypes.recipe_created, | ||||||
|         ) |                 document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         return recipe.slug |         return recipe.slug | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,10 +1,9 @@ | |||||||
| import shutil | import shutil | ||||||
| from pathlib import Path |  | ||||||
|  |  | ||||||
| from fastapi import Depends, File, HTTPException, UploadFile, status | from fastapi import File, HTTPException, UploadFile, status | ||||||
| from pydantic import UUID4 | from pydantic import UUID4 | ||||||
|  |  | ||||||
| from mealie.core.dependencies.dependencies import temporary_dir | from mealie.core.dependencies import get_temporary_path | ||||||
| from mealie.pkgs import cache, img | from mealie.pkgs import cache, img | ||||||
| from mealie.routes._base import BaseUserController, controller | from mealie.routes._base import BaseUserController, controller | ||||||
| from mealie.routes._base.routers import UserAPIRouter | from mealie.routes._base.routers import UserAPIRouter | ||||||
| @@ -21,19 +20,19 @@ class UserImageController(BaseUserController): | |||||||
|         self, |         self, | ||||||
|         id: UUID4, |         id: UUID4, | ||||||
|         profile: UploadFile = File(...), |         profile: UploadFile = File(...), | ||||||
|         temp_dir: Path = Depends(temporary_dir), |  | ||||||
|     ): |     ): | ||||||
|         """Updates a User Image""" |         """Updates a User Image""" | ||||||
|         assert_user_change_allowed(id, self.user) |         with get_temporary_path() as temp_path: | ||||||
|         temp_img = temp_dir.joinpath(profile.filename) |             assert_user_change_allowed(id, self.user) | ||||||
|  |             temp_img = temp_path.joinpath(profile.filename) | ||||||
|  |  | ||||||
|         with temp_img.open("wb") as buffer: |             with temp_img.open("wb") as buffer: | ||||||
|             shutil.copyfileobj(profile.file, buffer) |                 shutil.copyfileobj(profile.file, buffer) | ||||||
|  |  | ||||||
|         image = img.PillowMinifier.to_webp(temp_img) |             image = img.PillowMinifier.to_webp(temp_img) | ||||||
|         dest = PrivateUser.get_directory(id) / "profile.webp" |             dest = PrivateUser.get_directory(id) / "profile.webp" | ||||||
|  |  | ||||||
|         shutil.copyfile(image, dest) |             shutil.copyfile(image, dest) | ||||||
|  |  | ||||||
|         self.repos.users.patch(id, {"cache_key": cache.new_key()}) |         self.repos.users.patch(id, {"cache_key": cache.new_key()}) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,7 @@ | |||||||
|  | from io import BytesIO | ||||||
|  | import json | ||||||
|  | import zipfile | ||||||
|  |  | ||||||
| from fastapi.testclient import TestClient | from fastapi.testclient import TestClient | ||||||
|  |  | ||||||
| from tests.utils import api_routes | from tests.utils import api_routes | ||||||
| @@ -36,6 +40,29 @@ def test_render_jinja_template(api_client: TestClient, unique_user: TestUser) -> | |||||||
|     assert f"# {recipe_name}" in response.text |     assert f"# {recipe_name}" in response.text | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> None: | ||||||
|  |     # Create Recipe | ||||||
|  |     recipe_name = random_string() | ||||||
|  |     response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) | ||||||
|  |     assert response.status_code == 201 | ||||||
|  |     slug = response.json() | ||||||
|  |  | ||||||
|  |     # Get zip token | ||||||
|  |     response = api_client.post(api_routes.recipes_slug_exports(slug), headers=unique_user.token) | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     token = response.json()["token"] | ||||||
|  |     assert token | ||||||
|  |  | ||||||
|  |     response = api_client.get(api_routes.recipes_slug_exports_zip(slug) + f"?token={token}", headers=unique_user.token) | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |  | ||||||
|  |     # Verify the zip | ||||||
|  |     zip_file = BytesIO(response.content) | ||||||
|  |     with zipfile.ZipFile(zip_file, "r") as zip_fp: | ||||||
|  |         with zip_fp.open(f"{slug}.json") as json_fp: | ||||||
|  |             assert json.loads(json_fp.read())["name"] == recipe_name | ||||||
|  |  | ||||||
|  |  | ||||||
| # TODO: Allow users to upload templates to their own directory | # TODO: Allow users to upload templates to their own directory | ||||||
| # def test_upload_template(api_client: TestClient, unique_user: TestUser) -> None: | # def test_upload_template(api_client: TestClient, unique_user: TestUser) -> None: | ||||||
| #     assert False | #     assert False | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user