From 1b6b37e96e372124973f8cc5bb1d5ad87eb4de87 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 16 May 2026 13:40:58 +0200 Subject: [PATCH] feat(settings): add read-only SQL console with diagnostic presets --- RELEASE_NOTES_v2.3.8.md | 9 ++ app/settings/backend/views.py | 73 +++++++++- app/settings/frontend/settings.html | 3 + app/settings/frontend/sql_console.html | 189 +++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 RELEASE_NOTES_v2.3.8.md create mode 100644 app/settings/frontend/sql_console.html diff --git a/RELEASE_NOTES_v2.3.8.md b/RELEASE_NOTES_v2.3.8.md new file mode 100644 index 0000000..d779a9f --- /dev/null +++ b/RELEASE_NOTES_v2.3.8.md @@ -0,0 +1,9 @@ +# Release Notes: v2.3.8 +**Date:** 16. maj 2026 + +## Summary +- Added secure read-only SQL Console page under /settings/sql +- Added superadmin-protected execute endpoint /settings/sql/execute (SELECT/WITH only) +- Added SQL Console nav link in settings +- Added preset query buttons for telefoni/mission diagnostics +- files: app/settings/backend/views.py, app/settings/frontend/sql_console.html, app/settings/frontend/settings.html diff --git a/app/settings/backend/views.py b/app/settings/backend/views.py index a56cdf3..339f9e5 100644 --- a/app/settings/backend/views.py +++ b/app/settings/backend/views.py @@ -5,13 +5,14 @@ Settings Frontend Views from datetime import datetime from pathlib import Path import re -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, Request, HTTPException, Depends from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel from app.core.config import settings -from app.core.database import get_db_connection, release_db_connection, execute_query_single +from app.core.database import get_db_connection, release_db_connection, execute_query_single, execute_query +from app.core.auth_dependencies import require_superadmin router = APIRouter() templates = Jinja2Templates(directory="app") @@ -33,6 +34,13 @@ SKIP_COLUMN_LINE_RE = re.compile( re.IGNORECASE, ) +SQL_COMMENT_BLOCK_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +SQL_COMMENT_LINE_RE = re.compile(r"--[^\n]*") +SQL_FORBIDDEN_RE = re.compile( + r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|copy|vacuum|analyze|refresh|merge)\b", + re.IGNORECASE, +) + def _strip_sql_comments(sql: str) -> str: sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL) @@ -291,6 +299,67 @@ class MigrationExecution(BaseModel): file_name: str +class SqlConsoleRequest(BaseModel): + query: str + limit: int = 200 + + +def _sanitize_and_validate_sql(sql: str) -> str: + raw = (sql or "").strip() + if not raw: + raise HTTPException(status_code=400, detail="SQL query is required") + + cleaned = SQL_COMMENT_BLOCK_RE.sub("", raw) + cleaned = SQL_COMMENT_LINE_RE.sub("", cleaned).strip() + if not cleaned: + raise HTTPException(status_code=400, detail="SQL query is empty after removing comments") + + lowered = cleaned.lower() + if not (lowered.startswith("select") or lowered.startswith("with")): + raise HTTPException(status_code=400, detail="Only SELECT/WITH queries are allowed") + + single_statement = cleaned.rstrip(";").strip() + if ";" in single_statement: + raise HTTPException(status_code=400, detail="Only one SQL statement is allowed") + + if SQL_FORBIDDEN_RE.search(single_statement): + raise HTTPException(status_code=400, detail="Query contains forbidden SQL keywords") + + return single_statement + + +@router.get("/settings/sql", response_class=HTMLResponse, tags=["Frontend"]) +async def sql_console_page(request: Request, _current_user: dict = Depends(require_superadmin)): + return templates.TemplateResponse( + "settings/frontend/sql_console.html", + { + "request": request, + "title": "SQL Console", + }, + ) + + +@router.post("/settings/sql/execute", tags=["Frontend"]) +async def execute_sql_console_query(payload: SqlConsoleRequest, _current_user: dict = Depends(require_superadmin)): + query = _sanitize_and_validate_sql(payload.query) + limit = payload.limit if isinstance(payload.limit, int) else 200 + if limit < 1: + limit = 1 + if limit > 1000: + limit = 1000 + + wrapped_query = f"SELECT * FROM ({query}) AS bmc_sql_console LIMIT %s" + rows = execute_query(wrapped_query, (limit,)) or [] + columns = list(rows[0].keys()) if rows else [] + + return { + "columns": columns, + "rows": rows, + "row_count": len(rows), + "limit": limit, + } + + @router.get("/settings/migrations/status", tags=["Frontend"]) def migration_statuses(): """Check migration files against current schema and return per-file color status.""" diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index f31814d..66449b3 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -125,6 +125,9 @@ DB Migrationer + + SQL Console + diff --git a/app/settings/frontend/sql_console.html b/app/settings/frontend/sql_console.html new file mode 100644 index 0000000..efe3f82 --- /dev/null +++ b/app/settings/frontend/sql_console.html @@ -0,0 +1,189 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}SQL Console - BMC Hub{% endblock %} + +{% block content %} +
+
+
+

SQL Console

+
Read-only SQL for superadmin. Kun SELECT/WITH queries.
+
+
+ +
+
+
+ +
+ + + + + +
+
+
+ + +
+
+
+ + +
+
+ +
+
+
Ready
+
+
+ +
+
+
+ + + + + +
No results yet
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %}