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 # Frontend Build
############################################### ###############################################
FROM node:24@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec \ FROM node:24@sha256:33cf7f057918860b043c307751ef621d74ac96f875b79b6724dcebf2dfd0db6d \
AS frontend-builder AS frontend-builder
WORKDIR /frontend 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: 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! 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. 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 4. Restart the container

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -371,14 +371,18 @@ async function parseIngredients() {
} }
state.loading.parser = true; state.loading.parser = true;
try { try {
const ingsAsString = props.ingredients const filteredIngredients = props.ingredients.filter(ing => !ing.referencedRecipe);
.filter(ing => !ing.referencedRecipe) const ingsAsString = filteredIngredients.map(ing => ingredientToParserString(ing));
.map(ing => ingredientToParserString(ing));
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString); const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) { if (error || !data) {
throw new Error("Failed to parse ingredients"); 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 parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({ const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "", input: ing.note || "",

View File

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

View File

@@ -28,7 +28,6 @@
<v-col v-else cols="9" style="margin: auto; text-align: center"> <v-col v-else cols="9" style="margin: auto; text-align: center">
{{ event.subject }} {{ event.subject }}
</v-col> </v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0"> <v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
<RecipeTimelineContextMenu <RecipeTimelineContextMenu
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'" 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", warning: theme?.darkWarning ?? "#FF6D00",
error: theme?.darkError ?? "#EF5350", error: theme?.darkError ?? "#EF5350",
background: "#1E1E1E", background: "#1E1E1E",
surface: "#1E1E1E",
}, },
}, },
}, },

View File

@@ -234,151 +234,7 @@ export default defineNuxtConfig({
periodicSyncForUpdates: 120, periodicSyncForUpdates: 120,
}, },
includeAssets: ["favicon.ico", "apple-touch-icon.png", "safari-pinned-tab.svg"], includeAssets: ["favicon.ico", "apple-touch-icon.png", "safari-pinned-tab.svg"],
manifest: { manifest: false, // This is served via the backend, see mealie/routes/spa/manifest.py
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",
},
],
},
],
},
}, },
// Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify // Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify

View File

@@ -1,6 +1,6 @@
{ {
"name": "mealie", "name": "mealie",
"version": "3.15.1", "version": "3.16.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxt dev",
@@ -26,7 +26,7 @@
"axios": "^1.8.1", "axios": "^1.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"isomorphic-dompurify": "^2.28.0", "isomorphic-dompurify": "^3.4.0",
"json-editor-vue": "^0.18.1", "json-editor-vue": "^0.18.1",
"marked": "^15.0.12", "marked": "^15.0.12",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
@@ -55,8 +55,6 @@
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"resolutions": { "resolutions": {
"esbuild": ">=0.25.0",
"glob": ">=10.5.0",
"js-yaml": ">=4.1.1", "js-yaml": ">=4.1.1",
"node-forge": ">=1.3.2" "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) @controller(router)
class AdminBackupController(BaseAdminController): class AdminBackupController(BaseAdminController):
def _backup_path(self, name) -> Path: def _backup_path(self, name: str) -> Path:
return get_app_dirs().BACKUP_DIR / name 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) @router.get("", response_model=AllBackups)
def get_all(self): def get_all(self):
@@ -86,7 +90,7 @@ class AdminBackupController(BaseAdminController):
app_dirs = get_app_dirs() app_dirs = get_app_dirs()
dest = app_dirs.BACKUP_DIR.joinpath(f"{name}.zip") 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) raise HTTPException(status.HTTP_400_BAD_REQUEST)
with dest.open("wb") as buffer: with dest.open("wb") as buffer:

View File

@@ -1,5 +1,6 @@
import os import os
import shutil import shutil
from pathlib import Path
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, File, UploadFile
@@ -25,9 +26,12 @@ class AdminDebugController(BaseAdminController):
with get_temporary_path() as temp_path: with get_temporary_path() as temp_path:
if image: 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) 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)] local_images = [OpenAILocalImage(filename=os.path.basename(local_image_path), path=local_image_path)]
else: else:
local_images = None local_images = None

View File

