from __future__ import annotations 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, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, NOTIFICATION_CHANNEL_OPTIONS, ROLE_LABELS, 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"] 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 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" 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="1.0.0", 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, "daypart_suggestions": DAYPARTS, "energy_density_options": ENERGY_DENSITY_OPTIONS, "energy_density_labels": ENERGY_DENSITY_LABELS, "suggestion_style_options": SUGGESTION_STYLE_OPTIONS, "suggestion_style_labels": SUGGESTION_STYLE_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"], "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()], "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