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:
1 parent
9e22cebb81
commit
0236278929
19 files changed
+986
-81
No files matched your search
+93
-19
@@ -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
|
||||
|
||||
Reference in new issue
Block a user