mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-03 06:23:10 -05:00
feat(frontend): ✨ Fix scheduler, forgot password flow, and minor bug fixes (#725)
* feat(frontend): 💄 add recipe title * fix(frontend): 🐛 fixes #722 side-bar issue * feat(frontend): ✨ Add page titles to all pages * minor cleanup * refactor(backend): ♻️ rewrite scheduler to be more modulare and work * feat(frontend): ✨ start password reset functionality * refactor(backend): ♻️ refactor application settings to facilitate dependency injection * refactor(backend): 🔥 remove RECIPE_SETTINGS env variables in favor of group settings * formatting * refactor(backend): ♻️ align naming convention * feat(backend): ✨ password reset * test(backend): ✅ password reset * feat(frontend): ✨ self-service password reset * purge password schedule * update user creation for tests Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
@@ -1,209 +1,39 @@
|
||||
import os
|
||||
import secrets
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import dotenv
|
||||
from pydantic import BaseSettings, Field, PostgresDsn, validator
|
||||
|
||||
APP_VERSION = "v1.0.0b"
|
||||
DB_VERSION = "v1.0.0b"
|
||||
from mealie.core.settings.settings import app_settings_constructor
|
||||
|
||||
from .settings import AppDirectories, AppSettings
|
||||
from .settings.static import APP_VERSION, DB_VERSION
|
||||
|
||||
APP_VERSION
|
||||
DB_VERSION
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
BASE_DIR = CWD.parent.parent
|
||||
|
||||
ENV = BASE_DIR.joinpath(".env")
|
||||
|
||||
dotenv.load_dotenv(ENV)
|
||||
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
|
||||
|
||||
|
||||
def determine_data_dir(production: bool) -> Path:
|
||||
global CWD
|
||||
if production:
|
||||
def determine_data_dir() -> Path:
|
||||
global PRODUCTION
|
||||
global BASE_DIR
|
||||
if PRODUCTION:
|
||||
return Path("/app/data")
|
||||
|
||||
return CWD.parent.parent.joinpath("dev", "data")
|
||||
|
||||
|
||||
def determine_secrets(data_dir: Path, production: bool) -> str:
|
||||
if not production:
|
||||
return "shh-secret-test-key"
|
||||
|
||||
secrets_file = data_dir.joinpath(".secret")
|
||||
if secrets_file.is_file():
|
||||
with open(secrets_file, "r") as f:
|
||||
return f.read()
|
||||
else:
|
||||
with open(secrets_file, "w") as f:
|
||||
new_secret = secrets.token_hex(32)
|
||||
f.write(new_secret)
|
||||
return new_secret
|
||||
|
||||
|
||||
# General
|
||||
DATA_DIR = determine_data_dir(PRODUCTION)
|
||||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, cwd, data_dir) -> None:
|
||||
self.DATA_DIR: Path = data_dir
|
||||
self.WEB_PATH: Path = cwd.joinpath("dist")
|
||||
self.IMG_DIR: Path = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
|
||||
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
|
||||
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
|
||||
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
|
||||
self.USER_DIR: Path = data_dir.joinpath("users")
|
||||
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
|
||||
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
|
||||
|
||||
self.ensure_directories()
|
||||
|
||||
def ensure_directories(self):
|
||||
required_dirs = [
|
||||
self.IMG_DIR,
|
||||
self.BACKUP_DIR,
|
||||
self.DEBUG_DIR,
|
||||
self.MIGRATION_DIR,
|
||||
self.TEMPLATE_DIR,
|
||||
self.NEXTCLOUD_DIR,
|
||||
self.CHOWDOWN_DIR,
|
||||
self.RECIPE_DATA_DIR,
|
||||
self.USER_DIR,
|
||||
]
|
||||
|
||||
for dir in required_dirs:
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
app_dirs = AppDirectories(CWD, DATA_DIR)
|
||||
|
||||
|
||||
def determine_sqlite_path(path=False, suffix=DB_VERSION) -> str:
|
||||
global app_dirs
|
||||
db_path = app_dirs.DATA_DIR.joinpath(f"mealie_{suffix}.db") # ! Temporary Until Alembic
|
||||
|
||||
if path:
|
||||
return db_path
|
||||
|
||||
return "sqlite:///" + str(db_path.absolute())
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
global DATA_DIR
|
||||
PRODUCTION: bool = Field(True, env="PRODUCTION")
|
||||
BASE_URL: str = "http://localhost:8080"
|
||||
IS_DEMO: bool = False
|
||||
API_PORT: int = 9000
|
||||
API_DOCS: bool = True
|
||||
|
||||
@property
|
||||
def DOCS_URL(self) -> str:
|
||||
return "/docs" if self.API_DOCS else None
|
||||
|
||||
@property
|
||||
def REDOC_URL(self) -> str:
|
||||
return "/redoc" if self.API_DOCS else None
|
||||
|
||||
SECRET: str = determine_secrets(DATA_DIR, PRODUCTION)
|
||||
|
||||
DB_ENGINE: str = "sqlite" # Optional: 'sqlite', 'postgres'
|
||||
POSTGRES_USER: str = "mealie"
|
||||
POSTGRES_PASSWORD: str = "mealie"
|
||||
POSTGRES_SERVER: str = "postgres"
|
||||
POSTGRES_PORT: str = 5432
|
||||
POSTGRES_DB: str = "mealie"
|
||||
|
||||
DB_URL: Union[str, PostgresDsn] = None # Actual DB_URL is calculated with `assemble_db_connection`
|
||||
|
||||
@validator("DB_URL", pre=True)
|
||||
def assemble_db_connection(cls, v: Optional[str], values: dict[str, Any]) -> Any:
|
||||
engine = values.get("DB_ENGINE", "sqlite")
|
||||
if engine == "postgres":
|
||||
host = f"{values.get('POSTGRES_SERVER')}:{values.get('POSTGRES_PORT')}"
|
||||
return PostgresDsn.build(
|
||||
scheme="postgresql",
|
||||
user=values.get("POSTGRES_USER"),
|
||||
password=values.get("POSTGRES_PASSWORD"),
|
||||
host=host,
|
||||
path=f"/{values.get('POSTGRES_DB') or ''}",
|
||||
)
|
||||
return determine_sqlite_path()
|
||||
|
||||
DB_URL_PUBLIC: str = "" # hide credentials to show on logs/frontend
|
||||
|
||||
@validator("DB_URL_PUBLIC", pre=True)
|
||||
def public_db_url(cls, v: Optional[str], values: dict[str, Any]) -> str:
|
||||
url = values.get("DB_URL")
|
||||
engine = values.get("DB_ENGINE", "sqlite")
|
||||
if engine != "postgres":
|
||||
# sqlite
|
||||
return url
|
||||
|
||||
user = values.get("POSTGRES_USER")
|
||||
password = values.get("POSTGRES_PASSWORD")
|
||||
return url.replace(user, "*****", 1).replace(password, "*****", 1)
|
||||
|
||||
DEFAULT_GROUP: str = "Home"
|
||||
DEFAULT_EMAIL: str = "changeme@email.com"
|
||||
DEFAULT_PASSWORD: str = "MyPassword"
|
||||
|
||||
SCHEDULER_DATABASE = f"sqlite:///{app_dirs.DATA_DIR.joinpath('scheduler.db')}"
|
||||
|
||||
TOKEN_TIME: int = 2 # Time in Hours
|
||||
|
||||
# Recipe Default Settings
|
||||
RECIPE_PUBLIC: bool = True
|
||||
RECIPE_SHOW_NUTRITION: bool = True
|
||||
RECIPE_SHOW_ASSETS: bool = True
|
||||
RECIPE_LANDSCAPE_VIEW: bool = True
|
||||
RECIPE_DISABLE_COMMENTS: bool = False
|
||||
RECIPE_DISABLE_AMOUNT: bool = False
|
||||
|
||||
# ===============================================
|
||||
# Email Configuration
|
||||
SMTP_HOST: Optional[str]
|
||||
SMTP_PORT: Optional[str] = "587"
|
||||
SMTP_FROM_NAME: Optional[str] = "Mealie"
|
||||
SMTP_TLS: Optional[bool] = True
|
||||
SMTP_FROM_EMAIL: Optional[str]
|
||||
SMTP_USER: Optional[str]
|
||||
SMTP_PASSWORD: Optional[str]
|
||||
|
||||
@property
|
||||
def SMTP_ENABLE(self) -> bool:
|
||||
"""Validates all SMTP variables are set"""
|
||||
required = {
|
||||
self.SMTP_HOST,
|
||||
self.SMTP_PORT,
|
||||
self.SMTP_FROM_NAME,
|
||||
self.SMTP_TLS,
|
||||
self.SMTP_FROM_EMAIL,
|
||||
self.SMTP_USER,
|
||||
self.SMTP_PASSWORD,
|
||||
}
|
||||
|
||||
return "" not in required and None not in required
|
||||
|
||||
class Config:
|
||||
env_file = BASE_DIR.joinpath(".env")
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
return BASE_DIR.joinpath("dev", "data")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_app_dirs() -> AppDirectories:
|
||||
global app_dirs
|
||||
return app_dirs
|
||||
return AppDirectories(determine_data_dir())
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> AppSettings:
|
||||
global settings
|
||||
return settings
|
||||
def get_app_settings() -> AppSettings:
|
||||
return app_settings_constructor(env_file=ENV, production=PRODUCTION, data_dir=determine_data_dir())
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import app_dirs, settings
|
||||
from mealie.core.config import get_app_dirs, get_app_settings
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData
|
||||
@@ -16,6 +16,8 @@ from mealie.schema.user import LongLiveTokenInDB, PrivateUser, TokenData
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
|
||||
ALGORITHM = "HS256"
|
||||
app_dirs = get_app_dirs()
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)) -> bool:
|
||||
|
||||
@@ -3,9 +3,13 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from mealie.core.config import DATA_DIR
|
||||
from mealie.core.config import determine_data_dir
|
||||
|
||||
from .config import settings
|
||||
DATA_DIR = determine_data_dir()
|
||||
|
||||
from .config import get_app_settings
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
|
||||
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from mealie.core.config import settings
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.database import get_database
|
||||
from mealie.schema.user import PrivateUser
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@@ -43,26 +46,15 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser:
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Compares a plain string to a hashed password
|
||||
|
||||
Args:
|
||||
plain_password (str): raw password string
|
||||
hashed_password (str): hashed password from the database
|
||||
|
||||
Returns:
|
||||
bool: Returns True if a match return False
|
||||
"""
|
||||
"""Compares a plain string to a hashed password"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Takes in a raw password and hashes it. Used prior to saving
|
||||
a new password to the database.
|
||||
|
||||
Args:
|
||||
password (str): Password String
|
||||
|
||||
Returns:
|
||||
str: Hashed Password
|
||||
"""
|
||||
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def url_safe_token() -> str:
|
||||
"""Generates a cryptographic token without embedded data. Used for password reset tokens and invitation tokens"""
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
2
mealie/core/settings/__init__.py
Normal file
2
mealie/core/settings/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .directories import *
|
||||
from .settings import *
|
||||
65
mealie/core/settings/db_providers.py
Normal file
65
mealie/core/settings/db_providers.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from abc import ABC, abstractproperty
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, BaseSettings, PostgresDsn
|
||||
|
||||
|
||||
class AbstractDBProvider(ABC):
|
||||
@abstractproperty
|
||||
def db_url(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
def db_url_public(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class SQLiteProvider(AbstractDBProvider, BaseModel):
|
||||
data_dir: Path
|
||||
prefix: str = ""
|
||||
|
||||
@property
|
||||
def db_path(self):
|
||||
return self.data_dir / f"{self.prefix}mealie.db"
|
||||
|
||||
@property
|
||||
def db_url(self) -> str:
|
||||
return "sqlite:///" + str(self.db_path.absolute())
|
||||
|
||||
@property
|
||||
def db_url_public(self) -> str:
|
||||
return self.db_url
|
||||
|
||||
|
||||
class PostgresProvider(AbstractDBProvider, BaseSettings):
|
||||
POSTGRES_USER: str = "mealie"
|
||||
POSTGRES_PASSWORD: str = "mealie"
|
||||
POSTGRES_SERVER: str = "postgres"
|
||||
POSTGRES_PORT: str = 5432
|
||||
POSTGRES_DB: str = "mealie"
|
||||
|
||||
@property
|
||||
def db_url(self) -> str:
|
||||
host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}"
|
||||
return PostgresDsn.build(
|
||||
scheme="postgresql",
|
||||
user=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=host,
|
||||
path=f"/{self.POSTGRES_DB or ''}",
|
||||
)
|
||||
|
||||
@property
|
||||
def db_url_public(self) -> str:
|
||||
user = self.POSTGRES_USER
|
||||
password = self.POSTGRES_PASSWORD
|
||||
return self.db_url.replace(user, "*****", 1).replace(password, "*****", 1)
|
||||
|
||||
|
||||
def db_provider_factory(provider_name: str, data_dir: Path, env_file: Path, env_encoding="utf-8") -> AbstractDBProvider:
|
||||
if provider_name == "sqlite":
|
||||
return SQLiteProvider(data_dir=data_dir)
|
||||
elif provider_name == "postgres":
|
||||
return PostgresProvider(_env_file=env_file, _env_file_encoding=env_encoding)
|
||||
else:
|
||||
return
|
||||
34
mealie/core/settings/directories.py
Normal file
34
mealie/core/settings/directories.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, data_dir) -> None:
|
||||
self.DATA_DIR: Path = data_dir
|
||||
self.IMG_DIR: Path = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
|
||||
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
|
||||
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
|
||||
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
|
||||
self.USER_DIR: Path = data_dir.joinpath("users")
|
||||
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
|
||||
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
|
||||
|
||||
self.ensure_directories()
|
||||
|
||||
def ensure_directories(self):
|
||||
required_dirs = [
|
||||
self.IMG_DIR,
|
||||
self.BACKUP_DIR,
|
||||
self.DEBUG_DIR,
|
||||
self.MIGRATION_DIR,
|
||||
self.TEMPLATE_DIR,
|
||||
self.NEXTCLOUD_DIR,
|
||||
self.CHOWDOWN_DIR,
|
||||
self.RECIPE_DATA_DIR,
|
||||
self.USER_DIR,
|
||||
]
|
||||
|
||||
for dir in required_dirs:
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
109
mealie/core/settings/settings.py
Normal file
109
mealie/core/settings/settings.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
from .db_providers import AbstractDBProvider, db_provider_factory
|
||||
|
||||
|
||||
def determine_secrets(data_dir: Path, production: bool) -> str:
|
||||
if not production:
|
||||
return "shh-secret-test-key"
|
||||
|
||||
secrets_file = data_dir.joinpath(".secret")
|
||||
if secrets_file.is_file():
|
||||
with open(secrets_file, "r") as f:
|
||||
return f.read()
|
||||
else:
|
||||
with open(secrets_file, "w") as f:
|
||||
new_secret = secrets.token_hex(32)
|
||||
f.write(new_secret)
|
||||
return new_secret
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
PRODUCTION: bool
|
||||
BASE_URL: str = "http://localhost:8080"
|
||||
IS_DEMO: bool = False
|
||||
API_PORT: int = 9000
|
||||
API_DOCS: bool = True
|
||||
TOKEN_TIME: int = 48 # Time in Hours
|
||||
SECRET: str
|
||||
|
||||
@property
|
||||
def DOCS_URL(self) -> str:
|
||||
return "/docs" if self.API_DOCS else None
|
||||
|
||||
@property
|
||||
def REDOC_URL(self) -> str:
|
||||
return "/redoc" if self.API_DOCS else None
|
||||
|
||||
# ===============================================
|
||||
# Database Configuration
|
||||
|
||||
DB_ENGINE: str = "sqlite" # Options: 'sqlite', 'postgres'
|
||||
DB_PROVIDER: AbstractDBProvider = None
|
||||
|
||||
@property
|
||||
def DB_URL(self) -> str:
|
||||
return self.DB_PROVIDER.db_url
|
||||
|
||||
@property
|
||||
def DB_URL_PUBLIC(self) -> str:
|
||||
return self.DB_PROVIDER.db_url_public
|
||||
|
||||
DEFAULT_GROUP: str = "Home"
|
||||
DEFAULT_EMAIL: str = "changeme@email.com"
|
||||
DEFAULT_PASSWORD: str = "MyPassword"
|
||||
|
||||
# ===============================================
|
||||
# Email Configuration
|
||||
|
||||
SMTP_HOST: Optional[str]
|
||||
SMTP_PORT: Optional[str] = "587"
|
||||
SMTP_FROM_NAME: Optional[str] = "Mealie"
|
||||
SMTP_TLS: Optional[bool] = True
|
||||
SMTP_FROM_EMAIL: Optional[str]
|
||||
SMTP_USER: Optional[str]
|
||||
SMTP_PASSWORD: Optional[str]
|
||||
|
||||
@property
|
||||
def SMTP_ENABLE(self) -> bool:
|
||||
"""Validates all SMTP variables are set"""
|
||||
required = {
|
||||
self.SMTP_HOST,
|
||||
self.SMTP_PORT,
|
||||
self.SMTP_FROM_NAME,
|
||||
self.SMTP_TLS,
|
||||
self.SMTP_FROM_EMAIL,
|
||||
self.SMTP_USER,
|
||||
self.SMTP_PASSWORD,
|
||||
}
|
||||
|
||||
return "" not in required and None not in required
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
|
||||
"""
|
||||
app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the
|
||||
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated
|
||||
directly, but rather through this factory function.
|
||||
"""
|
||||
app_settings = AppSettings(
|
||||
_env_file=env_file,
|
||||
_env_file_encoding=env_encoding,
|
||||
**{"SECRET": determine_secrets(data_dir, production)},
|
||||
)
|
||||
|
||||
app_settings.DB_PROVIDER = db_provider_factory(
|
||||
app_settings.DB_ENGINE or "sqlite",
|
||||
data_dir,
|
||||
env_file=env_file,
|
||||
env_encoding=env_encoding,
|
||||
)
|
||||
|
||||
return app_settings
|
||||
10
mealie/core/settings/static.py
Normal file
10
mealie/core/settings/static.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_VERSION = "v1.0.0b"
|
||||
DB_VERSION = "v1.0.0b"
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
BASE_DIR = CWD.parent.parent.parent
|
||||
|
||||
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
|
||||
Reference in New Issue
Block a user