04/24 Migrated info which stored in .ini to database and keyring

This commit is contained in:
2026-04-24 15:37:58 -04:00
parent f2a8d3f15b
commit 5d6ca5a039
9 changed files with 479 additions and 299 deletions
+255 -93
View File
@@ -169,134 +169,153 @@ logging.basicConfig(
)
logger = logging.getLogger("config")
# ─── Config File Load / Save ──────────────────────────────────────────────────
import configparser as _cp
# ─── DB Credential Storage (OS Keychain via keyring) ─────────────────────────
#
# DB connection details are stored in the OS native credential store:
# Windows → Windows Credential Manager
# macOS → Keychain
# Linux → Secret Service / libsecret (or plaintext fallback)
#
# This replaces config.ini entirely — no local file is needed on any machine.
# The user enters credentials once via the Settings dialog; keyring persists
# them securely and they survive reboots, upgrades, and user profile migrations.
#
# keyring is a stdlib-level cross-platform abstraction; install with:
# pip install keyring
#
import os as _os
import json as _json
CONFIG_FILE = "config.ini"
APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
_KEYRING_SVC = "WebChecker" # service name in the OS credential store
_KEYRING_KEY = "db_config" # single credential entry stores JSON blob
# Sensitive fields that are DPAPI-encrypted in config.ini
_DB_SENSITIVE = {"user", "password"}
def _keyring_set(data: dict) -> None:
"""Persist DB config dict as a JSON blob in the OS keychain."""
import keyring
keyring.set_password(_KEYRING_SVC, _KEYRING_KEY, _json.dumps(data))
def _keyring_get() -> dict:
"""Read DB config dict from the OS keychain. Returns {} if not found."""
try:
import keyring
raw = keyring.get_password(_KEYRING_SVC, _KEYRING_KEY)
if raw:
return _json.loads(raw)
except Exception as e:
logger.warning(f"keyring read failed: {e}")
return {}
def load_config() -> dict:
"""
Load DB settings from config.ini.
Load DB connection settings from the OS keychain.
Returns a dict with keys: host, port, database, user, password.
Sensitive fields (user, password) are decrypted transparently via DPAPI.
Returns empty dict if the file does not exist or is incomplete.
Returns empty dict if no credentials are stored yet (first run).
On first startup after migration from config.ini, automatically imports
existing credentials from config.ini into the keychain and removes them
from the file so the file is no longer required.
"""
from utils.config_crypto import decrypt_value
cfg = _cp.ConfigParser()
if not _os.path.exists(CONFIG_FILE):
return {}
cfg.read(CONFIG_FILE, encoding="utf-8")
if "database" not in cfg:
return {}
section = cfg["database"]
# Try keychain first
data = _keyring_get()
if data.get("host") and data.get("database") and data.get("user"):
return data
# One-time migration: pull from config.ini if it still exists
try:
return {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
}
except RuntimeError as exc:
logger.error(f"Failed to decrypt DB credentials: {exc}")
raise
import configparser as _cp
_cfg_file = "config.ini"
if _os.path.exists(_cfg_file):
from utils.config_crypto import decrypt_value
cfg = _cp.ConfigParser()
cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_section("database"):
section = cfg["database"]
migrated = {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
}
if migrated.get("host") and migrated.get("database") and migrated.get("user"):
_keyring_set(migrated)
logger.info(
"DB credentials migrated from config.ini to OS keychain. "
"config.ini [database] section is no longer needed."
)
return migrated
except Exception as e:
logger.warning(f"config.ini migration attempt failed: {e}")
return {}
def save_config(host: str, port: int, database: str, user: str, password: str):
"""Persist DB connection settings to config.ini (sensitive fields DPAPI-encrypted)."""
from utils.config_crypto import encrypt_value
# Preserve any existing non-database sections (email, crypto, groq)
cfg = _cp.ConfigParser()
if _os.path.exists(CONFIG_FILE):
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["database"] = {
"""Persist DB connection settings to the OS keychain."""
data = {
"host": host,
"port": str(port),
"port": port,
"database": database,
"user": encrypt_value(user),
"password": encrypt_value(password),
"user": user,
"password": password,
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
try:
_keyring_set(data)
logger.info(
f"DB credentials saved to OS keychain "
f"({host}:{port}/{database})."
)
except Exception as e:
logger.error(f"Failed to save credentials to OS keychain: {e}")
raise RuntimeError(
f"Could not save to the OS keychain: {e}\n\n"
"Ensure the keyring package is installed: pip install keyring"
) from e
def migrate_plaintext_config():
"""
One-time migration: if config.ini contains plain-text DB credentials
(no 'dpapi:' prefix) encrypt them in-place using Windows DPAPI.
Safe to call on every startup — is a no-op when already encrypted.
One-time migration runner called on every startup.
- Imports config.ini [database] into the OS keychain (handled by load_config).
- Migrates config.ini [email]/[groq]/[crypto] into app_settings (DB).
Safe to call repeatedly — all operations are idempotent no-ops once done.
"""
from utils.config_crypto import encrypt_value, is_encrypted
if not _os.path.exists(CONFIG_FILE):
return
cfg = _cp.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
# DB section
for key in ("user", "password"):
if cfg.has_option("database", key):
raw = cfg.get("database", key)
if raw and not is_encrypted(raw):
cfg.set("database", key, encrypt_value(raw))
changed = True
# Email section
if cfg.has_option("email", "smtp_password"):
raw = cfg.get("email", "smtp_password")
if raw and not is_encrypted(raw):
cfg.set("email", "smtp_password", encrypt_value(raw))
changed = True
# Groq section
if cfg.has_option("groq", "api_key"):
raw = cfg.get("groq", "api_key")
if raw and not is_encrypted(raw):
cfg.set("groq", "api_key", encrypt_value(raw))
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("config.ini: plain-text credentials encrypted with Windows DPAPI.")
# Trigger the config.ini → keychain migration via load_config
load_config()
def config_exists() -> bool:
"""Return True if config.ini contains all required connection fields."""
ini = load_config()
return bool(ini.get("host") and ini.get("database") and ini.get("user"))
"""Return True if DB credentials are stored in the OS keychain."""
data = _keyring_get()
return bool(data.get("host") and data.get("database") and data.get("user"))
def reload_db_config():
"""
Re-read config.ini and update DB_CONFIG in place.
Re-read credentials from the OS keychain and update DB_CONFIG in place.
Also resets the connection pool so the next get_connection() uses new creds.
"""
global _pool, DB_CONFIG
ini = load_config()
data = load_config()
DB_CONFIG.update({
"host": ini.get("host", DB_CONFIG["host"]),
"port": ini.get("port", DB_CONFIG["port"]),
"database": ini.get("database", DB_CONFIG["database"]),
"user": ini.get("user", DB_CONFIG["user"]),
"password": ini.get("password", DB_CONFIG["password"]),
"host": data.get("host", DB_CONFIG["host"]),
"port": data.get("port", DB_CONFIG["port"]),
"database": data.get("database", DB_CONFIG["database"]),
"user": data.get("user", DB_CONFIG["user"]),
"password": data.get("password", DB_CONFIG["password"]),
})
_pool = None # force pool recreation on next connection
logger.info("DB_CONFIG reloaded from config.ini.")
logger.info("DB_CONFIG reloaded from OS keychain.")
# ─── Database Configuration ───────────────────────────────────────────────────
# Populated from config.ini at runtime; falls back to placeholder strings so
# the module is importable even before first-run setup has completed.
# Populated from the OS keychain at import time; falls back to placeholder
# strings so the module is importable even before first-run setup has completed.
_ini = load_config()
DB_CONFIG = {
"host": _ini.get("host", "your-mysql-host"),
@@ -511,6 +530,15 @@ def initialize_database():
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS app_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Key-value store for application configuration. '
'Replaces config.ini sections: email, groq, crypto.';
""",
]
conn = None
@@ -606,6 +634,36 @@ def initialize_database():
)
conn.commit()
logger.info("Default admin account seeded (username: admin / password: admin123).")
# ── users.email column (added in this release) ─────────────────────────
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'email'
"""
)
(has_email,) = cursor.fetchone()
if not has_email:
cursor.execute(
"ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL DEFAULT NULL "
"AFTER full_name"
)
conn.commit()
logger.info("Migration: added email column to users table.")
# ── app_settings: migrate config.ini → DB on first post-upgrade start ──
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_settings'
"""
)
(has_settings,) = cursor.fetchone()
if has_settings:
logger.info("app_settings table confirmed present.")
_migrate_config_ini_to_db(cursor, conn)
cursor.close()
except mysql.connector.Error as e:
logger.error(f"Database initialisation error: {e}")
@@ -613,6 +671,112 @@ def initialize_database():
finally:
if conn:
conn.close()
# ─── app_settings DB helpers ──────────────────────────────────────────────────
# These replace all config.ini reads/writes for email, groq, and crypto settings.
# The DB connection settings remain in the local .db_config file (bootstrap only).
def _migrate_config_ini_to_db(cursor, conn):
"""
One-time migration: read email, groq, and crypto sections from config.ini
(if present) and upsert them into app_settings. This runs silently on every
startup but is a no-op once the keys exist in the DB or if config.ini is absent.
"""
import configparser as _cfgp, os as _os
_cfg_file = "config.ini"
if not _os.path.exists(_cfg_file):
return
cfg = _cfgp.ConfigParser()
cfg.read(_cfg_file, encoding="utf-8")
mappings = []
# email section
if cfg.has_section("email"):
for key in ("enabled", "smtp_host", "smtp_port", "smtp_user",
"smtp_password", "security", "use_tls", "recipients",
"send_time", "last_sent_date"):
val = cfg.get("email", key, fallback=None)
if val is not None:
mappings.append((f"email.{key}", val))
# groq section
if cfg.has_section("groq"):
for key in ("api_key", "model"):
val = cfg.get("groq", key, fallback=None)
if val is not None:
mappings.append((f"groq.{key}", val))
# crypto section (salt)
if cfg.has_section("crypto"):
val = cfg.get("crypto", "salt", fallback=None)
if val is not None:
mappings.append(("crypto.salt", val))
if not mappings:
return
for k, v in mappings:
cursor.execute(
"INSERT INTO app_settings (key_name, value) VALUES (%s, %s) "
"ON DUPLICATE KEY UPDATE value=value", # don't overwrite existing
(k, v),
)
conn.commit()
logger.info(f"Migrated {len(mappings)} setting(s) from config.ini to app_settings.")
def get_setting(key: str, default: str = "") -> str:
"""Read a value from the app_settings table. Returns default if not found."""
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT value FROM app_settings WHERE key_name=%s", (key,))
row = cur.fetchone()
cur.close()
conn.close()
return row[0] if row and row[0] is not None else default
except Exception as e:
logger.warning(f"get_setting({key!r}) failed: {e}")
return default
def set_setting(key: str, value: str) -> None:
"""Upsert a key-value pair into app_settings."""
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO app_settings (key_name, value) VALUES (%s, %s) "
"ON DUPLICATE KEY UPDATE value=%s, updated_at=CURRENT_TIMESTAMP",
(key, value, value),
)
conn.commit()
cur.close()
conn.close()
except Exception as e:
logger.error(f"set_setting({key!r}) failed: {e}")
def get_settings_dict(prefix: str) -> dict:
"""Return all app_settings rows whose key starts with prefix as a plain dict."""
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"SELECT key_name, value FROM app_settings WHERE key_name LIKE %s",
(prefix + "%",),
)
rows = cur.fetchall()
cur.close()
conn.close()
return {r[0]: (r[1] or "") for r in rows}
except Exception as e:
logger.warning(f"get_settings_dict({prefix!r}) failed: {e}")
return {}
def install(self):
"""Signal the worker that the DB pool is ready to accept writes."""
self._ready = True
@@ -1098,6 +1262,4 @@ def initialize_database():
raise
finally:
if conn:
conn.close()
conn.close()