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

File diff suppressed because one or more lines are too long

View File

@@ -377,11 +377,14 @@ async function deleteRecipe() {
const download = useDownloader(); const download = useDownloader();
async function handleDownloadEvent() { async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug); const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
if (!shareToken) {
if (data) { console.error("No share token received");
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`); alert.error(i18n.t("events.something-went-wrong"));
return;
} }
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
} }
async function addRecipeToPlan() { async function addRecipeToPlan() {

View File

@@ -1,9 +1,19 @@
import { alert } from "~/composables/use-toast";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export function useDownloader() { export function useDownloader() {
function download(url: string, filename: string) { function download(url: string, filename: string) {
useFetch(url, { useFetch(url, {
method: "GET", method: "GET",
responseType: "blob", responseType: "blob",
onResponse({ response }) { onResponse({ response }) {
if (!response.ok) {
console.error("Download failed", response);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
return;
}
const url = window.URL.createObjectURL(new Blob([response._data])); const url = window.URL.createObjectURL(new Blob([response._data]));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;

View File

@@ -470,9 +470,6 @@ export interface RecipeToolSave {
householdsWithTool?: string[]; householdsWithTool?: string[];
groupId: string; groupId: string;
} }
export interface RecipeZipTokenResponse {
token: string;
}
export interface SaveIngredientFood { export interface SaveIngredientFood {
id?: string | null; id?: string | null;
name: string; name: string;

View File

@@ -6,9 +6,14 @@ const prefix = "/api";
const routes = { const routes = {
shareToken: `${prefix}/shared/recipes`, shareToken: `${prefix}/shared/recipes`,
shareTokenId: (id: string) => `${prefix}/shared/recipes/${id}`, shareTokenId: (id: string) => `${prefix}/shared/recipes/${id}`,
shareTokenIdZip: (id: string) => `${prefix}/recipes/shared/${id}/zip`,
}; };
export class RecipeShareApi extends BaseCRUDAPI<RecipeShareTokenCreate, RecipeShareToken> { export class RecipeShareApi extends BaseCRUDAPI<RecipeShareTokenCreate, RecipeShareToken> {
baseRoute: string = routes.shareToken; baseRoute: string = routes.shareToken;
itemRoute = routes.shareTokenId; itemRoute = routes.shareTokenId;
getZipRedirectUrl(tokenId: string) {
return routes.shareTokenIdZip(tokenId);
}
} }

View File

@@ -9,7 +9,6 @@ import type {
CreateRecipeByUrlBulk, CreateRecipeByUrlBulk,
ParsedIngredient, ParsedIngredient,
UpdateImageResponse, UpdateImageResponse,
RecipeZipTokenResponse,
RecipeLastMade, RecipeLastMade,
RecipeSuggestionQuery, RecipeSuggestionQuery,
RecipeSuggestionResponse, RecipeSuggestionResponse,
@@ -46,8 +45,6 @@ const routes = {
recipesTimelineEvent: `${prefix}/recipes/timeline/events`, recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
recipesRecipeSlugExportZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports/zip`,
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`, recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`, recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
@@ -182,14 +179,6 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient }); return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
} }
async getZipToken(recipeSlug: string) {
return await this.requests.post<RecipeZipTokenResponse>(routes.recipesRecipeSlugExport(recipeSlug), {});
}
getZipRedirectUrl(recipeSlug: string, token: string) {
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
}
async updateMany(payload: Recipe[]) { async updateMany(payload: Recipe[]) {
return await this.requests.put<Recipe[]>(routes.recipesBase, payload); return await this.requests.put<Recipe[]>(routes.recipesBase, payload);
} }

View File

