Compare commits

...

15 Commits

Author SHA1 Message Date
mealie-commit-bot[bot]
5fc4851ef5 chore: bump version to v3.16.0 2026-04-17 20:59:57 +00:00
Michael Genson
d9e933d5ae fix: Misc frontend layout fixes (#7487) 2026-04-17 12:28:13 -05:00
renovate[bot]
0a07835338 fix(deps): update dependency lxml to v6.0.4 (#7485)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 16:46:53 +00:00
Michael Genson
7a85ea6ae9 chore: Update yarn deps (#7486) 2026-04-17 11:58:19 -05:00
renovate[bot]
c4c60f1645 fix(deps): update dependency authlib to v1.6.11 [security] (#7481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 16:46:22 +00:00
Zdenek Stursa
9f7ba8dc08 fix: preserve ingredient section titles when parsing recipe ingredients (#7483)
Co-authored-by: Zdenek <tvuj-email@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:44:45 +00:00
Michael Genson
c4799ceb9e dev: Enable lockfile maintenance and update deps (#7484) 2026-04-17 11:33:50 -05:00
Michael Genson
828be095a2 fix: Blank query filter builder fields (#7480) 2026-04-16 19:11:05 -05:00
renovate[bot]
18718fb647 chore(deps): update node.js to 33cf7f0 (#7478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 15:57:22 +00:00
Brian Choromanski
fb545962dd feat: Migrate PWA manifest to backend (#7331)
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-04-16 10:59:21 -05:00
Brian Choromanski
781a08ef54 docs: Added copy button to codeblocks (#7343) 2026-04-16 15:34:28 +00:00
mealie-commit-bot[bot]
a7a08b6b11 chore: bump version to v3.15.2 2026-04-16 03:43:59 +00:00
Hayden
bd296c3eaf fix: path traversal vulnerabilities in migration image imports and media routes (#7474) 2026-04-16 03:24:50 +00:00
renovate[bot]
8aa016e57b fix(deps): update dependency python-multipart to v0.0.26 [security] (#7473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 01:39:09 +00:00
renovate[bot]
480574eb3d fix(deps): update dependency python-multipart to v0.0.25 (#7470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:30:07 +00:00
35 changed files with 2666 additions and 2962 deletions

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:24@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec \
FROM node:24@sha256:33cf7f057918860b043c307751ef621d74ac96f875b79b6724dcebf2dfd0db6d \
AS frontend-builder
WORKDIR /frontend

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.15.1`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.16.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container

View File

@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.15.1 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.15.1 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.16.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -19,6 +19,7 @@ theme:
custom_dir: docs/overrides
features:
- content.code.annotate
- content.code.copy
- navigation.top
- navigation.instant
- navigation.expand

View File

@@ -20,16 +20,12 @@
max-width: 1100px !important;
}
.theme--dark.v-application {
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
.v-theme--dark.v-application {
background-color: rgb(var(--v-theme-background)) !important;
}
.theme--dark.v-navigation-drawer {
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
}
.theme--dark.v-card {
background-color: #1e1e1e !important;
.v-theme--dark .v-navigation-drawer {
background-color: rgb(var(--v-theme-background)) !important;
}
.left-border {

View File

@@ -41,19 +41,14 @@
>
<v-select
v-if="index"
:model-value="field.logicalOperator"
:model-value="field.logicalOperator?.value"
:items="[logOps.AND, logOps.OR]"
item-title="label"
item-value="value"
variant="underlined"
class="text-center"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
/>
</v-col>
<!-- left parenthesis -->
@@ -67,14 +62,9 @@
:model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']"
variant="underlined"
class="text-center"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
/>
</v-col>
<!-- field name -->
@@ -84,19 +74,14 @@
:class="config.col.class"
>
<v-select
chips
:model-value="field.label"
:items="fieldDefs"
variant="underlined"
item-title="label"
item-value="label"
class="text-center"
@update:model-value="setField(index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
/>
</v-col>
<!-- relational operator -->
@@ -107,19 +92,14 @@
>
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:model-value="field.relationalOperatorValue?.value"
:items="field.relationalOperatorChoices"
item-title="label"
item-value="value"
variant="underlined"
class="text-center"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
/>
</v-col>
<!-- field value -->
@@ -275,23 +255,14 @@
:model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']"
variant="underlined"
class="text-center"
@update:model-value="setRightParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field actions -->
<v-col
/>
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
:cols="config.items.fieldActions.cols(index)"
:sm="config.items.fieldActions.sm(index)"
:class="config.col.class"
>
>
<BaseButtonGroup
:buttons="[
{
@@ -723,9 +694,6 @@ const config = computed(() => {
col: {
class: "d-flex justify-center align-end py-0",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
icon: {
cols: (_index: number) => 2,

View File

@@ -371,14 +371,18 @@ async function parseIngredients() {
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients
.filter(ing => !ing.referencedRecipe)
.map(ing => ingredientToParserString(ing));
const filteredIngredients = props.ingredients.filter(ing => !ing.referencedRecipe);
const ingsAsString = filteredIngredients.map(ing => ingredientToParserString(ing));
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) {
throw new Error("Failed to parse ingredients");
}
parsedIngs.value = data;
// Restore section titles from original ingredients — the parser doesn't return them
data.forEach((parsed, index) => {
parsed.ingredient.title = filteredIngredients[index]?.title || "";
});
const parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "",

View File

@@ -1,6 +1,6 @@
<template>
<div style="height: 100%;">
<v-row class="my-0 mx-7">
<v-row class="mb-0 mt-3 mx-7">
<v-spacer />
<v-col class="text-right">
<!-- Filters -->
@@ -44,6 +44,7 @@
:model-value="option.checked"
color="primary"
readonly
hide-details
@click="toggleEventTypeOption(option.value)"
>
<template #label>

View File

@@ -28,7 +28,6 @@
<v-col v-else cols="9" style="margin: auto; text-align: center">
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
<RecipeTimelineContextMenu
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"

View File

@@ -68,6 +68,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
warning: theme?.darkWarning ?? "#FF6D00",
error: theme?.darkError ?? "#EF5350",
background: "#1E1E1E",
surface: "#1E1E1E",
},
},
},

View File

@@ -234,151 +234,7 @@ export default defineNuxtConfig({
periodicSyncForUpdates: 120,
},
includeAssets: ["favicon.ico", "apple-touch-icon.png", "safari-pinned-tab.svg"],
manifest: {
name: "Mealie",
short_name: "Mealie",
id: "/",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#FFFFFF",
theme_color: process.env.THEME_LIGHT_PRIMARY || "#E58325",
description: "Mealie is a recipe management and meal planning app",
lang: "en",
display_override: [
"standalone",
"minimal-ui",
"browser",
"window-controls-overlay",
],
categories: ["food", "lifestyle"],
prefer_related_applications: false,
handle_links: "preferred",
launch_handler: {
client_mode: ["focus-existing", "auto"],
},
edge_side_panel: {
preferred_width: 400,
},
share_target: {
action: "/r/create/url",
method: "GET",
enctype: "application/x-www-form-urlencoded",
params: {
text: "recipe_import_url",
},
},
icons: [
{
src: "/icons/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/android-chrome-maskable-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/android-chrome-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
screenshots: [
{
src: "/screenshots/home-narrow.png",
sizes: "1600x2420",
form_factor: "narrow",
label: "Home Page",
},
{
src: "/screenshots/recipe-narrow.png",
sizes: "1600x2420",
form_factor: "narrow",
label: "Recipe Page",
},
{
src: "/screenshots/editor-narrow.png",
sizes: "1600x2420",
form_factor: "narrow",
label: "Editor Page",
},
{
src: "/screenshots/parser-narrow.png",
sizes: "1600x2420",
form_factor: "narrow",
label: "Parser Page",
},
{
src: "/screenshots/home-wide.png",
sizes: "2560x1460",
form_factor: "wide",
label: "Home Page",
},
{
src: "/screenshots/recipe-wide.png",
sizes: "2560x1460",
form_factor: "wide",
label: "Recipe Page",
},
{
src: "/screenshots/editor-wide.png",
sizes: "2560x1460",
form_factor: "wide",
label: "Editor Page",
},
{
src: "/screenshots/parser-wide.png",
sizes: "2560x1460",
form_factor: "wide",
label: "Parser Page",
},
],
shortcuts: [
{
name: "Shopping Lists",
short_name: "Shopping Lists",
description: "Open the shopping lists",
url: "/shopping-lists",
icons: [
{
src: "/icons/mdiFormatListChecks-192x192.png",
sizes: "192x192",
},
{
src: "/icons/mdiFormatListChecks-96x96.png",
sizes: "96x96",
},
],
},
{
name: "Meal Planner",
short_name: "Meal Planner",
description: "Open the meal planner",
url: "/household/mealplan/planner/view",
icons: [
{
src: "/icons/mdiCalendarMultiselect-192x192.png",
sizes: "192x192",
},
{
src: "/icons/mdiCalendarMultiselect-96x96.png",
sizes: "96x96",
},
],
},
],
},
manifest: false, // This is served via the backend, see mealie/routes/spa/manifest.py
},
// Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify

View File

@@ -1,6 +1,6 @@
{
"name": "mealie",
"version": "3.15.1",
"version": "3.16.0",
"private": true,
"scripts": {
"dev": "nuxt dev",
@@ -26,7 +26,7 @@
"axios": "^1.8.1",
"date-fns": "^4.1.0",
"fuse.js": "^7.1.0",
"isomorphic-dompurify": "^2.28.0",
"isomorphic-dompurify": "^3.4.0",
"json-editor-vue": "^0.18.1",
"marked": "^15.0.12",
"nuxt": "^4.4.2",
@@ -55,8 +55,6 @@
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": {
"esbuild": ">=0.25.0",
"glob": ">=10.5.0",
"js-yaml": ">=4.1.1",
"node-forge": ">=1.3.2"
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,12 @@ router = APIRouter(prefix="/backups")
@controller(router)
class AdminBackupController(BaseAdminController):
def _backup_path(self, name) -> Path:
return get_app_dirs().BACKUP_DIR / name
def _backup_path(self, name: str) -> Path:
backup_dir = get_app_dirs().BACKUP_DIR
candidate = (backup_dir / name).resolve()
if not candidate.is_relative_to(backup_dir.resolve()):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
return candidate
@router.get("", response_model=AllBackups)
def get_all(self):
@@ -86,7 +90,7 @@ class AdminBackupController(BaseAdminController):
app_dirs = get_app_dirs()
dest = app_dirs.BACKUP_DIR.joinpath(f"{name}.zip")
if dest.absolute().parent != app_dirs.BACKUP_DIR:
if dest.resolve().parent != app_dirs.BACKUP_DIR.resolve():
raise HTTPException(status.HTTP_400_BAD_REQUEST)
with dest.open("wb") as buffer:

View File

@@ -1,5 +1,6 @@
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, File, UploadFile
@@ -25,9 +26,12 @@ class AdminDebugController(BaseAdminController):
with get_temporary_path() as temp_path:
if image:
with temp_path.joinpath(image.filename).open("wb") as buffer:
if not image.filename:
return DebugResponse(success=False, response="Invalid image filename")
safe_filename = Path(image.filename).name
local_image_path = temp_path.joinpath(safe_filename)
with local_image_path.open("wb") as buffer:
shutil.copyfileobj(image.file, buffer)
local_image_path = temp_path.joinpath(image.filename)
local_images = [OpenAILocalImage(filename=os.path.basename(local_image_path), path=local_image_path)]
else:
local_images = None

View File

@@ -17,7 +17,7 @@ class ImageType(StrEnum):
@router.get("/{recipe_id}/images/{file_name}")
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
async def get_recipe_img(recipe_id: UUID4, file_name: ImageType = ImageType.original):
"""
Takes in a recipe id, returns the static image. This route is proxied in the docker image
and should not hit the API in production
@@ -32,7 +32,7 @@ async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.origin
@router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}")
async def get_recipe_timeline_event_img(
recipe_id: str, timeline_event_id: str, file_name: ImageType = ImageType.original
recipe_id: UUID4, timeline_event_id: UUID4, file_name: ImageType = ImageType.original
):
"""
Takes in a recipe id and event timeline id, returns the static image. This route is proxied in the docker image
@@ -51,7 +51,11 @@ async def get_recipe_timeline_event_img(
@router.get("/{recipe_id}/assets/{file_name}")
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
"""Returns a recipe asset"""
file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name)
asset_dir = Recipe.directory_from_id(recipe_id).joinpath("assets")
file = asset_dir.joinpath(file_name).resolve()
if not file.is_relative_to(asset_dir.resolve()):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
if file.exists():
return FileResponse(file)

View File

@@ -11,7 +11,11 @@ router = APIRouter(prefix="/users")
async def get_user_image(user_id: UUID4, file_name: str):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = PrivateUser.get_directory(user_id) / file_name
user_dir = PrivateUser.get_directory(user_id)
recipe_image = (user_dir / file_name).resolve()
if not recipe_image.is_relative_to(user_dir.resolve()):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
if recipe_image.exists():
return FileResponse(recipe_image, media_type="image/webp")

View File

@@ -16,6 +16,7 @@ from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import try_get_current_user
from mealie.db.db_setup import generate_session
from mealie.repos.repository_factory import AllRepositories
from mealie.routes.spa.manifest import serve_manifest
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.user.user import PrivateUser
@@ -251,4 +252,5 @@ def mount_spa(app: FastAPI):
app.get("/g/{group_slug}/r/{recipe_slug}", include_in_schema=False)(serve_recipe_with_meta)
app.get("/g/{group_slug}/shared/r/{token_id}", include_in_schema=False)(serve_shared_recipe_with_meta)
app.get("/manifest.webmanifest", include_in_schema=False)(serve_manifest)
app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")

View File

@@ -0,0 +1,125 @@
import json
from urllib.parse import urlparse
from fastapi import Response
from mealie.core.config import get_app_settings
def serve_manifest():
settings = get_app_settings()
sub_path = urlparse(settings.BASE_URL).path or "/"
manifest = {
"name": "Mealie",
"short_name": "Mealie",
"id": "/",
"start_url": sub_path,
"scope": sub_path,
"display": "standalone",
"background_color": "#1E1E1E",
"theme_color": settings.theme.light_primary,
"description": "Mealie is a recipe management and meal planning app",
"lang": "en",
"display_override": ["standalone", "minimal-ui", "browser", "window-controls-overlay"],
"categories": ["food", "lifestyle"],
"prefer_related_applications": False,
"handle_links": "preferred",
"launch_handler": {"client_mode": ["focus-existing", "auto"]},
"edge_side_panel": {"preferred_width": 400},
"share_target": {
"action": "/r/create/url",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"text": "recipe_import_url"},
},
"icons": [
{"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{
"src": "/icons/android-chrome-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable",
},
{
"src": "/icons/android-chrome-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
},
],
"screenshots": [
{
"src": "/screenshots/home-narrow.png",
"sizes": "1600x2420",
"form_factor": "narrow",
"label": "Home Page",
},
{
"src": "/screenshots/recipe-narrow.png",
"sizes": "1600x2420",
"form_factor": "narrow",
"label": "Recipe Page",
},
{
"src": "/screenshots/editor-narrow.png",
"sizes": "1600x2420",
"form_factor": "narrow",
"label": "Editor Page",
},
{
"src": "/screenshots/parser-narrow.png",
"sizes": "1600x2420",
"form_factor": "narrow",
"label": "Parser Page",
},
{"src": "/screenshots/home-wide.png", "sizes": "2560x1460", "form_factor": "wide", "label": "Home Page"},
{
"src": "/screenshots/recipe-wide.png",
"sizes": "2560x1460",
"form_factor": "wide",
"label": "Recipe Page",
},
{
"src": "/screenshots/editor-wide.png",
"sizes": "2560x1460",
"form_factor": "wide",
"label": "Editor Page",
},
{
"src": "/screenshots/parser-wide.png",
"sizes": "2560x1460",
"form_factor": "wide",
"label": "Parser Page",
},
],
"shortcuts": [
{
"name": "Shopping Lists",
"short_name": "Shopping Lists",
"description": "Open the shopping lists",
"url": "/shopping-lists",
"icons": [
{"src": "/icons/mdiFormatListChecks-192x192.png", "sizes": "192x192"},
{"src": "/icons/mdiFormatListChecks-96x96.png", "sizes": "96x96"},
],
},
{
"name": "Meal Planner",
"short_name": "Meal Planner",
"description": "Open the meal planner",
"url": "/household/mealplan/planner/view",
"icons": [
{"src": "/icons/mdiCalendarMultiselect-192x192.png", "sizes": "192x192"},
{"src": "/icons/mdiCalendarMultiselect-96x96.png", "sizes": "96x96"},
],
},
],
}
return Response(
content=json.dumps(manifest),
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)

View File

@@ -272,8 +272,8 @@ class BaseMigrator(BaseService):
recipe = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
return recipe
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4):
def import_image(self, slug: str, src: str | Path, recipe_id: UUID4, extraction_root: Path | None = None):
try:
import_image(src, recipe_id)
import_image(src, recipe_id, extraction_root=extraction_root)
except UnidentifiedImageError as e:
self.logger.error(f"Failed to import image for {slug}: {e}")

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias
from .utils.migration_helpers import MigrationReaders, split_by_comma
from .utils.migration_helpers import MigrationReaders, safe_local_path, split_by_comma
class ChowdownMigrator(BaseMigrator):
@@ -60,8 +60,10 @@ class ChowdownMigrator(BaseMigrator):
continue
if r.image:
cd_image = image_dir.joinpath(r.image)
cd_image = safe_local_path(image_dir.joinpath(r.image), image_dir)
else:
cd_image = None
except StopIteration:
continue
if cd_image:
self.import_image(slug, cd_image, recipe_id)
self.import_image(slug, cd_image, recipe_id, extraction_root=image_dir)

View File

@@ -11,7 +11,7 @@ from mealie.services.parser_services._base import DataMatcher
from mealie.services.parser_services.parser_utils.string_utils import extract_quantity_from_string
from ._migration_base import BaseMigrator
from .utils.migration_helpers import format_time
from .utils.migration_helpers import format_time, safe_local_path
class DSVParser:
@@ -157,15 +157,21 @@ class CooknMigrator(BaseMigrator):
if _media_type != "":
# Determine file extension based on media type
_extension = _media_type.split("/")[-1]
_old_image_path = os.path.join(db.directory, str(_media_id))
new_image_path = f"{_old_image_path}.{_extension}"
_old_image_path = Path(db.directory) / str(_media_id)
new_image_path = _old_image_path.with_suffix(f".{_extension}")
if safe_local_path(_old_image_path, db.directory) is None:
return None
if safe_local_path(new_image_path, db.directory) is None:
return None
# Rename the file if it exists and has no extension
if os.path.exists(_old_image_path) and not os.path.exists(new_image_path):
if _old_image_path.exists() and not new_image_path.exists():
os.rename(_old_image_path, new_image_path)
if Path(new_image_path).exists():
return new_image_path
if new_image_path.exists():
return str(new_image_path)
else:
return os.path.join(db.directory, str(_media_id))
candidate = Path(db.directory) / str(_media_id)
if safe_local_path(candidate, db.directory) is not None:
return str(candidate)
return None
def _parse_ingredients(self, _recipe_id: str, db: DSVParser) -> list[RecipeIngredient]:
@@ -388,14 +394,14 @@ class CooknMigrator(BaseMigrator):
recipe = recipe_lookup.get(slug)
if recipe:
if recipe.image:
self.import_image(slug, recipe.image, recipe_id)
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
else:
index_len = len(slug.split("-")[-1])
recipe = recipe_lookup.get(slug[: -(index_len + 1)])
if recipe:
self.logger.warning("Duplicate recipe (%s) found! Saved as copy...", recipe.name)
if recipe.image:
self.import_image(slug, recipe.image, recipe_id)
self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
else:
self.logger.warning("Failed to lookup recipe! (%s)", slug)

View File

@@ -9,7 +9,7 @@ from mealie.schema.reports.reports import ReportEntryCreate
from ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias
from .utils.migration_helpers import import_image
from .utils.migration_helpers import import_image, safe_local_path
def parse_recipe_tags(tags: list) -> list[str]:
@@ -52,7 +52,9 @@ class CopyMeThatMigrator(BaseMigrator):
# the recipe image tag has no id, so we parse it directly
if tag.name == "img" and "recipeImage" in tag.get("class", []):
if image_path := tag.get("src"):
recipe_dict["image"] = str(source_dir.joinpath(image_path))
safe = safe_local_path(source_dir.joinpath(image_path), source_dir)
if safe is not None:
recipe_dict["image"] = str(safe)
continue
@@ -120,4 +122,4 @@ class CopyMeThatMigrator(BaseMigrator):
except StopIteration:
continue
import_image(r.image, recipe_id)
import_image(r.image, recipe_id, extraction_root=source_dir)

View File

@@ -97,4 +97,4 @@ class NextcloudMigrator(BaseMigrator):
if status:
nc_dir = nextcloud_dirs[slug]
if nc_dir.image:
self.import_image(slug, nc_dir.image, recipe_id)
self.import_image(slug, nc_dir.image, recipe_id, extraction_root=base_dir)

View File

@@ -84,6 +84,6 @@ class PaprikaMigrator(BaseMigrator):
temp_file.write(image.read())
temp_file.flush()
path = Path(temp_file.name)
self.import_image(slug, path, recipe_id)
self.import_image(slug, path, recipe_id, extraction_root=path.parent)
except Exception as e:
self.logger.error(f"Failed to import image for {slug}: {e}")

View File

@@ -8,7 +8,7 @@ from mealie.services.scraper import cleaner
from ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias
from .utils.migration_helpers import parse_iso8601_duration
from .utils.migration_helpers import parse_iso8601_duration, safe_local_path
def clean_instructions(instructions: list[str]) -> list[str]:
@@ -30,7 +30,9 @@ def parse_recipe_div(recipe, image_path):
elif item.name == "div":
meta[item["itemprop"]] = list(item.stripped_strings)
elif item.name == "img":
meta[item["itemprop"]] = str(image_path / item["src"])
safe = safe_local_path(image_path / item["src"], image_path)
if safe is not None:
meta[item["itemprop"]] = str(safe)
else:
meta[item["itemprop"]] = item.string
# merge nutrition keys into their own dict.
@@ -107,4 +109,4 @@ class RecipeKeeperMigrator(BaseMigrator):
except StopIteration:
continue
self.import_image(slug, recipe.image, recipe_id)
self.import_image(slug, recipe.image, recipe_id, extraction_root=source_dir)

View File

@@ -132,4 +132,4 @@ class TandoorMigrator(BaseMigrator):
except StopIteration:
continue
self.import_image(slug, r.image, recipe_id)
self.import_image(slug, r.image, recipe_id, extraction_root=source_dir)

View File

@@ -8,6 +8,7 @@ import yaml
from PIL import UnidentifiedImageError
from pydantic import UUID4
from mealie.core import root_logger
from mealie.services.recipe.recipe_data_service import RecipeDataService
@@ -100,16 +101,45 @@ def glob_walker(directory: Path, glob_str: str, return_parent=True) -> list[Path
return matches
def import_image(src: str | Path, recipe_id: UUID4):
"""Read the successful migrations attribute and for each import the image
appropriately into the image directory. Minification is done in mass
after the migration occurs.
def safe_local_path(candidate: str | Path, root: Path) -> Path | None:
"""
Returns the resolved path only if it is safely contained within root.
Returns ``None`` for any path that would escape the root directory,
including ``../../`` traversal sequences and absolute paths outside root.
Symlinks are followed by ``resolve()``, so a symlink pointing outside root
is also rejected.
"""
try:
# OSError: symlink resolution failure; ValueError: null bytes on some platforms
resolved = Path(candidate).resolve()
if resolved.is_relative_to(root.resolve()):
return resolved
except (OSError, ValueError):
pass
return None
def import_image(src: str | Path, recipe_id: UUID4, extraction_root: Path | None = None):
"""Import a local image file into the recipe image directory.
May raise an UnidentifiedImageError if the file is not a recognised format.
If extraction_root is provided, the src path must be contained within it.
Paths that escape the extraction_root are silently rejected to prevent
arbitrary local file reads via archive-controlled image paths.
"""
if isinstance(src, str):
src = Path(src)
if extraction_root is not None:
if safe_local_path(src, extraction_root) is None:
root_logger.get_logger().warning(
"Rejected image path outside extraction root: %s (root: %s)", src, extraction_root
)
return
if not src.exists():
return

View File

@@ -129,19 +129,24 @@ class OpenAIService(BaseService):
"""
tree = name.split(".")
relative_path = Path(*tree[:-1], tree[-1] + ".txt")
default_prompt_file = Path(self.PROMPTS_DIR, relative_path)
default_prompt_file = (self.PROMPTS_DIR / relative_path).resolve()
if not default_prompt_file.is_relative_to(self.PROMPTS_DIR.resolve()):
raise ValueError(f"Invalid prompt name '{name}': resolves outside prompts directory")
try:
# Only include custom files if the custom_dir is configured, is a directory, and the prompt file exists
custom_dir = Path(self.custom_prompt_dir) if self.custom_prompt_dir else None
custom_dir = Path(self.custom_prompt_dir).resolve() if self.custom_prompt_dir else None
if custom_dir and not custom_dir.is_dir():
custom_dir = None
except Exception:
custom_dir = None
if custom_dir:
custom_prompt_file = Path(custom_dir, relative_path)
if custom_prompt_file.exists():
custom_prompt_file = (custom_dir / relative_path).resolve()
if not custom_prompt_file.is_relative_to(custom_dir):
logger.warning(f"Custom prompt file resolves outside custom dir, skipping: {custom_prompt_file}")
elif custom_prompt_file.exists():
logger.debug(f"Found valid custom prompt file: {custom_prompt_file}")
return [custom_prompt_file, default_prompt_file]
else:

View File

@@ -99,7 +99,12 @@ class RecipeDataService(BaseService):
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
self.minifier.minify(image_path)
try:
self.minifier.minify(image_path)
except Exception:
# Remove the partially-written file so corrupt images don't persist on disk.
image_path.unlink(missing_ok=True)
raise
return image_path

View File

@@ -325,9 +325,11 @@ class RecipeService(RecipeServiceBase):
with get_temporary_path() as temp_path:
local_images: list[Path] = []
for image in images:
with temp_path.joinpath(image.filename).open("wb") as buffer:
safe_filename = Path(image.filename).name
image_path = temp_path.joinpath(safe_filename)
with image_path.open("wb") as buffer:
shutil.copyfileobj(image.file, buffer)
local_images.append(temp_path.joinpath(image.filename))
local_images.append(image_path)
recipe_data = await openai_recipe_service.build_recipe_from_images(
local_images, translate_language=translate_language

View File

@@ -1,6 +1,6 @@
[project]
name = "mealie"
version = "3.15.1"
version = "3.16.0"
description = "A Recipe Manager"
authors = [{ name = "Hayden", email = "hay-kot@pm.me" }]
license = "AGPL-3.0-only"
@@ -19,14 +19,14 @@ dependencies = [
"extruct==0.18.0",
"fastapi==0.135.3",
"httpx==0.28.1",
"lxml==6.0.3",
"lxml==6.0.4",
"orjson==3.11.8",
"pydantic==2.12.5",
"pyhumps==3.8.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.2.2",
"python-ldap==3.4.5",
"python-multipart==0.0.24",
"python-multipart==0.0.26",
"python-slugify==8.0.4",
"recipe-scrapers==15.11.0",
"requests==2.33.1",
@@ -36,7 +36,7 @@ dependencies = [
"isodate==0.7.2",
"text-unidecode==1.3",
"rapidfuzz==3.14.5",
"authlib==1.6.9",
"authlib==1.6.11",
"html2text==2025.4.15",
"paho-mqtt==1.6.1",
"pydantic-settings==2.13.1",

View File

@@ -1,5 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"lockFileMaintenance": {
"enabled": true
},
"enabledManagers": [
"pep621",
"dockerfile",

616
uv.lock generated

File diff suppressed because it is too large Load Diff