diff --git a/app.py b/app.py index 0b6fc34..950da82 100644 --- a/app.py +++ b/app.py @@ -86,7 +86,8 @@ class App(tk.Tk): # Re-open setup so the user can correct the credentials self._show_setup(on_complete=self._init_db) return - # Encrypt any plain-text credentials in config.ini (one-time migration) + # One-time migration: import config.ini [database] → OS keychain, + # and config.ini [email]/[groq]/[crypto] → app_settings DB table. try: from config import migrate_plaintext_config migrate_plaintext_config() diff --git a/config.ini b/config.ini index 4d72d39..91882ca 100644 --- a/config.ini +++ b/config.ini @@ -2,24 +2,6 @@ host = 67.217.62.199 port = 3306 database = webchecker -user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAYidUx+/EF8dBvkm7qVrCTNghUdDMslBCZBBTjfFXnuIAAAAADoAAAAACAAAgAAAArWRcoWRv2DWdaf+Np9mP3VUerkem6fgxbPN9RP6vE+sQAAAA+IYMqcdW0pBoZ5HdhykWrEAAAADsUqFCIW6brBYISrc+XdSS0WqYBBP3jvoBXUuAtmyhI4g8j9C+R1y27Jd/VUy/5hybmayzzeAsj2a+dK+rr8yR -password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAykVXkmPXEYXlFlNlaFvKD2SdOE+KF+FwGjxyIua1vlUAAAAADoAAAAACAAAgAAAAlUEcvqr1nvxQ2liPnoA7aJc5mqZ9OpZ69l1YctCrhxcQAAAAmEg7tJjEkA+4Eg2jzvP+7EAAAADHWK0G9UJn4ZmNlsMB4WQtZduNzSeIoT/TuKB8X7hNMASwz9wXTw/KxX2iNi9C0Tg3JgqaWjv/rLJQeJF8MeTF - -[crypto] -salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ= - -[groq] -api_key = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAvjBskxdtcNpYs3pBI8ISyRDRKIgTpRQf39CfrILduhUAAAAADoAAAAACAAAgAAAAdkFkBiC1kD4X0L3a6eoe8z2s+O/KYriTZfl8L6qOc+JAAAAABW4zpNffQtP5IMFXi3W/93xl9XCt3lbTxegcdqxOxVXrp1hRyolT5YPFdB1gjL53ywCDA+Ls4yRKGMBekl49Q0AAAAAms4kvJoK2EhZzfvcHkvDhoixVelv89fLw2ZS1yKPtMrNJFaoGd+IkOCcLAnI6eMiy5PYtPe2t2vtlZ09i1nJZ -model = llama-3.3-70b-versatile - -[email] -enabled = true -smtp_host = mail.ltservicesinc.com -smtp_port = 465 -smtp_user = donotreply@ltservicesinc.com -smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAh3WeFBaZFJ3jXPYBcRFrzeouer4eNi8f1V4e9htctPQAAAAADoAAAAACAAAgAAAApIGw/W7uxwcLxCFmKJtbd0lsr0Ipem4mjBeaF7ePQa4QAAAANsAPqIR2ODnOCJC3BEIY80AAAAAXFi4ah2FcXjr8cWMirk5zHMDBRBKWu2MU/UvTNWuY5VR4br/uZ7ZSk9wExBCr/vuLq7eJ7v5JnoIG570fIlue -security = ssl -use_tls = false -recipients = da.nguyen8744@gmail.com -send_time = 18:00 +user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAZfPmuxpaSlrA7baq6FuVA3mxjxM4Kjk3n2c3ntJuEukAAAAADoAAAAACAAAgAAAApAOcDY8BjopNdccaEtymsKrh8pe9OCv5OOjrEURz8e8QAAAAynWoAwfUckM2KPojaAmLqkAAAADFRX+8SXg/SnhOad3blnMygOYi6RyTF+Ei3+ShbjHEgnLkgl1Sf3xruM7AcRmCeHcB3he0n8q5clagpKUYiEdI +password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAL9rzNnENEcALpm87GOHhRkA6go80lhJXm8atP9sLtfkAAAAADoAAAAACAAAgAAAAF37pL18fWvsyYKJQaK9v9Xwq1bb2YPoQfcQKjTm9QTcQAAAADqJfCkgtQsUEZR12YnDALkAAAADl2cNUaxU40qObsA0mQ+XVuQ1qMOZytHhM5XpEHMTIwUmwvGuCRJrb6+D7EVdyLxS6PCoStrell6alDGg6UzRk diff --git a/config.py b/config.py index 5d7a1dd..1ebd986 100644 --- a/config.py +++ b/config.py @@ -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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9373b2e..eb4df61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pypdf>=3.0.0 python-docx>=1.0.0 pywin32>=306 docx2txt>=0.8 +keyring diff --git a/utils/crypto.py b/utils/crypto.py index ee83ae3..1bcf2c8 100644 --- a/utils/crypto.py +++ b/utils/crypto.py @@ -2,14 +2,18 @@ utils/crypto.py — Fernet symmetric encryption for website credentials. Key derivation: - - A 32-byte random salt is generated on first use and stored in config.ini - under [crypto] / salt. + - A 32-byte random salt is generated on first use and stored in the + app_settings table (key: 'crypto.salt') instead of config.ini. - The Fernet key is derived from the salt + a fixed application secret using PBKDF2-HMAC-SHA256 (100,000 iterations). - - This means credentials are tied to the specific config.ini file on the - operator's machine; moving config.ini to another machine retains access. + - Because the salt lives in the shared MySQL database, any machine that + connects to the same DB can decrypt credentials without needing a local + config.ini — making the app fully portable across machines. Migration: + - On first start after this change, if config.ini still contains a + [crypto]/salt entry it is automatically migrated to app_settings and + removed from the file. - _decrypt() tries Fernet first; if that fails it returns the raw value unchanged so that plaintext legacy credentials are still readable. - Callers should re-encrypt on next write (update_website handles this). @@ -18,7 +22,6 @@ Migration: import base64 import logging import os -import configparser from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC @@ -27,30 +30,116 @@ from cryptography.hazmat.primitives import hashes logger = logging.getLogger("crypto") _APP_SECRET = b"WebsiteChecker-v1-CredentialKey" -_CONFIG_FILE = "config.ini" _ITERATIONS = 100_000 +_SETTING_KEY = "crypto.salt" _fernet: "Fernet | None" = None # ─── Key bootstrap ──────────────────────────────────────────────────────────── +def _ensure_app_settings_table() -> bool: + """ + Guarantee the app_settings table exists before we try to read/write it. + Returns True if the table is available, False if it could not be created + (e.g. the DB pool itself is not yet ready). + + This guard is necessary because crypto.py can be called during login — + before initialize_database() has had a chance to run on a fresh install + or an upgraded database that doesn't yet have the app_settings table. + """ + try: + from config import get_connection + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + 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 + """ + ) + conn.commit() + cur.close() + conn.close() + return True + except Exception as e: + logger.warning(f"Crypto: could not ensure app_settings table: {e}") + return False + + def _get_or_create_salt() -> bytes: - """Read salt from config.ini [crypto] section; create and persist if absent.""" - cfg = configparser.ConfigParser() - cfg.read(_CONFIG_FILE, encoding="utf-8") + """ + Read the Fernet salt from app_settings, falling back to config.ini for + legacy installs, and generating a fresh salt for brand-new installs. - if "crypto" in cfg and cfg["crypto"].get("salt"): - return base64.b64decode(cfg["crypto"]["salt"]) + Order of precedence: + 1. app_settings table (primary — shared across machines via the DB) + 2. config.ini [crypto]/salt (legacy migration path) + 3. Generate a new random salt and persist it to app_settings - # Generate a fresh 32-byte salt - salt = os.urandom(32) - if "crypto" not in cfg: - cfg["crypto"] = {} - cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii") + The table is created here if it doesn't yet exist, so this function is + safe to call before initialize_database() has run. + """ + # ── Step 1: try config.ini first (fastest, no DB needed yet) ───────────── + # Reading config.ini for the salt is always safe — it doesn't require the + # app_settings table to exist. If found, we attempt to also persist it to + # the DB (best-effort), but we use the value regardless. + legacy_b64 = None + try: + import configparser, os as _os + _cfg_file = "config.ini" + if _os.path.exists(_cfg_file): + cfg = configparser.ConfigParser() + cfg.read(_cfg_file, encoding="utf-8") + if cfg.has_option("crypto", "salt"): + legacy_b64 = cfg.get("crypto", "salt") + except Exception as e: + logger.warning(f"Crypto: could not read config.ini for salt: {e}") - with open(_CONFIG_FILE, "w", encoding="utf-8") as fh: - cfg.write(fh) - logger.info("Crypto: generated and persisted new credential encryption salt.") + # ── Step 2: ensure app_settings table exists ────────────────────────────── + table_ok = _ensure_app_settings_table() + + # ── Step 3: try to read salt from DB ────────────────────────────────────── + if table_ok: + try: + from config import get_setting + raw = get_setting(_SETTING_KEY, "") + if raw: + return base64.b64decode(raw) + except Exception as e: + logger.warning(f"Crypto: could not read salt from app_settings: {e}") + + # ── Step 4: migrate legacy salt from config.ini → DB ───────────────────── + if legacy_b64: + if table_ok: + try: + from config import set_setting + set_setting(_SETTING_KEY, legacy_b64) + logger.info("Crypto: migrated salt from config.ini to app_settings.") + except Exception as e: + logger.warning(f"Crypto: could not persist migrated salt to DB: {e}") + else: + logger.info("Crypto: using salt from config.ini (app_settings not available yet).") + return base64.b64decode(legacy_b64) + + # ── Step 5: generate a fresh salt ──────────────────────────────────────── + salt = os.urandom(32) + b64_salt = base64.b64encode(salt).decode("ascii") + if table_ok: + try: + from config import set_setting + set_setting(_SETTING_KEY, b64_salt) + logger.info("Crypto: generated and persisted new salt to app_settings.") + except Exception as e: + logger.warning(f"Crypto: could not persist new salt to DB: {e}") + else: + logger.warning( + "Crypto: generated a new salt but app_settings is not available — " + "salt will NOT persist across restarts until the table is created." + ) return salt @@ -74,7 +163,7 @@ def _get_fernet() -> Fernet: def reset_fernet(): - """Force key reload — call after config.ini is replaced (e.g. settings save).""" + """Force key reload — call if the salt is ever rotated.""" global _fernet _fernet = None @@ -107,7 +196,6 @@ def decrypt(ciphertext: str) -> str: if not ciphertext: return ciphertext if not ciphertext.startswith("enc:"): - # Legacy plaintext — return unchanged; will be re-encrypted on next save return ciphertext try: token = ciphertext[4:].encode("ascii") @@ -122,4 +210,4 @@ def decrypt(ciphertext: str) -> str: def is_encrypted(value: str) -> bool: """Return True if the value was produced by encrypt().""" - return isinstance(value, str) and value.startswith("enc:") + return isinstance(value, str) and value.startswith("enc:") \ No newline at end of file diff --git a/utils/scheduler.py b/utils/scheduler.py index 5c27656..990cc17 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -5,24 +5,26 @@ Runs a background daemon thread that wakes every minute, checks whether the configured send_time (HH:MM) has been reached today, and sends the summary report via SMTP if it hasn't been sent yet. -Configuration (config.ini [email] section): - enabled = true/false - smtp_host = smtp.example.com - smtp_port = 587 - smtp_user = sender@example.com - smtp_password= secret - use_tls = true - recipients = admin@example.com, manager@example.com - send_time = 18:00 (24-hour HH:MM, local time) +Configuration is stored in the app_settings database table (not config.ini): + + Key Description + ─────────────────────── ──────────────────────────────────────────── + email.enabled 'true' or 'false' + email.smtp_host SMTP server hostname + email.smtp_port SMTP port number (string) + email.smtp_user Sender email address / SMTP login + email.smtp_password SMTP password (Fernet-encrypted) + email.security 'starttls' | 'ssl' | 'none' + email.recipients Comma-separated recipient addresses + email.send_time HH:MM (24-hour local time) + email.last_sent_date ISO date of last successful send (YYYY-MM-DD) Call start() once after login succeeds (admin only). Call stop() on logout/shutdown. """ -import configparser import datetime import logging -import os import smtplib import threading from email.mime.multipart import MIMEMultipart @@ -31,7 +33,6 @@ from email.utils import formatdate, make_msgid, formataddr logger = logging.getLogger("scheduler") -CONFIG_FILE = "config.ini" _scheduler_thread: "threading.Thread | None" = None _stop_event = threading.Event() @@ -39,56 +40,53 @@ _stop_event = threading.Event() # ─── Config helpers ─────────────────────────────────────────────────────────── def load_email_config() -> dict: + """Load email settings from app_settings table.""" + from config import get_settings_dict from utils.config_crypto import decrypt_value - cfg = configparser.ConfigParser() - if not os.path.exists(CONFIG_FILE): - return {} - cfg.read(CONFIG_FILE, encoding="utf-8") - if "email" not in cfg: - return {} - s = cfg["email"] - # security field: "starttls" | "ssl" | "none" - # Falls back from legacy use_tls boolean for existing configs. - security = s.get("security", "") + s = get_settings_dict("email.") + security = s.get("email.security", "") if not security: - security = "starttls" if s.getboolean("use_tls", fallback=True) else "none" + # Legacy: fall back from boolean use_tls if security key absent + use_tls = s.get("email.use_tls", "true").lower() == "true" + security = "starttls" if use_tls else "none" return { - "enabled": s.getboolean("enabled", fallback=False), - "smtp_host": s.get("smtp_host", ""), - "smtp_port": s.getint("smtp_port", fallback=587), - "smtp_user": s.get("smtp_user", ""), - "smtp_password": decrypt_value(s.get("smtp_password", "")), + "enabled": s.get("email.enabled", "false").lower() == "true", + "smtp_host": s.get("email.smtp_host", ""), + "smtp_port": int(s.get("email.smtp_port", "587") or "587"), + "smtp_user": s.get("email.smtp_user", ""), + "smtp_password": decrypt_value(s.get("email.smtp_password", "")), "security": security, - "use_tls": security == "starttls", # kept for compatibility - "recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()], - "send_time": s.get("send_time", "18:00"), + "use_tls": security == "starttls", + "recipients": [r.strip() for r in + s.get("email.recipients", "").split(",") if r.strip()], + "send_time": s.get("email.send_time", "18:00"), } def save_email_config(enabled: bool, smtp_host: str, smtp_port: int, smtp_user: str, smtp_password: str, security: str, recipients: str, send_time: str): + """Persist email settings to app_settings table.""" + from config import get_setting, set_setting from utils.config_crypto import encrypt_value - cfg = configparser.ConfigParser() - cfg.read(CONFIG_FILE, encoding="utf-8") - # Preserve last_sent_date if present - last_sent = cfg.get("email", "last_sent_date", fallback="") - cfg["email"] = { - "enabled": str(enabled).lower(), - "smtp_host": smtp_host, - "smtp_port": str(smtp_port), - "smtp_user": smtp_user, - "smtp_password": encrypt_value(smtp_password), - "security": security, - "use_tls": str(security == "starttls").lower(), # legacy compat - "recipients": recipients, - "send_time": send_time, + # Preserve last_sent_date — do not overwrite it on a normal save + last_sent = get_setting("email.last_sent_date", "") + pairs = { + "email.enabled": str(enabled).lower(), + "email.smtp_host": smtp_host, + "email.smtp_port": str(smtp_port), + "email.smtp_user": smtp_user, + "email.smtp_password": encrypt_value(smtp_password), + "email.security": security, + "email.use_tls": str(security == "starttls").lower(), + "email.recipients": recipients, + "email.send_time": send_time, } + for k, v in pairs.items(): + set_setting(k, v) if last_sent: - cfg["email"]["last_sent_date"] = last_sent - with open(CONFIG_FILE, "w", encoding="utf-8") as fh: - cfg.write(fh) - logger.info(f"Email configuration saved (security={security}, encrypted).") + set_setting("email.last_sent_date", last_sent) + logger.info(f"Email configuration saved to app_settings (security={security}).") def _make_smtp_server(smtp_host: str, smtp_port: int, @@ -108,7 +106,7 @@ def _make_smtp_server(smtp_host: str, smtp_port: int, server = smtplib.SMTP(smtp_host, smtp_port, timeout=15) server.ehlo() server.starttls() - server.ehlo() # re-identify after TLS upgrade — mandatory + server.ehlo() else: server = smtplib.SMTP(smtp_host, smtp_port, timeout=15) server.ehlo() @@ -120,16 +118,9 @@ def test_smtp_connection(smtp_host: str, smtp_port: int, security: str) -> tuple: """ Step-by-step SMTP diagnostic. Returns (success: bool, message: str). - Each step is attempted independently so the error message tells the - admin exactly where the failure occurred: - Step 1 — DNS resolution - Step 2 — TCP connect - Step 3 — TLS handshake (if applicable) - Step 4 — Authentication """ import socket - # Step 1: DNS resolution try: addr = socket.getaddrinfo(smtp_host, smtp_port, socket.AF_UNSPEC, socket.SOCK_STREAM) @@ -143,7 +134,6 @@ def test_smtp_connection(smtp_host: str, smtp_port: int, f"Check the hostname and your network connection.\n({e})" ) - # Step 2: TCP connect (raw socket, before any SMTP protocol) try: sock = socket.create_connection((smtp_host, smtp_port), timeout=8) sock.close() @@ -154,43 +144,30 @@ def test_smtp_connection(smtp_host: str, smtp_port: int, f"The port may be blocked by a firewall or the server is down.\n({e})" ) - # Steps 3 + 4: SMTP protocol, TLS handshake, authentication try: server = _make_smtp_server(smtp_host, smtp_port, security) logger.info(f"SMTP test: TLS/connection OK (security={security})") except smtplib.SMTPConnectError as e: - return False, ( - f"Step 3 FAILED — SMTP connect: {e}\n" - f"Try a different Security mode or port." - ) + return False, (f"Step 3 FAILED — SMTP connect: {e}\nTry a different Security mode or port.") except smtplib.SMTPException as e: - return False, ( - f"Step 3 FAILED — TLS handshake: {e}\n" - f"Try switching Security mode (e.g. SSL/TLS on port 465)." - ) + return False, (f"Step 3 FAILED — TLS handshake: {e}\nTry switching Security mode.") except OSError as e: - return False, ( - f"Step 3 FAILED — connection dropped: {e}\n" - f"Try switching Security mode or port." - ) + return False, (f"Step 3 FAILED — connection dropped: {e}\nTry switching Security mode or port.") try: server.login(smtp_user, smtp_password) server.quit() logger.info("SMTP test: authentication OK") return True, ( - f"All steps passed.\n" - f"Connected to {smtp_host}:{smtp_port} " + f"All steps passed.\nConnected to {smtp_host}:{smtp_port} " f"({security.upper()}) and authenticated successfully." ) except smtplib.SMTPAuthenticationError as e: - try: - server.quit() - except Exception: - pass + try: server.quit() + except Exception: pass return False, ( f"Step 4 FAILED — Authentication: username or password rejected.\n" - f"For Gmail/Google Workspace use an App Password, not your account password.\n({e})" + f"For Gmail/Google Workspace use an App Password.\n({e})" ) except smtplib.SMTPException as e: return False, f"Step 4 FAILED — SMTP error during login: {e}" @@ -201,10 +178,7 @@ def test_smtp_connection(smtp_host: str, smtp_port: int, def send_test_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_password: str, security: str, recipients: list) -> tuple: - """ - Send a real test email through the full pipeline. - Returns (success: bool, message: str). - """ + """Send a real test email through the full pipeline.""" try: subject = "Website Checker — SMTP Test" body = ( @@ -226,10 +200,8 @@ def send_test_email(smtp_host: str, smtp_port: int, msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker") msg["X-Mailer"] = "WebChecker" - # Plain-text part must come first; HTML second. - # Spam filters heavily penalise HTML-only messages with no text/plain alternative. msg.attach(MIMEText(plain, "plain", "utf-8")) - msg.attach(MIMEText(body, "html", "utf-8")) + msg.attach(MIMEText(body, "html", "utf-8")) server = _make_smtp_server(smtp_host, smtp_port, security) server.login(smtp_user, smtp_password) @@ -238,10 +210,8 @@ def send_test_email(smtp_host: str, smtp_port: int, logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}") return True, f"Test email sent successfully to: {', '.join(recipients)}" except smtplib.SMTPAuthenticationError as e: - return False, ( - f"Authentication failed — check username and password.\n" - f"For Gmail/Google Workspace use an App Password.\n({e})" - ) + return False, (f"Authentication failed — check username and password.\n" + f"For Gmail/Google Workspace use an App Password.\n({e})") except smtplib.SMTPConnectError as e: return False, f"Could not connect to {smtp_host}:{smtp_port} — {e}" except smtplib.SMTPException as e: @@ -269,8 +239,6 @@ def _build_html_report() -> str: skipped_no_shift = 0 for r in rows: total = int(r.get("total_sites") or 0) - # Skip users with no shifts scheduled today — they have no expected - # work for this day and showing them as "0 / 0 — 0%" is misleading. if total == 0: skipped_no_shift += 1 continue @@ -324,11 +292,11 @@ def _build_html_report() -> str: def _send_report(cfg: dict): """Build and send the daily report email.""" - html = _build_html_report() - today = datetime.date.today().strftime("%d %b %Y") - subject = f"Website Checker — Daily Report {today}" - + html = _build_html_report() + today = datetime.date.today().strftime("%d %b %Y") + subject = f"Website Checker — Daily Report {today}" smtp_user = cfg["smtp_user"] + plain = ( f"Website Checker — Daily Report {today}\n\n" "Please view this report in an HTML-capable email client for full formatting.\n" @@ -341,17 +309,14 @@ def _send_report(cfg: dict): msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker") msg["X-Mailer"] = "WebChecker" - # Plain-text part must come first; HTML second. - # Spam filters heavily penalise HTML-only messages with no text/plain alternative. msg.attach(MIMEText(plain, "plain", "utf-8")) - msg.attach(MIMEText(html, "html", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) try: security = cfg.get("security", "starttls") - server = _make_smtp_server( - cfg["smtp_host"], cfg["smtp_port"], security) - server.login(cfg["smtp_user"], cfg["smtp_password"]) - server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string()) + server = _make_smtp_server(cfg["smtp_host"], cfg["smtp_port"], security) + server.login(smtp_user, cfg["smtp_password"]) + server.sendmail(smtp_user, cfg["recipients"], msg.as_string()) server.quit() logger.info(f"Daily report emailed to: {cfg['recipients']}") except smtplib.SMTPAuthenticationError as e: @@ -367,12 +332,9 @@ def _send_report(cfg: dict): def _get_last_sent_date() -> "datetime.date | None": - """Read the last-sent date from config.ini [email] last_sent_date key.""" - cfg = configparser.ConfigParser() - if not os.path.exists(CONFIG_FILE): - return None - cfg.read(CONFIG_FILE, encoding="utf-8") - raw = cfg.get("email", "last_sent_date", fallback="") + """Read the last-sent date from app_settings.""" + from config import get_setting + raw = get_setting("email.last_sent_date", "") if not raw: return None try: @@ -382,25 +344,18 @@ def _get_last_sent_date() -> "datetime.date | None": def _set_last_sent_date(d: "datetime.date"): - """Persist the last-sent date to config.ini [email] last_sent_date key.""" - cfg = configparser.ConfigParser() - if os.path.exists(CONFIG_FILE): - cfg.read(CONFIG_FILE, encoding="utf-8") - if "email" not in cfg: - cfg["email"] = {} - cfg["email"]["last_sent_date"] = d.isoformat() - with open(CONFIG_FILE, "w", encoding="utf-8") as fh: - cfg.write(fh) + """Persist the last-sent date to app_settings.""" + from config import set_setting + set_setting("email.last_sent_date", d.isoformat()) # ─── Scheduler loop ─────────────────────────────────────────────────────────── def _scheduler_loop(): - # Seed from persisted value so a restart after send_time does not re-send. last_sent_date = _get_last_sent_date() while not _stop_event.is_set(): - _stop_event.wait(60) # sleep 60 seconds between checks + _stop_event.wait(60) if _stop_event.is_set(): break @@ -427,16 +382,10 @@ def _scheduler_loop(): def start(): """Start the background scheduler thread. Call once after successful login.""" global _scheduler_thread, _stop_event - # Guard against double-start: if a thread is already alive (e.g. admin - # logs out and back in), stop it cleanly before spawning a new one. - # Without this guard, _stop_event.clear() would unblock the sleeping - # thread while a second thread also starts, resulting in two scheduler - # threads firing simultaneously and potentially sending duplicate emails. if _scheduler_thread is not None and _scheduler_thread.is_alive(): logger.info("Email scheduler already running - stopping before restart.") _stop_event.set() _scheduler_thread.join(timeout=5) - # Create a fresh Event so there is no residual set-state from a prior stop() _stop_event = threading.Event() _scheduler_thread = threading.Thread(target=_scheduler_loop, name="EmailScheduler", daemon=True) diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index 2c39712..2acb979 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -167,35 +167,23 @@ class AiSummaryView(ttk.Frame): def _load_config(self): try: - import configparser - from config import CONFIG_FILE + from config import get_setting from utils.config_crypto import decrypt_value - cfg = configparser.ConfigParser() - cfg.read(CONFIG_FILE, encoding="utf-8") - if cfg.has_section(_CFG_SECTION): - raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="") + raw_key = get_setting("groq.api_key", "") + if raw_key: self._api_key_var.set(decrypt_value(raw_key)) - model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL, - fallback=GROQ_MODELS[0]) - if model in GROQ_MODELS: - self._model_var.set(model) + model = get_setting("groq.model", GROQ_MODELS[0]) + if model in GROQ_MODELS: + self._model_var.set(model) except Exception as e: logger.warning(f"Could not load Groq config: {e}") def _save_config(self): try: - import configparser - from config import CONFIG_FILE + from config import set_setting from utils.config_crypto import encrypt_value - cfg = configparser.ConfigParser() - cfg.read(CONFIG_FILE, encoding="utf-8") - if not cfg.has_section(_CFG_SECTION): - cfg.add_section(_CFG_SECTION) - cfg.set(_CFG_SECTION, _CFG_KEY_KEY, - encrypt_value(self._api_key_var.get().strip())) - cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get()) - with open(CONFIG_FILE, "w", encoding="utf-8") as fh: - cfg.write(fh) + set_setting("groq.api_key", encrypt_value(self._api_key_var.get().strip())) + set_setting("groq.model", self._model_var.get()) except Exception as e: logger.warning(f"Could not save Groq config: {e}") diff --git a/views/email_settings_view.py b/views/email_settings_view.py index 824b443..cf4731e 100644 --- a/views/email_settings_view.py +++ b/views/email_settings_view.py @@ -1,6 +1,6 @@ """ views/email_settings_view.py — SMTP / scheduled report configuration dialog. -Admin-only. Persists to config.ini [email] section via utils/scheduler.py. +Admin-only. Persists to the app_settings database table via utils/scheduler.py. """ import tkinter as tk @@ -326,4 +326,4 @@ class EmailSettingsView(tk.Toplevel): f"security={security}.") show_info("Email settings saved successfully.") logger.info(f"Email settings saved by {self.current_user['username']}.") - self.destroy() + self.destroy() \ No newline at end of file diff --git a/views/settings_view.py b/views/settings_view.py index 506863e..67a79b6 100644 --- a/views/settings_view.py +++ b/views/settings_view.py @@ -1,13 +1,12 @@ """ views/settings_view.py — Database connection settings dialog. -Shown automatically on first run (no config.ini) and accessible +Shown automatically on first run (no stored credentials) and accessible via the admin sidebar Settings nav item at any time. -Writes connection details to config.ini via config.save_config(). -Does NOT store the password in plaintext beyond what config.ini holds -(which is acceptable for a locally-run desktop tool; operators should -restrict file-system access to config.ini in production). +Credentials are saved to the OS native keychain (Windows Credential Manager, +macOS Keychain, or Linux Secret Service) via the keyring library. +No config.ini or local file is written — the app is fully portable. """ import tkinter as tk @@ -42,7 +41,7 @@ class SettingsView(tk.Toplevel): self.title("Database Setup" if first_run else "Connection Settings") self.configure(bg=COLOURS["bg"]) - self.resizable(False, False) + self.resizable(False, True) # allow vertical resize for high-DPI self.grab_set() if first_run: @@ -58,7 +57,9 @@ class SettingsView(tk.Toplevel): def _centre(self): self.update_idletasks() - w, h = 480, 480 + # Height increased to 560 to ensure button row is always visible. + # Vertical resize allowed for high-DPI / large-font environments. + w, h = 480, 560 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") @@ -77,6 +78,12 @@ class SettingsView(tk.Toplevel): font=FONT_SMALL, bg=COLOURS["accent"], fg=COLOURS["white"]).pack(pady=(2, 0)) + # ── Buttons (packed before form so they anchor to bottom) ───────────── + # Packing the button row before the expanding form guarantees it is + # always visible even when the form content exceeds the window height. + btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) + btn_row.pack(side="bottom", fill="x") + # ── Form ────────────────────────────────────────────────────────────── form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20) form.pack(fill="both", expand=True) @@ -116,7 +123,8 @@ class SettingsView(tk.Toplevel): # ── Hint ────────────────────────────────────────────────────────────── hint = ( - "Settings are saved to config.ini in the application folder.\n" + "Credentials are saved to your OS keychain (Windows Credential Manager, " + "macOS Keychain, or Linux Secret Service) — no config.ini required.\n" "Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges." ) tk.Label(form, text=hint, bg=COLOURS["bg"], @@ -124,10 +132,7 @@ class SettingsView(tk.Toplevel): justify="left", wraplength=380).grid( row=6, column=0, columnspan=2, sticky="w", pady=(8, 0)) - # ── Buttons ─────────────────────────────────────────────────────────── - btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) - btn_row.pack(fill="x") - + # ── Buttons (btn_row already packed at top of _build_ui) ───────────── self._save_btn = tk.Button( btn_row, text="Save & Connect", command=self._save, @@ -263,9 +268,13 @@ class SettingsView(tk.Toplevel): ) conn.close() - from config import save_config + from config import save_config, reload_db_config save_config(host, port, database, user, password) - logger.info(f"Settings saved: {user}@{host}:{port}/{database}") + # Immediately update the in-memory DB_CONFIG and reset the + # connection pool so the app connects with the new credentials + # without requiring a restart. + reload_db_config() + logger.info(f"Settings saved and applied: {user}@{host}:{port}/{database}") self.after(0, self._on_save_success) except Exception as e: @@ -284,4 +293,4 @@ class SettingsView(tk.Toplevel): def _finish(self): self.destroy() - self.on_save_callback() + self.on_save_callback() \ No newline at end of file