mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-31 20:25:14 -04:00
fix: Fix file not found error with individual recipe export/download. (#3579)
This commit is contained in:
parent
c610ec1344
commit
c70a5cb72c
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user