mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-23 13:25:35 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a08b6b11 | ||
|
|
bd296c3eaf | ||
|
|
8aa016e57b | ||
|
|
480574eb3d |
@@ -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.15.2`
|
||||||
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.15.2 # (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.15.2 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mealie",
|
"name": "mealie",
|
||||||
"version": "3.15.1",
|
"version": "3.15.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.15.2"
|
||||||
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"
|
||||||
@@ -26,7 +26,7 @@ dependencies = [
|
|||||||
"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",
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -864,7 +864,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mealie"
|
name = "mealie"
|
||||||
version = "3.15.1"
|
version = "3.15.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -975,7 +975,7 @@ requires-dist = [
|
|||||||
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
|
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
|
||||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||||
{ name = "python-ldap", specifier = "==3.4.5" },
|
{ name = "python-ldap", specifier = "==3.4.5" },
|
||||||
{ name = "python-multipart", specifier = "==0.0.24" },
|
{ name = "python-multipart", specifier = "==0.0.26" },
|
||||||
{ name = "python-slugify", specifier = "==8.0.4" },
|
{ name = "python-slugify", specifier = "==8.0.4" },
|
||||||
{ name = "pyyaml", specifier = "==6.0.3" },
|
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||||
{ name = "rapidfuzz", specifier = "==3.14.5" },
|
{ name = "rapidfuzz", specifier = "==3.14.5" },
|
||||||
@@ -1627,11 +1627,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1c
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-multipart"
|
name = "python-multipart"
|
||||||
version = "0.0.24"
|
version = "0.0.26"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user