"""Flask hängt die API und die Seiten an den Kern.""" import json import re import secrets import time import uuid from datetime import datetime, timedelta from pathlib import Path from urllib.parse import quote import markdown import psycopg from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for from markupsafe import Markup from psycopg.rows import dict_row from werkzeug.middleware.proxy_fix import ProxyFix from grotti import auth as grotti_auth from grotti import config as grotti_config from grotti import db as grotti_db from grotti import eject, jobs, library, logutil, moonraker, notify, store from grotti.catalog import color_catalog from grotti.cost import calculate, offer_from_file, order_actuals from grotti import orders as grotti_orders from grotti.errors import GrottiError from grotti.printers import FAMILY_LABELS, family_from_name from grotti.print_queue import PrintQueueDispatcher from grotti.send_queue import SendQueue from grotti.toolpath import TOOLPATH_VERSION, build_toolpath from grotti.version import __version__ from grotti import verdict as grotti_verdict from grotti.watcher import StateWatcher log = logutil.get("grotti.web") ROOT = Path(__file__).resolve().parent TOOL_ROOT = ROOT.parent DOCS_DIR = TOOL_ROOT / "docs" PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"} VERDICTS = {"", "gut", "ausschuss"} VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS) PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf") HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$") SERIAL_RE = re.compile( r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$", re.IGNORECASE, ) TOKEN_QUERY_PATHS = ( "/api/v1/kamera", "/api/v1/kamera/stream", "/api/v1/vorschau", "/api/v1/dateien/", "/api/v1/projekte/", ) STEER_ACTIONS = { "nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error", "exclude_object", "dryer_start", "dryer_stop", "slot_setzen", } def validate_printer_host(host): host = (host or "").strip() if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)): raise GrottiError( "Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…", "host", ) return host def create_app(cfg): app = Flask( __name__, template_folder=str(ROOT / "templates"), static_folder=str(ROOT / "static"), ) app.config["GROTTI"] = cfg app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32) app.config["TEMPLATES_AUTO_RELOAD"] = True app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # Orca-App-Fenster schließt oft — Session-Cookie muss überleben (30 Tage, gleitend). app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) app.config["SESSION_REFRESH_EACH_REQUEST"] = True if cfg.get("fqdn", "").startswith("https://"): app.config["SESSION_COOKIE_SECURE"] = True app.json.ensure_ascii = False # Hinter Reverse Proxy: X-Forwarded-For für Rate-Limits. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # Threads starten in server.main() — Tests können create_app ohne Netz/DB-Worker nutzen. app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg) watcher = StateWatcher(cfg["database_url"], cfg) dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher) app.extensions["print_queue"] = dispatcher app.extensions["watcher"] = watcher PUBLIC_PATHS = { "/login", "/setup", "/api/v1/auth/status", "/api/v1/auth/login", "/api/v1/auth/token", "/api/v1/auth/setup", "/api/v1/version", } ADMIN_API_PREFIXES = ( "/api/v1/users", "/api/v1/einstellungen", "/api/v1/backup", ) ADMIN_API_EXACT = { "/api/v1/telegram/test", "/api/v1/ntfy/test", } ADMIN_METHODS_ON = { # Drucker anlegen/ändern/löschen nur Admin ("POST", "/api/v1/drucker"), } def conn(): if "db" not in g: try: pool = grotti_db.get_pool() g.db = pool.getconn() g.db_from_pool = True except RuntimeError: g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row) g.db_from_pool = False return g.db def ensure_csrf(): if not session.get("csrf"): session["csrf"] = grotti_auth.new_csrf() return session["csrf"] def auth_user(): return getattr(g, "user", None) def require_role(need): user = auth_user() if not user or not grotti_auth.role_at_least(user.get("role"), need): raise GrottiError("Keine Berechtigung.", "forbidden", 403) return user def resolve_bearer(presented): if not presented.startswith(grotti_auth.TOKEN_PREFIX): return None row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented)) if not row or row.get("revoked_at") or not row.get("user_active"): return None if row.get("expires_at") and row["expires_at"].timestamp() < time.time(): return None store.touch_api_token(conn(), row["id"]) return { "id": row["user_id"], "username": row["username"], "role": row["role"], "auth": "token", "token_id": row["id"], } def resolve_session_user(): uid = session.get("user_id") if not uid: return None row = store.get_user(conn(), uid) if not row or not row.get("active"): session.clear() return None return { "id": row["id"], "username": row["username"], "role": row["role"], "auth": "session", } def wants_json(): if request.path.startswith("/api/"): return True accept = request.accept_mimetypes return accept.best == "application/json" @app.before_request def check_auth(): g.user = None path = request.path if path.startswith("/static/"): return None if path in PUBLIC_PATHS: return None users_exist = store.user_count(conn()) > 0 if not users_exist: if path.startswith("/api/") and path != "/api/v1/auth/setup": if path == "/api/v1/auth/status": return None return jsonify({ "error": "Zuerst Admin anlegen.", "code": "setup", "setup": True, }), 503 if path != "/setup": return redirect(url_for("setup_page")) return None presented = "" header = request.headers.get("Authorization", "") if header.startswith("Bearer "): presented = header[7:].strip() elif ( request.method == "GET" and any(request.path.startswith(p) for p in TOKEN_QUERY_PATHS) ): # Token in der URL nur für Bild-Endpunkte (img-src ohne Header). presented = (request.args.get("token") or "").strip() user = None if presented: user = resolve_bearer(presented) if not user: return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401 else: user = resolve_session_user() if not user: if wants_json() or path.startswith("/api/"): return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401 # Query behalten (z. B. /dateien?file=12 nach Orca-Upload) next_path = path if request.query_string: next_path = path + "?" + request.query_string.decode("utf-8", "replace") return redirect(url_for("login_page", next=next_path)) g.user = user ensure_csrf() # CSRF für Cookie-Sessions bei mutierenden API-Calls if ( user.get("auth") == "session" and path.startswith("/api/") and request.method in ("POST", "PATCH", "PUT", "DELETE") ): sent = request.headers.get("X-CSRF-Token") or "" expected = session.get("csrf") or "" if not expected or not secrets.compare_digest(sent, expected): return jsonify({"error": "CSRF-Token fehlt oder ungültig.", "code": "csrf"}), 403 need_admin = False if path.startswith(ADMIN_API_PREFIXES) or path in ADMIN_API_EXACT: if path == "/api/v1/einstellungen" and request.method == "GET": need_admin = False elif path == "/api/v1/einstellungen" and request.method == "PATCH": # Operator darf nur Automatik-Schalter setzen payload = request.get_json(silent=True) or {} keys = {k for k, v in payload.items() if v is not None} need_admin = keys - {"auto_queue", "auto_retry"} != set() elif path.startswith("/api/v1/users"): need_admin = True elif request.method != "GET": need_admin = True if (request.method, path) in ADMIN_METHODS_ON: need_admin = True if path.startswith("/api/v1/drucker/id/") and request.method in ("PATCH", "DELETE"): need_admin = True if need_admin and not grotti_auth.role_at_least(user.get("role"), "admin"): return jsonify({"error": "Nur für Admins.", "code": "forbidden"}), 403 return None @app.teardown_appcontext def close_db(exc): db_conn = g.pop("db", None) from_pool = g.pop("db_from_pool", False) if not db_conn: return try: if exc: db_conn.rollback() else: db_conn.commit() except Exception: try: db_conn.rollback() except Exception: pass finally: if from_pool: try: grotti_db.get_pool().putconn(db_conn) except Exception: try: db_conn.close() except Exception: pass else: db_conn.close() @app.errorhandler(GrottiError) def grotti_error(err): db_conn = g.get("db") if db_conn: try: db_conn.rollback() except Exception: pass return jsonify({"error": err.message, "code": err.code}), err.status @app.errorhandler(psycopg.Error) def psycopg_error(err): db_conn = g.get("db") if db_conn: try: db_conn.rollback() except Exception: pass message = "Datenbankfehler." if isinstance(err, psycopg.errors.ForeignKeyViolation): message = "Der Eintrag hängt noch an anderen Daten." elif isinstance(err, psycopg.errors.UniqueViolation): message = "Diesen Eintrag gibt es schon." elif isinstance(err, psycopg.errors.InvalidTextRepresentation): message = "Ungültiger Wert in der Anfrage." log.warning("DB: %s", err) return jsonify({"error": message, "code": "db"}), 500 def body(): data = request.get_json(silent=True) if not isinstance(data, dict): raise GrottiError("JSON-Objekt erwartet.", "json") return data def optional_id(value): if value in (None, "", 0, "0"): return None try: return int(value) except (TypeError, ValueError) as exc: raise GrottiError("Die Nummer stimmt nicht.", "id") from exc def require_host(host): if not store.printer_by_host(conn(), host or ""): raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) return host def optional_host(host): host = (host or "").strip() if host: require_host(host) return host _icons_cache = {"html": "", "mtime": None} def icons_sprite_html(): path = ROOT / "static" / "icons.svg" try: mtime = path.stat().st_mtime except OSError: return "" if _icons_cache["html"] and _icons_cache["mtime"] == mtime: return _icons_cache["html"] text = path.read_text(encoding="utf-8") text = re.sub(r"<\?xml[^?]*\?>\s*", "", text) text = re.sub(r"\s*", "", text, count=1, flags=re.DOTALL) text = text.replace("