fix: Refactor Recipe Zip File Flow (#6170)

This commit is contained in:
Michael Genson
2025-11-03 14:43:22 -06:00
committed by GitHub
parent 3d177566ed
commit 0371874670
15 changed files with 81 additions and 125 deletions

View File

@@ -176,36 +176,6 @@ def validate_file_token(token: str | None = None) -> Path:
return file_path
def validate_recipe_token(token: str | None = None) -> str:
"""
Args:
token (Optional[str], optional): _description_. Defaults to None.
Raises:
HTTPException: 400 Bad Request when no token or the recipe doesn't exist
HTTPException: 401 PyJWTError when token is invalid
Returns:
str: token data
"""
if not token:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
slug: str | None = payload.get("slug")
except PyJWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate file token",
) from e
if slug is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST)
return slug
@contextmanager
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)

View File

@@ -45,11 +45,6 @@ def create_file_token(file_path: Path) -> str:
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def create_recipe_slug_token(file_path: str | Path) -> str:
token_data = {"slug": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return get_hasher().hash(password)

View File

@@ -1,24 +1,11 @@
from shutil import rmtree
from zipfile import ZipFile
from fastapi import (
HTTPException,
)
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core.dependencies import (
get_temporary_path,
get_temporary_zip_path,
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.core.dependencies import get_temporary_path
from mealie.routes._base import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.schema.recipe.request_helpers import (
RecipeZipTokenResponse,
)
from mealie.services.recipe.template_service import TemplateService
from ._base import BaseRecipeController, FormatResponse
@@ -35,11 +22,6 @@ class RecipeExportController(BaseRecipeController):
def get_recipe_formats_and_templates(self):
return TemplateService().templates
@router.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
@router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str):
"""
@@ -53,24 +35,3 @@ class RecipeExportController(BaseRecipeController):
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
@router.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str):
"""Get a Recipe and Its Original Image as a Zip File"""
with get_temporary_zip_path(auto_unlink=False) as temp_path:
validated_slug = validate_recipe_token(token)
if validated_slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(validated_slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)

View File

@@ -1,11 +1,17 @@
from zipfile import ZipFile
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from sqlalchemy.orm.session import Session
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core.dependencies import get_temporary_zip_path
from mealie.core.root_logger import get_logger
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
from mealie.schema.response import ErrorResponse
router = APIRouter()
@@ -14,7 +20,7 @@ logger = get_logger()
@router.get("/shared/{token_id}", response_model=Recipe)
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)):
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)) -> Recipe:
db = get_repositories(session, group_id=None, household_id=None)
token_summary = db.recipe_share_tokens.get_one(token_id)
@@ -31,3 +37,22 @@ def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_sessi
raise HTTPException(status_code=404, detail=ErrorResponse.respond("Token Not Found"))
return token_summary.recipe
@router.get("/shared/{token_id}/zip", response_class=FileResponse)
def get_shared_recipe_as_zip(token_id: UUID4, session: Session = Depends(generate_session)) -> FileResponse:
"""Get a recipe and its original image as a Zip file"""
recipe = get_shared_recipe(token_id=token_id, session=session)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with get_temporary_zip_path(auto_unlink=False) as temp_path:
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{recipe.slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)

View File

@@ -86,7 +86,7 @@ from .recipe_timeline_events import (
TimelineEventType,
)
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
from .request_helpers import RecipeDuplicate, RecipeSlug, SlugResponse, UpdateImageResponse
__all__ = [
"IngredientReferences",
@@ -178,7 +178,6 @@ __all__ = [
"RecipeImageTypes",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
]

View File

@@ -17,9 +17,5 @@ class UpdateImageResponse(BaseModel):
image: str
class RecipeZipTokenResponse(BaseModel):
token: str
class RecipeDuplicate(BaseModel):
name: str | None = None