245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
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.2.1"
|
|
|
|
|
|
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/<path:filename>")
|
|
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
|