Grotti 3.0 Phase 1: Migrationen, Pool, Queue- und Abzug-Fixes.

Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs,
sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:47:23 +02:00
1 parent 9e22cebb81
commit 0236278929
19 files changed
+986 -81

No files matched your search

+93 -19
View File
@@ -14,9 +14,11 @@ import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, 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, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate
@@ -35,12 +37,30 @@ PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/vorschau",
)
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 not HOST_RE.fullmatch(host):
raise GrottiError(
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
"host",
)
if ".." in host:
raise GrottiError("Ungültiger Host.", "host")
return host
def create_app(cfg):
app = Flask(
__name__,
@@ -56,13 +76,14 @@ def create_app(cfg):
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
watcher.start()
dispatcher.start()
PUBLIC_PATHS = {
"/login",
@@ -86,7 +107,13 @@ def create_app(cfg):
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
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():
@@ -176,7 +203,11 @@ def create_app(cfg):
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
presented = header[7:].strip()
elif request.method == "GET" and request.path.startswith("/api/"):
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
@@ -233,24 +264,60 @@ def create_app(cfg):
@app.teardown_appcontext
def close_db(exc):
conn = g.pop("db", None)
if not conn:
db_conn = g.pop("db", None)
from_pool = g.pop("db_from_pool", False)
if not db_conn:
return
try:
if exc:
conn.rollback()
db_conn.rollback()
else:
conn.commit()
db_conn.commit()
except Exception:
try:
db_conn.rollback()
except Exception:
pass
finally:
conn.close()
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 = g.get("db")
if db:
db.rollback()
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."
print("DB: %s" % err, flush=True)
return jsonify({"error": message, "code": "db"}), 500
def body():
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -334,6 +401,7 @@ def create_app(cfg):
username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "")
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
grotti_db.invalidate_user_count()
store.touch_user_login(conn(), row["id"])
session.clear()
session["user_id"] = row["id"]
@@ -392,6 +460,7 @@ def create_app(cfg):
if store.get_user_by_name(conn(), username):
raise GrottiError("Benutzer gibt es schon.", "user", 409)
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
grotti_db.invalidate_user_count()
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
return jsonify(grotti_auth.public_user(row)), 201
@@ -779,11 +848,9 @@ def create_app(cfg):
def printer_create():
data = body()
name = (data.get("name") or "").strip()
host = (data.get("host") or "").strip()
host = validate_printer_host(data.get("host") or "")
if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
family = (data.get("family") or "").strip() or family_from_name(name)
if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family")
@@ -799,14 +866,12 @@ def create_app(cfg):
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
name = (data.get("name") if "name" in data else current["name"]) or ""
name = str(name).strip()
host = (data.get("host") if "host" in data else current["host"]) or ""
host = str(host).strip()
host_raw = (data.get("host") if "host" in data else current["host"]) or ""
host = validate_printer_host(str(host_raw).strip())
family = data.get("family") if "family" in data else current["family"]
family = (family or "").strip() or family_from_name(name)
if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family")
other = store.printer_by_host(conn(), host)
@@ -957,6 +1022,15 @@ def create_app(cfg):
upload = request.files.get("file")
if not upload or not upload.filename:
raise GrottiError("Keine Datei angehängt.", "datei")
low = upload.filename.lower().replace("\\", "/")
base = low.rsplit("/", 1)[-1]
if base in (".", "..") or not base.strip("."):
raise GrottiError("Ungültiger Dateiname.", "datei")
if not any(base.endswith(suf) for suf in UPLOAD_SUFFIXES):
raise GrottiError(
"Nur .gcode, .3mf oder .gcode.3mf sind erlaubt.",
"datei",
)
project_id = optional_id(request.form.get("project_id"))
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
return jsonify(saved), 201