@@ -17,7 +17,7 @@ class ImageType(StrEnum):
@router.get("/{recipe_id}/images/{file_name}") @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 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 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}") @router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}")
async def get_recipe_timeline_event_img( 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 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}") @router.get("/{recipe_id}/assets/{file_name}")
async def get_recipe_asset(recipe_id: UUID4, file_name: str): async def get_recipe_asset(recipe_id: UUID4, file_name: str):
"""Returns a recipe asset""" """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(): if file.exists():
return FileResponse(file) return FileResponse(file)

View File

@@ -11,7 +11,11 @@ router = APIRouter(prefix="/users")
async def get_user_image(user_id: UUID4, file_name: str): 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 """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""" 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(): if recipe_image.exists():
return FileResponse(recipe_image, media_type="image/webp") 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.core.dependencies.dependencies import try_get_current_user
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.repos.repository_factory import AllRepositories 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.recipe.recipe import Recipe
from mealie.schema.user.user import PrivateUser 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}/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("/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") 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)) recipe = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
return recipe 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: try:
import_image(src, recipe_id) import_image(src, recipe_id, extraction_root=extraction_root)
except UnidentifiedImageError as e: except UnidentifiedImageError as e:
self.logger.error(f"Failed to import image for {slug}: {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 ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias 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): class ChowdownMigrator(BaseMigrator):
@@ -60,8 +60,10 @@ class ChowdownMigrator(BaseMigrator):
continue continue
if r.image: 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: except StopIteration:
continue continue
if cd_image: 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 mealie.services.parser_services.parser_utils.string_utils import extract_quantity_from_string
from ._migration_base import BaseMigrator from ._migration_base import BaseMigrator
from .utils.migration_helpers import format_time from .utils.migration_helpers import format_time, safe_local_path
class DSVParser: class DSVParser:
@@ -157,15 +157,21 @@ class CooknMigrator(BaseMigrator):
if _media_type != "": if _media_type != "":
# Determine file extension based on media type # Determine file extension based on media type
_extension = _media_type.split("/")[-1] _extension = _media_type.split("/")[-1]
_old_image_path = os.path.join(db.directory, str(_media_id)) _old_image_path = Path(db.directory) / str(_media_id)
new_image_path = f"{_old_image_path}.{_extension}" 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 # 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) os.rename(_old_image_path, new_image_path)
if Path(new_image_path).exists(): if new_image_path.exists():
return new_image_path return str(new_image_path)
else: 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 return None
def _parse_ingredients(self, _recipe_id: str, db: DSVParser) -> list[RecipeIngredient]: def _parse_ingredients(self, _recipe_id: str, db: DSVParser) -> list[RecipeIngredient]:
@@ -388,14 +394,14 @@ class CooknMigrator(BaseMigrator):
recipe = recipe_lookup.get(slug) recipe = recipe_lookup.get(slug)
if recipe: if recipe:
if recipe.image: if recipe.image:
self.import_image(slug, recipe.image, recipe_id) self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
else: else:
index_len = len(slug.split("-")[-1]) index_len = len(slug.split("-")[-1])
recipe = recipe_lookup.get(slug[: -(index_len + 1)]) recipe = recipe_lookup.get(slug[: -(index_len + 1)])
if recipe: if recipe:
self.logger.warning("Duplicate recipe (%s) found! Saved as copy...", recipe.name) self.logger.warning("Duplicate recipe (%s) found! Saved as copy...", recipe.name)
if recipe.image: if recipe.image:
self.import_image(slug, recipe.image, recipe_id) self.import_image(slug, recipe.image, recipe_id, extraction_root=db.directory)
else: else:
self.logger.warning("Failed to lookup recipe! (%s)", slug) 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 ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias 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]: 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 # the recipe image tag has no id, so we parse it directly
if tag.name == "img" and "recipeImage" in tag.get("class", []): if tag.name == "img" and "recipeImage" in tag.get("class", []):
if image_path := tag.get("src"): 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 continue
@@ -120,4 +122,4 @@ class CopyMeThatMigrator(BaseMigrator):
except StopIteration: except StopIteration:
continue 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: if status:
nc_dir = nextcloud_dirs[slug] nc_dir = nextcloud_dirs[slug]
if nc_dir.image: 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.write(image.read())
temp_file.flush() temp_file.flush()
path = Path(temp_file.name) 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: except Exception as e:
self.logger.error(f"Failed to import image for {slug}: {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 ._migration_base import BaseMigrator
from .utils.migration_alias import MigrationAlias 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]: def clean_instructions(instructions: list[str]) -> list[str]:
@@ -30,7 +30,9 @@ def parse_recipe_div(recipe, image_path):
elif item.name == "div": elif item.name == "div":
meta[item["itemprop"]] = list(item.stripped_strings) meta[item["itemprop"]] = list(item.stripped_strings)
elif item.name == "img": 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: else:
meta[item["itemprop"]] = item.string meta[item["itemprop"]] = item.string
# merge nutrition keys into their own dict. # merge nutrition keys into their own dict.
@@ -107,4 +109,4 @@ class RecipeKeeperMigrator(BaseMigrator):
except StopIteration: except StopIteration:
continue 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: except StopIteration:
continue 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 PIL import UnidentifiedImageError
from pydantic import UUID4 from pydantic import UUID4
from mealie.core import root_logger
from mealie.services.recipe.recipe_data_service import RecipeDataService 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 return matches
def import_image(src: str | Path, recipe_id: UUID4): def safe_local_path(candidate: str | Path, root: Path) -> Path | None:
"""Read the successful migrations attribute and for each import the image """
appropriately into the image directory. Minification is done in mass Returns the resolved path only if it is safely contained within root.
after the migration occurs.
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. 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): if isinstance(src, str):
src = Path(src) 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(): if not src.exists():
return return

View File

@@ -129,19 +129,24 @@ class OpenAIService(BaseService):
""" """
tree = name.split(".") tree = name.split(".")
relative_path = Path(*tree[:-1], tree[-1] + ".txt") 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: try:
# Only include custom files if the custom_dir is configured, is a directory, and the prompt file exists # 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(): if custom_dir and not custom_dir.is_dir():
custom_dir = None custom_dir = None
except Exception: except Exception:
custom_dir = None custom_dir = None
if custom_dir: if custom_dir:
custom_prompt_file = Path(custom_dir, relative_path) custom_prompt_file = (custom_dir / relative_path).resolve()
if custom_prompt_file.exists(): 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}") logger.debug(f"Found valid custom prompt file: {custom_prompt_file}")
return [custom_prompt_file, default_prompt_file] return [custom_prompt_file, default_prompt_file]
else: else:

View File

@@ -99,7 +99,12 @@ class RecipeDataService(BaseService):
with open(image_path, "ab") as f: with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, 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 return image_path

View File

@@ -325,9 +325,11 @@ class RecipeService(RecipeServiceBase):
with get_temporary_path() as temp_path: with get_temporary_path() as temp_path:
local_images: list[Path] = [] local_images: list[Path] = []
for image in images: 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) 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( recipe_data = await openai_recipe_service.build_recipe_from_images(
local_images, translate_language=translate_language local_images, translate_language=translate_language

View File

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

View File

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

616
uv.lock generated

File diff suppressed because it is too large Load Diff