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
+2 -1
View File
@@ -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()
+2 -20
View File
@@ -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
+244 -82
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"
_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.
"""
# 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:
import configparser as _cp
_cfg_file = "config.ini"
if _os.path.exists(_cfg_file):
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 {}
cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_section("database"):
section = cfg["database"]
try:
return {
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", "")),
}
except RuntimeError as exc:
logger.error(f"Failed to decrypt DB credentials: {exc}")
raise
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
@@ -1099,5 +1263,3 @@ def initialize_database():
finally:
if conn:
conn.close()
+1
View File
@@ -10,3 +10,4 @@ pypdf>=3.0.0
python-docx>=1.0.0
pywin32>=306
docx2txt>=0.8
keyring
+108 -20
View File
@@ -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."""
"""
Read the Fernet salt from app_settings, falling back to config.ini for
legacy installs, and generating a fresh salt for brand-new installs.
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
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(_CONFIG_FILE, encoding="utf-8")
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}")
if "crypto" in cfg and cfg["crypto"].get("salt"):
return base64.b64decode(cfg["crypto"]["salt"])
# ── Step 2: ensure app_settings table exists ──────────────────────────────
table_ok = _ensure_app_settings_table()
# Generate a fresh 32-byte salt
# ── 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)
if "crypto" not in cfg:
cfg["crypto"] = {}
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii")
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("Crypto: generated and persisted new credential encryption salt.")
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")
+69 -120
View File
@@ -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,8 +200,6 @@ 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"))
@@ -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
@@ -327,8 +295,8 @@ def _send_report(cfg: dict):
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"))
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)
+7 -19
View File
@@ -167,16 +167,12 @@ 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])
model = get_setting("groq.model", GROQ_MODELS[0])
if model in GROQ_MODELS:
self._model_var.set(model)
except Exception as e:
@@ -184,18 +180,10 @@ class AiSummaryView(ttk.Frame):
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}")
+1 -1
View File
@@ -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
+23 -14
View File
@@ -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: