Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.
This commit is contained in:
1 parent
b20f8a7779
commit
5a3716b26a
65 files changed
+2212
-198
No files matched your search
+121
@@ -0,0 +1,121 @@
|
||||
"""Benutzer, Sessions, API-Tokens und CSRF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
ROLES = ("admin", "operator")
|
||||
ROLE_RANK = {"operator": 1, "admin": 2}
|
||||
TOKEN_PREFIX = "grt_"
|
||||
LOGIN_WINDOW_S = 300
|
||||
LOGIN_MAX_ATTEMPTS = 12
|
||||
|
||||
_login_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return generate_password_hash(password, method="scrypt")
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
if not password_hash or not password:
|
||||
return False
|
||||
return check_password_hash(password_hash, password)
|
||||
|
||||
|
||||
def validate_username(username: str) -> str:
|
||||
name = (username or "").strip()
|
||||
if len(name) < 2 or len(name) > 64:
|
||||
raise GrottiError("Benutzername: 2–64 Zeichen.", "user")
|
||||
if not all(ch.isalnum() or ch in "._-" for ch in name):
|
||||
raise GrottiError("Benutzername: nur Buchstaben, Ziffern, . _ -", "user")
|
||||
return name
|
||||
|
||||
|
||||
def validate_password(password: str) -> str:
|
||||
if not password or len(password) < 8:
|
||||
raise GrottiError("Passwort mindestens 8 Zeichen.", "password")
|
||||
if len(password) > 200:
|
||||
raise GrottiError("Passwort zu lang.", "password")
|
||||
return password
|
||||
|
||||
|
||||
def normalize_role(role: str, *, default: str = "operator") -> str:
|
||||
role = (role or default).strip().lower()
|
||||
if role not in ROLES:
|
||||
raise GrottiError("Rolle muss admin oder operator sein.", "role")
|
||||
return role
|
||||
|
||||
|
||||
def role_at_least(have: str, need: str) -> bool:
|
||||
return ROLE_RANK.get(have or "", 0) >= ROLE_RANK.get(need or "", 99)
|
||||
|
||||
|
||||
def clamp_token_role(user_role: str, token_role: str | None) -> str:
|
||||
wanted = normalize_role(token_role or user_role, default=user_role)
|
||||
if not role_at_least(user_role, wanted):
|
||||
raise GrottiError("Token-Rolle darf nicht höher als die des Benutzers sein.", "role")
|
||||
return wanted
|
||||
|
||||
|
||||
def new_api_token() -> str:
|
||||
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def token_prefix(raw: str) -> str:
|
||||
return (raw or "")[:12]
|
||||
|
||||
|
||||
def new_csrf() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def login_rate_ok(key: str) -> bool:
|
||||
now = time.time()
|
||||
hits = _login_hits[key]
|
||||
_login_hits[key] = [t for t in hits if now - t < LOGIN_WINDOW_S]
|
||||
return len(_login_hits[key]) < LOGIN_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def login_rate_hit(key: str) -> None:
|
||||
_login_hits[key].append(time.time())
|
||||
|
||||
|
||||
def public_user(row) -> dict:
|
||||
if not row:
|
||||
return {}
|
||||
created = row.get("created_at")
|
||||
last = row.get("last_login")
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"username": row.get("username"),
|
||||
"role": row.get("role"),
|
||||
"active": bool(row["active"]) if "active" in row else True,
|
||||
"created_at": created.isoformat() if created and hasattr(created, "isoformat") else created,
|
||||
"last_login": last.isoformat() if last and hasattr(last, "isoformat") else last,
|
||||
}
|
||||
|
||||
|
||||
def public_token(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"name": row["name"],
|
||||
"prefix": row["token_prefix"],
|
||||
"role": row["role"],
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"last_used": row["last_used"].isoformat() if row.get("last_used") else None,
|
||||
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||
"revoked": bool(row.get("revoked_at")),
|
||||
}
|
||||
+40
-2
@@ -1,6 +1,7 @@
|
||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -59,6 +60,27 @@ def public_base(cfg, request_root=None):
|
||||
return str(request_root or "").rstrip("/")
|
||||
|
||||
|
||||
def _oauth(raw):
|
||||
oauth = raw.get("oauth") or {}
|
||||
return {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
"issuer": str(oauth.get("issuer") or ""),
|
||||
"client_id": str(oauth.get("client_id") or ""),
|
||||
"client_secret": str(oauth.get("client_secret") or ""),
|
||||
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_secret_key(path, raw):
|
||||
key = str(raw.get("secret_key") or "").strip()
|
||||
if key:
|
||||
return key
|
||||
key = secrets.token_hex(32)
|
||||
raw["secret_key"] = key
|
||||
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return key
|
||||
|
||||
|
||||
def load():
|
||||
path = CONFIG_PATH
|
||||
if not path.exists():
|
||||
@@ -66,8 +88,14 @@ def load():
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
host = (raw.get("host") or "127.0.0.1").strip()
|
||||
token = raw.get("token") or ""
|
||||
if host not in LOOPBACK and not token:
|
||||
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
|
||||
secret_key = _ensure_secret_key(path, raw)
|
||||
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
|
||||
# Neu laden falls secret_key geschrieben wurde
|
||||
if raw.get("secret_key") == secret_key:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
data_dir = Path(raw.get("data_dir") or "data")
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = ROOT / data_dir
|
||||
@@ -82,6 +110,8 @@ def load():
|
||||
"watch_dir": watch_dir,
|
||||
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
||||
"token": token,
|
||||
"secret_key": secret_key,
|
||||
"oauth": _oauth(raw),
|
||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||
"printers_file": ROOT / "drucker.json",
|
||||
"energy": _energy(raw),
|
||||
@@ -102,10 +132,12 @@ def mask_token(token):
|
||||
|
||||
def settings_public(cfg):
|
||||
tg = cfg.get("telegram") or {}
|
||||
oauth = cfg.get("oauth") or {}
|
||||
return {
|
||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||
"fqdn": str(cfg.get("fqdn") or ""),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
"energy": dict(cfg.get("energy") or {}),
|
||||
"telegram": {
|
||||
"enabled": bool(tg.get("enabled")),
|
||||
@@ -114,6 +146,12 @@ def settings_public(cfg):
|
||||
"chat_id": str(tg.get("chat_id") or ""),
|
||||
},
|
||||
"alerts": dict(cfg.get("alerts") or {}),
|
||||
"oauth": {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
"issuer": str(oauth.get("issuer") or ""),
|
||||
"client_id": str(oauth.get("client_id") or ""),
|
||||
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""MCP-Server für Grotti — spricht nur die HTTP-API mit einem API-Token.
|
||||
|
||||
Umgebung:
|
||||
GROTTI_URL Basis-URL (Standard http://127.0.0.1:8770)
|
||||
GROTTI_TOKEN Bearer-Token (grt_…)
|
||||
GROTTI_MCP_READ_ONLY=1 nur Lese-Tools
|
||||
|
||||
Start:
|
||||
python -m grotti.mcp_server
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
READ_ONLY = os.environ.get("GROTTI_MCP_READ_ONLY", "").strip() in ("1", "true", "yes")
|
||||
BASE = (os.environ.get("GROTTI_URL") or "http://127.0.0.1:8770").rstrip("/")
|
||||
TOKEN = (os.environ.get("GROTTI_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def _api(method: str, path: str, body: dict | None = None):
|
||||
if not TOKEN:
|
||||
raise RuntimeError("GROTTI_TOKEN fehlt.")
|
||||
url = BASE + "/api/v1" + path
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": "Bearer " + TOKEN,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(detail)
|
||||
message = payload.get("error") or detail
|
||||
except json.JSONDecodeError:
|
||||
message = detail or str(exc)
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
|
||||
def _write_guard():
|
||||
if READ_ONLY:
|
||||
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError:
|
||||
print(
|
||||
"Paket „mcp“ fehlt. Installieren: pip install mcp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
server = FastMCP(
|
||||
"grotti",
|
||||
instructions=(
|
||||
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
||||
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
||||
),
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_printers(live: bool = True) -> str:
|
||||
"""Druckerliste mit optionalem Live-Zustand."""
|
||||
q = "?live=1" if live else "?live=0"
|
||||
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_printer(host: str) -> str:
|
||||
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
||||
rows = _api("GET", "/drucker?live=1") or []
|
||||
for row in rows:
|
||||
if row.get("host") == host:
|
||||
return json.dumps(row, ensure_ascii=False, indent=2)
|
||||
raise RuntimeError("Drucker nicht gefunden: %s" % host)
|
||||
|
||||
@server.tool()
|
||||
def list_running() -> str:
|
||||
"""Laufende Drucke und Abläufe."""
|
||||
return json.dumps(_api("GET", "/laeuft"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def list_queue(limit: int = 40) -> str:
|
||||
"""Druckwarteschlange."""
|
||||
return json.dumps(
|
||||
_api("GET", "/druck-queue?limit=%d" % max(1, min(limit, 200))),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_events(limit: int = 30) -> str:
|
||||
"""Letzte Ereignisse / Aktivität."""
|
||||
return json.dumps(
|
||||
_api("GET", "/ereignisse?limit=%d" % max(1, min(limit, 200))),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_files(limit: int = 50) -> str:
|
||||
"""Dateien in der Bibliothek."""
|
||||
rows = _api("GET", "/dateien") or []
|
||||
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def list_spools() -> str:
|
||||
"""Spulen / Materialbestand."""
|
||||
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_version() -> str:
|
||||
"""Grotti-Version."""
|
||||
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def pause_printer(host: str) -> str:
|
||||
"""Druck pausieren."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/pause" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def resume_printer(host: str) -> str:
|
||||
"""Druck fortsetzen."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/fortsetzen" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def cancel_printer(host: str, confirm: bool = False) -> str:
|
||||
"""Druck abbrechen. confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Abbruch braucht confirm=true.")
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/abbrechen" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||
return json.dumps(
|
||||
_api(
|
||||
"POST",
|
||||
"/drucker/%s/abraeumen" % urllib.parse.quote(host),
|
||||
{"confirm": True},
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def enqueue_when_free(
|
||||
file_id: int,
|
||||
host: str = "",
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||
payload = {
|
||||
"file_id": file_id,
|
||||
"host": host,
|
||||
"confirm": True,
|
||||
"bed_leveling": bool(bed_leveling),
|
||||
}
|
||||
return json.dumps(_api("POST", "/druck-queue", payload), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def enqueue_print(
|
||||
file_id: int,
|
||||
host: str,
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Sofort senden und starten. confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Start braucht confirm=true.")
|
||||
if not host:
|
||||
raise RuntimeError("host fehlt.")
|
||||
payload = {
|
||||
"file_id": file_id,
|
||||
"host": host,
|
||||
"confirm": True,
|
||||
"bed_leveling": bool(bed_leveling),
|
||||
}
|
||||
return json.dumps(_api("POST", "/senden", payload), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def steer_temp(host: str, nozzle: float | None = None, bed: float | None = None) -> str:
|
||||
"""Zieltemperaturen setzen (Whitelist)."""
|
||||
_write_guard()
|
||||
out = []
|
||||
if nozzle is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "nozzle", "value": nozzle,
|
||||
}))
|
||||
if bed is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "bed", "value": bed,
|
||||
}))
|
||||
if not out:
|
||||
raise RuntimeError("nozzle und/oder bed angeben.")
|
||||
return json.dumps(out, ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def steer_speed(host: str, speed: float) -> str:
|
||||
"""Geschwindigkeitsfaktor in Prozent."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "speed", "value": speed,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def steer_fan(host: str, fan: float) -> str:
|
||||
"""Teilekühlung 0–100."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "fan", "value": fan,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.resource("grotti://version")
|
||||
def resource_version() -> str:
|
||||
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+19
-1
@@ -907,7 +907,7 @@ def query(host):
|
||||
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
||||
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
||||
),
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
**_camera_fields(host, quoted_host),
|
||||
**mesh_fields(host, shown, mesh_profile),
|
||||
"fan": fan_speed,
|
||||
"fan_rpm": fan_rpm,
|
||||
@@ -946,6 +946,8 @@ def snapshot(printers):
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"camera": "",
|
||||
"has_camera": False,
|
||||
"fan": None,
|
||||
"fan_rpm": None,
|
||||
"fans": {"part": None, "chamber": None, "aux": None},
|
||||
@@ -986,5 +988,21 @@ def camera_image(host):
|
||||
return CAMERA_CACHE[host][2]
|
||||
|
||||
|
||||
def _camera_fields(host, quoted_host):
|
||||
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||||
cached = CAMERA_CACHE.get(host)
|
||||
if cached is not None and cached[2] is None:
|
||||
return {"camera": "", "has_camera": False}
|
||||
if cached is not None and cached[2] is not None:
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": True,
|
||||
}
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": None,
|
||||
}
|
||||
|
||||
|
||||
def embedded_bytes(host, name):
|
||||
return EMBEDDED.get((host, name))
|
||||
@@ -180,3 +180,38 @@ CREATE TABLE IF NOT EXISTS bed_status (
|
||||
cleared_at timestamptz,
|
||||
message text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'operator',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
token_prefix text NOT NULL DEFAULT '',
|
||||
role text NOT NULL DEFAULT 'operator',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used timestamptz,
|
||||
revoked_at timestamptz,
|
||||
expires_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- OIDC später: Identitäten an lokale User binden.
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, subject)
|
||||
);
|
||||
+127
@@ -1280,3 +1280,130 @@ def set_bed_needs_clear(conn, host, needs, message=""):
|
||||
(host, needs, needs, message or ""),
|
||||
)
|
||||
|
||||
|
||||
def user_count(conn):
|
||||
return int(conn.execute("SELECT count(*) AS n FROM users").fetchone()["n"])
|
||||
|
||||
|
||||
def list_users(conn):
|
||||
return conn.execute(
|
||||
"SELECT id, username, role, active, created_at, last_login FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_user(conn, user_id):
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_user_by_name(conn, username):
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE lower(username) = lower(%s)",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_user(conn, username, password_hash, role="operator"):
|
||||
return conn.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, username, role, active, created_at, last_login
|
||||
""",
|
||||
(username, password_hash, role),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_user(conn, user_id, *, password_hash=None, role=None, active=None):
|
||||
row = get_user(conn, user_id)
|
||||
if not row:
|
||||
return None
|
||||
new_hash = password_hash if password_hash is not None else row["password_hash"]
|
||||
new_role = role if role is not None else row["role"]
|
||||
new_active = row["active"] if active is None else bool(active)
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET password_hash = %s, role = %s, active = %s
|
||||
WHERE id = %s
|
||||
RETURNING id, username, role, active, created_at, last_login
|
||||
""",
|
||||
(new_hash, new_role, new_active, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def delete_user(conn, user_id):
|
||||
conn.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def touch_user_login(conn, user_id):
|
||||
conn.execute("UPDATE users SET last_login = now() WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def create_api_token(conn, user_id, name, token_hash, token_prefix, role, expires_at=None):
|
||||
return conn.execute(
|
||||
"""
|
||||
INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, role, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(user_id, name or "", token_hash, token_prefix, role, expires_at),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def list_api_tokens(conn, user_id=None, include_revoked=False):
|
||||
sql = """
|
||||
SELECT t.id, t.user_id, t.name, t.token_prefix, t.role,
|
||||
t.created_at, t.last_used, t.expires_at, t.revoked_at,
|
||||
u.username
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
args = []
|
||||
if user_id is not None:
|
||||
sql += " AND t.user_id = %s"
|
||||
args.append(user_id)
|
||||
if not include_revoked:
|
||||
sql += " AND t.revoked_at IS NULL"
|
||||
sql += " ORDER BY t.created_at DESC"
|
||||
return conn.execute(sql, args).fetchall()
|
||||
|
||||
|
||||
def get_api_token_by_hash(conn, token_hash):
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT t.*, u.username, u.active AS user_active, u.role AS user_role
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = %s
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def touch_api_token(conn, token_id):
|
||||
conn.execute("UPDATE api_tokens SET last_used = now() WHERE id = %s", (token_id,))
|
||||
|
||||
|
||||
def revoke_api_token(conn, token_id, user_id=None):
|
||||
if user_id is None:
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE api_tokens SET revoked_at = now()
|
||||
WHERE id = %s AND revoked_at IS NULL
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(token_id,),
|
||||
).fetchone()
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE api_tokens SET revoked_at = now()
|
||||
WHERE id = %s AND user_id = %s AND revoked_at IS NULL
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(token_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.2.0"
|
||||
Reference in new issue
Block a user