@@ -37,14 +37,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
export default defineNuxtComponent({ export default defineNuxtComponent({
setup() { setup() {
const state = reactive({ const state = reactive({
error: false,
loading: false, loading: false,
}); });
const $auth = useMealieAuth(); const $auth = useMealieAuth();
@@ -54,15 +54,6 @@ export default defineNuxtComponent({
const api = useUserApi(); const api = useUserApi();
const router = useRouter(); const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
}
const newRecipeZip = ref<File | null>(null); const newRecipeZip = ref<File | null>(null);
const newRecipeZipFileName = "archive"; const newRecipeZipFileName = "archive";
@@ -73,8 +64,21 @@ export default defineNuxtComponent({
const formData = new FormData(); const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value); formData.append(newRecipeZipFileName, newRecipeZip.value);
const { response } = await api.upload.file("/api/recipes/create/zip", formData); try {
handleResponse(response); const response = await api.upload.file("/api/recipes/create/zip", formData);
if (response?.status !== 201) {
throw new Error("Failed to upload zip");
}
router.push(`/g/${groupSlug.value}/r/${response.data}`);
}
catch (error) {
console.error(error);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading = false;
}
} }
return { return {

View File

@@ -176,36 +176,6 @@ def validate_file_token(token: str | None = None) -> Path:
return file_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 @contextmanager
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]: 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)

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)) 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: def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database.""" """Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return get_hasher().hash(password) return get_hasher().hash(password)

View File

@@ -1,24 +1,11 @@
from shutil import rmtree from shutil import rmtree
from zipfile import ZipFile
from fastapi import (
HTTPException,
)
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from starlette.responses import FileResponse from starlette.responses import FileResponse
from mealie.core.dependencies import ( from mealie.core.dependencies import get_temporary_path
get_temporary_path,
get_temporary_zip_path,
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.routes._base import controller from mealie.routes._base import controller
from mealie.routes._base.routers import UserAPIRouter 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 mealie.services.recipe.template_service import TemplateService
from ._base import BaseRecipeController, FormatResponse from ._base import BaseRecipeController, FormatResponse
@@ -35,11 +22,6 @@ class RecipeExportController(BaseRecipeController):
def get_recipe_formats_and_templates(self): def get_recipe_formats_and_templates(self):
return TemplateService().templates 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) @router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str): def get_recipe_as_format(self, slug: str, template_name: str):
""" """
@@ -53,24 +35,3 @@ class RecipeExportController(BaseRecipeController):
recipe = self.mixins.get_one(slug) recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name) file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path)) 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 fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy.orm.session import Session 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.core.root_logger import get_logger
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
from mealie.schema.response import ErrorResponse from mealie.schema.response import ErrorResponse
router = APIRouter() router = APIRouter()
@@ -14,7 +20,7 @@ logger = get_logger()
@router.get("/shared/{token_id}", response_model=Recipe) @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) db = get_repositories(session, group_id=None, household_id=None)
token_summary = db.recipe_share_tokens.get_one(token_id) 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")) raise HTTPException(status_code=404, detail=ErrorResponse.respond("Token Not Found"))
return token_summary.recipe 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, TimelineEventType,
) )
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave 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__ = [ __all__ = [
"IngredientReferences", "IngredientReferences",
@@ -178,7 +178,6 @@ __all__ = [
"RecipeImageTypes", "RecipeImageTypes",
"RecipeDuplicate", "RecipeDuplicate",
"RecipeSlug", "RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse", "SlugResponse",
"UpdateImageResponse", "UpdateImageResponse",
] ]

View File

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

View File

@@ -28,13 +28,15 @@ def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> Non
assert response.status_code == 201 assert response.status_code == 201
slug = response.json() slug = response.json()
# Get zip token # Get token
response = api_client.post(api_routes.recipes_slug_exports(slug), headers=unique_user.token) recipe = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token).json()
assert response.status_code == 200 assert recipe["slug"] == slug
token = response.json()["token"] response = api_client.post(api_routes.shared_recipes, json={"recipeId": recipe["id"]}, headers=unique_user.token)
assert token assert response.status_code == 201
token_id = response.json()["id"]
response = api_client.get(api_routes.recipes_slug_exports_zip(slug) + f"?token={token}", headers=unique_user.token) # Get zip file
response = api_client.get(api_routes.recipes_shared_token_id_zip(token_id))
assert response.status_code == 200 assert response.status_code == 200
# Verify the zip # Verify the zip

View File

@@ -468,6 +468,11 @@ def recipes_shared_token_id(token_id):
return f"{prefix}/recipes/shared/{token_id}" return f"{prefix}/recipes/shared/{token_id}"
def recipes_shared_token_id_zip(token_id):
"""`/api/recipes/shared/{token_id}/zip`"""
return f"{prefix}/recipes/shared/{token_id}/zip"
def recipes_slug(slug): def recipes_slug(slug):
"""`/api/recipes/{slug}`""" """`/api/recipes/{slug}`"""
return f"{prefix}/recipes/{slug}" return f"{prefix}/recipes/{slug}"
@@ -493,11 +498,6 @@ def recipes_slug_exports(slug):
return f"{prefix}/recipes/{slug}/exports" return f"{prefix}/recipes/{slug}/exports"
def recipes_slug_exports_zip(slug):
"""`/api/recipes/{slug}/exports/zip`"""
return f"{prefix}/recipes/{slug}/exports/zip"
def recipes_slug_image(slug): def recipes_slug_image(slug):
"""`/api/recipes/{slug}/image`""" """`/api/recipes/{slug}/image`"""
return f"{prefix}/recipes/{slug}/image" return f"{prefix}/recipes/{slug}/image"