mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-20 11:55:35 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc4851ef5 | ||
|
|
d9e933d5ae | ||
|
|
0a07835338 | ||
|
|
7a85ea6ae9 | ||
|
|
c4c60f1645 | ||
|
|
9f7ba8dc08 | ||
|
|
c4799ceb9e | ||
|
|
828be095a2 | ||
|
|
18718fb647 | ||
|
|
fb545962dd | ||
|
|
781a08ef54 | ||
|
|
a7a08b6b11 | ||
|
|
bd296c3eaf | ||
|
|
8aa016e57b | ||
|
|
480574eb3d |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
4468
frontend/yarn.lock
4468
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
125
mealie/routes/spa/manifest.py
Normal file
125
mealie/routes/spa/manifest.py
Normal 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"},
|
||||||
|
)
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user