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:
Hayden
2021-10-07 09:39:47 -08:00
committed by GitHub
parent d1f0441252
commit 2e9026f9ea
121 changed files with 1461 additions and 679 deletions

View File

@@ -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())

View File

@@ -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:

View File

@@ -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"

View File

@@ -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)

View File

@@ -0,0 +1,2 @@
from .directories import *
from .settings import *

View 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

View 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)

View 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

View 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"]