Ctrl K
ComunidadGuías…

webapp/db.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Ali Sadeghi Aghili
"""SQLite persistence for the web app: users + query logs.

The database file lives next to this module (``webapp/app.db``) and is
created automatically on first use.  Passwords are stored as salted
hashes (werkzeug), never in plain text.
"""

from __future__ import annotations

import sqlite3
import sys
from contextlib import closing
from datetime import datetime
from pathlib import Path
from typing import Any

from werkzeug.security import check_password_hash, generate_password_hash

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from core.fileperms import restrict_file  # noqa: E402

DB_PATH = Path(__file__).resolve().parent / "app.db"

_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    username      TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS logs (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    username        TEXT NOT NULL,
    question        TEXT NOT NULL,
    generated_sql   TEXT,
    interpretation  TEXT,
    output_file     TEXT,
    row_count       INTEGER NOT NULL DEFAULT 0,
    status          TEXT NOT NULL,
    error_message   TEXT,
    elapsed_seconds REAL,
    created_at      TEXT NOT NULL
);
"""


def _connect() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def init_db() -> None:
    """Create tables if they do not exist yet.  Safe to call repeatedly.

    Finding 18 (2026 audit): this file holds every Flask user's password
    hash and the full log of questions asked through the web UI, and was
    previously created at the ambient process umask (world-readable on a
    typical Linux host). ``restrict_file`` runs after every call rather
    than only the one that first creates ``app.db`` -- this function is
    called from ``create_user``/``verify_user``/``log_query`` on every
    request, not just at start-up, so gating the call on "did this call
    just create the file" would need a second existence check of its own
    for no real benefit: the helper is idempotent and a plain ``chmod``
    on an already-correct file is cheap.
    """
    with closing(_connect()) as conn, conn:
        conn.executescript(_SCHEMA)
    restrict_file(DB_PATH)


# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------

def create_user(username: str, password: str) -> None:
    """Insert a new user.  Raises sqlite3.IntegrityError on duplicate name."""
    init_db()
    with closing(_connect()) as conn, conn:
        conn.execute(
            "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
            (
                username,
                generate_password_hash(password),
                datetime.now().isoformat(timespec="seconds"),
            ),
        )


def verify_user(username: str, password: str) -> dict[str, Any] | None:
    """Return the user row as a dict if credentials match, else None."""
    init_db()
    with closing(_connect()) as conn:
        row = conn.execute(
            "SELECT * FROM users WHERE username = ?", (username,)
        ).fetchone()
    if row is None or not check_password_hash(row["password_hash"], password):
        return None
    return dict(row)


# ---------------------------------------------------------------------------
# Query logs
# ---------------------------------------------------------------------------

def log_query(
    username: str,
    question: str,
    status: str,
    generated_sql: str | None = None,
    interpretation: str | None = None,
    output_file: str | None = None,
    row_count: int = 0,
    error_message: str | None = None,
    elapsed_seconds: float | None = None,
) -> None:
    """Record one submitted question and its outcome."""
    init_db()
    with closing(_connect()) as conn, conn:
        conn.execute(
            """INSERT INTO logs (
                   username, question, generated_sql, interpretation,
                   output_file, row_count, status, error_message,
                   elapsed_seconds, created_at
               ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                username,
                question,
                generated_sql,
                interpretation,
                output_file,
                row_count,
                status,
                error_message,
                elapsed_seconds,
                datetime.now().isoformat(timespec="seconds"),
            ),
        )