from __future__ import annotations import json import os import secrets from datetime import date, timedelta from pathlib import Path from flask import Flask, flash, g, redirect, request, send_from_directory, url_for from . import db from .admin import admin_bp from .auth import auth_bp from .constants import ( BUILDER_DESCRIPTIONS, BUILDER_LABELS, BUILDER_OPTIONS, DAYPARTS, DEFAULT_CATEGORIES, ENERGY_DENSITY_LABELS, ENERGY_DENSITY_OPTIONS, FOOD_ROLE_DESCRIPTIONS, FOOD_ROLE_LABELS, FOOD_ROLE_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, MEAL_STYLE_LABELS, MEAL_STYLE_OPTIONS, MEAL_TYPE_LABELS, MEAL_TYPE_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS, PROTEIN_PREFERENCE_LABELS, PROTEIN_PREFERENCE_OPTIONS, ROLE_LABELS, SUGGESTION_PRIORITY_LABELS, SUGGESTION_PRIORITY_OPTIONS, SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, WEEKDAY_OPTIONS, ) from .images import ensure_upload_structure, image_sizes, image_srcset, image_url from .main import main_bp WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases" DAYPART_ICON_CLASSES = { "breakfast": "icon-daypart-breakfast", "morning-snack": "icon-daypart-morning-snack", "lunch": "icon-daypart-lunch", "afternoon-snack": "icon-daypart-afternoon-snack", "dinner": "icon-daypart-dinner", "late-snack": "icon-daypart-late-snack", } def load_secret_key(data_dir: Path) -> str: env_secret = os.environ.get("NOURI_SECRET_KEY") if env_secret: return env_secret secret_path = data_dir / ".secret_key" if secret_path.exists(): return secret_path.read_text(encoding="utf-8").strip() secret_value = secrets.token_hex(24) try: with secret_path.open("x", encoding="utf-8") as handle: handle.write(secret_value) except FileExistsError: return secret_path.read_text(encoding="utf-8").strip() return secret_value def load_app_version(root_dir: Path) -> str: env_version = os.environ.get("NOURI_APP_VERSION", "").strip() if env_version: return env_version manifest_path = root_dir / "CloudronManifest.json" if manifest_path.exists(): try: manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) except json.JSONDecodeError: manifest_data = {} manifest_version = str( manifest_data.get("upstreamVersion") or manifest_data.get("version") or "" ).strip() if manifest_version: return manifest_version return "1.3.0" def load_release_url() -> str: return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL def create_app() -> Flask: root_dir = Path(__file__).resolve().parent.parent data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() upload_dir = data_dir / "uploads" db_path = data_dir / "nouri.sqlite3" app_version = load_app_version(root_dir) release_url = load_release_url() data_dir.mkdir(parents=True, exist_ok=True) ensure_upload_structure(upload_dir) app = Flask(__name__, instance_relative_config=False) app.config.update( SECRET_KEY=load_secret_key(data_dir), DATABASE_PATH=str(db_path), DATA_DIR=str(data_dir), UPLOAD_FOLDER=str(upload_dir), MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024, PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1", APP_VERSION=app_version, RELEASE_URL=release_url, TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"), VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), ) db.init_app(app) db.init_db_if_needed(app) app.register_blueprint(auth_bp) app.register_blueprint(admin_bp) app.register_blueprint(main_bp) @app.context_processor def inject_globals() -> dict[str, object]: def asset_url(filename: str) -> str: file_path = root_dir / "nouri" / "static" / filename version = int(file_path.stat().st_mtime) if file_path.exists() else app.config["APP_VERSION"] return url_for("static", filename=filename, v=version) return { "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "category_suggestions": DEFAULT_CATEGORIES, "builder_labels": BUILDER_LABELS, "builder_descriptions": BUILDER_DESCRIPTIONS, "builder_options": BUILDER_OPTIONS, "food_role_labels": FOOD_ROLE_LABELS, "food_role_descriptions": FOOD_ROLE_DESCRIPTIONS, "food_role_options": FOOD_ROLE_OPTIONS, "suggestion_priority_labels": SUGGESTION_PRIORITY_LABELS, "suggestion_priority_options": SUGGESTION_PRIORITY_OPTIONS, "daypart_suggestions": DAYPARTS, "energy_density_options": ENERGY_DENSITY_OPTIONS, "energy_density_labels": ENERGY_DENSITY_LABELS, "meal_type_options": MEAL_TYPE_OPTIONS, "meal_type_labels": MEAL_TYPE_LABELS, "meal_style_options": MEAL_STYLE_OPTIONS, "meal_style_labels": MEAL_STYLE_LABELS, "suggestion_style_options": SUGGESTION_STYLE_OPTIONS, "suggestion_style_labels": SUGGESTION_STYLE_LABELS, "protein_preference_options": PROTEIN_PREFERENCE_OPTIONS, "protein_preference_labels": PROTEIN_PREFERENCE_LABELS, "visibility_labels": VISIBILITY_LABELS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS, "role_labels": ROLE_LABELS, "weekday_options": WEEKDAY_OPTIONS, "notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS, "today": date.today(), "app_version": app.config["APP_VERSION"], "app_release_url": app.config["RELEASE_URL"], "push_public_key": app.config["VAPID_PUBLIC_KEY"], "push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]), "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "daypart_icon_class": lambda slug: DAYPART_ICON_CLASSES.get(slug, "icon-calendar"), "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", "asset_url": asset_url, "image_url": lambda filename, variant="md": image_url( filename, url_for, variant, upload_folder=app.config["UPLOAD_FOLDER"], ), "image_srcset": lambda filename: image_srcset( filename, url_for, upload_folder=app.config["UPLOAD_FOLDER"], ), "image_sizes": image_sizes, } @app.get("/uploads/") def uploaded_file(filename: str): response = send_from_directory(app.config["UPLOAD_FOLDER"], filename, max_age=60 * 60 * 24 * 30) response.headers["Cache-Control"] = "public, max-age=2592000, immutable" return response @app.get("/app.webmanifest") def webmanifest(): response = send_from_directory( root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json", max_age=60 * 30, ) response.headers["Cache-Control"] = "public, max-age=1800" return response @app.get("/service-worker.js") def service_worker(): response = send_from_directory( root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript", max_age=0, ) response.headers["Cache-Control"] = "no-store" return response @app.after_request def apply_cache_policy(response): if response.direct_passthrough: return response content_type = response.headers.get("Content-Type", "") if content_type.startswith("text/html"): response.headers.setdefault("Cache-Control", "no-store, max-age=0") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "same-origin") response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") return response @app.errorhandler(413) def upload_too_large(_error): flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error") return redirect(request.referrer or url_for("main.dashboard")) return app