modified: web/app.py
modified: web/blueprints/auth.py modified: web/blueprints/group_admin.py modified: web/blueprints/site_admin.py new file: web/limiter.py modified: web/panel_db.py modified: web/requirements.txt new file: web/templates/429.html new file: web/templates/admin/audit_log.html modified: web/templates/admin/base.html
This commit is contained in:
191
web/panel_db.py
191
web/panel_db.py
@@ -119,6 +119,56 @@ PANEL_SCHEMA = [
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(50) NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50) NULL,
|
||||
entity_id VARCHAR(100) NULL,
|
||||
details JSON NULL,
|
||||
group_id INT NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_audit_actor (actor_user_id),
|
||||
INDEX idx_audit_group (group_id),
|
||||
INDEX idx_audit_action (action),
|
||||
INDEX idx_audit_ts (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
note VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Versioned migrations (applied once, tracked in schema_migrations)
|
||||
# Each entry: (version_int, sql_statement, human_readable_note)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PANEL_MIGRATIONS = [
|
||||
(1,
|
||||
"ALTER TABLE group_members MODIFY COLUMN role "
|
||||
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
|
||||
"Extend group_members.role ENUM"),
|
||||
(2,
|
||||
"ALTER TABLE group_invites MODIFY COLUMN role "
|
||||
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
|
||||
"Extend group_invites.role ENUM"),
|
||||
(3,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS last_sent_at DATETIME NULL",
|
||||
"Add group_invites.last_sent_at"),
|
||||
(4,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS send_count INT NOT NULL DEFAULT 0",
|
||||
"Add group_invites.send_count"),
|
||||
(5,
|
||||
"ALTER TABLE group_invites MODIFY COLUMN group_id INT NULL",
|
||||
"Allow group_invites.group_id to be NULL"),
|
||||
(6,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS is_site_admin TINYINT(1) NOT NULL DEFAULT 0",
|
||||
"Add group_invites.is_site_admin"),
|
||||
]
|
||||
|
||||
CREDS_SCHEMA = [
|
||||
@@ -149,43 +199,33 @@ CREDS_SCHEMA = [
|
||||
|
||||
|
||||
def init_databases():
|
||||
"""Erstellt alle benötigten Tabellen falls nicht vorhanden."""
|
||||
"""Creates all required tables and applies pending schema migrations."""
|
||||
import logging
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
panel = get_panel_db()
|
||||
try:
|
||||
with panel.cursor() as cur:
|
||||
# Create tables (idempotent)
|
||||
for stmt in PANEL_SCHEMA:
|
||||
cur.execute(stmt)
|
||||
# Best-effort migrations for existing installs.
|
||||
try:
|
||||
cur.execute(
|
||||
"ALTER TABLE group_members MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute(
|
||||
"ALTER TABLE group_invites MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE group_invites ADD COLUMN last_sent_at DATETIME NULL")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine already-applied migration versions
|
||||
cur.execute("SELECT version FROM schema_migrations")
|
||||
applied = {row["version"] for row in cur.fetchall()}
|
||||
|
||||
for version, sql, note in PANEL_MIGRATIONS:
|
||||
if version in applied:
|
||||
continue
|
||||
try:
|
||||
cur.execute("SET foreign_key_checks=0")
|
||||
cur.execute("ALTER TABLE group_invites MODIFY group_id INT NULL")
|
||||
cur.execute("SET foreign_key_checks=1")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE group_invites ADD COLUMN is_site_admin TINYINT(1) NOT NULL DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT IGNORE INTO schema_migrations (version, note) VALUES (%s, %s)",
|
||||
(version, note),
|
||||
)
|
||||
_log.info("Migration %d applied: %s", version, note)
|
||||
except Exception as exc:
|
||||
_log.warning("Migration %d skipped (%s): %s", version, note, exc)
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
@@ -683,3 +723,92 @@ def delete_site_mail_settings():
|
||||
def has_site_mail_settings() -> bool:
|
||||
row = _creds_query("SELECT id FROM site_mail_settings WHERE config_key=%s", ("primary",), fetchone=True)
|
||||
return row is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Audit-Log
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def log_audit_event(
|
||||
actor_user_id,
|
||||
actor_username: str | None,
|
||||
action: str,
|
||||
entity_type: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
group_id: int | None = None,
|
||||
ip_address: str | None = None,
|
||||
):
|
||||
"""Records an audit event. Never raises — audit log must not break the main flow."""
|
||||
try:
|
||||
_panel_query(
|
||||
"INSERT INTO audit_log "
|
||||
"(actor_user_id, actor_username, action, entity_type, entity_id, details, group_id, ip_address) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||
(
|
||||
actor_user_id,
|
||||
actor_username,
|
||||
action,
|
||||
entity_type,
|
||||
str(entity_id) if entity_id is not None else None,
|
||||
json.dumps(details) if details else None,
|
||||
group_id,
|
||||
ip_address,
|
||||
),
|
||||
write=True,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to write audit event: %s", action)
|
||||
|
||||
|
||||
def get_audit_log(
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
action_filter: str | None = None,
|
||||
group_id_filter: int | None = None,
|
||||
actor_filter: str | None = None,
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
conditions: list[str] = []
|
||||
args: list = []
|
||||
|
||||
if action_filter:
|
||||
conditions.append("al.action LIKE %s")
|
||||
args.append(f"%{action_filter}%")
|
||||
if group_id_filter:
|
||||
conditions.append("al.group_id = %s")
|
||||
args.append(group_id_filter)
|
||||
if actor_filter:
|
||||
conditions.append("al.actor_username LIKE %s")
|
||||
args.append(f"%{actor_filter}%")
|
||||
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
|
||||
count_row = _panel_query(
|
||||
f"SELECT COUNT(*) AS c FROM audit_log al {where}", args, fetchone=True
|
||||
)
|
||||
total = int(count_row["c"]) if count_row else 0
|
||||
|
||||
rows = _panel_query(
|
||||
f"SELECT al.*, g.name AS group_name "
|
||||
f"FROM audit_log al "
|
||||
f"LEFT JOIN user_groups g ON g.id = al.group_id "
|
||||
f"{where} ORDER BY al.created_at DESC LIMIT %s OFFSET %s",
|
||||
args + [per_page, offset],
|
||||
)
|
||||
# Ensure details is always a dict (pymysql may return JSON as string)
|
||||
for row in (rows or []):
|
||||
d = row.get("details")
|
||||
if isinstance(d, str):
|
||||
try:
|
||||
row["details"] = json.loads(d)
|
||||
except Exception:
|
||||
row["details"] = {}
|
||||
return rows, total
|
||||
|
||||
|
||||
def get_audit_log_distinct_actions() -> list[str]:
|
||||
rows = _panel_query("SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
return [r["action"] for r in rows] if rows else []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user