04/22 user dashboard view, no credentials popup automatically
This commit is contained in:
@@ -29,7 +29,8 @@ website_checker/
|
||||
├── requirements.txt
|
||||
├── CLAUDE.md This file
|
||||
├── utils/
|
||||
│ ├── crypto.py Fernet credential encryption
|
||||
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
|
||||
│ ├── crypto.py Fernet credential encryption (website passwords)
|
||||
│ ├── export.py CSV + Excel export
|
||||
│ ├── scheduler.py Daily email report background daemon
|
||||
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
|
||||
@@ -38,7 +39,8 @@ website_checker/
|
||||
├── admin_log_view.py Activity log treeview
|
||||
├── admin_shifts_view.py Shift CRUD + PDF export
|
||||
├── admin_users_view.py User CRUD
|
||||
├── admin_websites_view.py Website CRUD + visibility + credentials
|
||||
├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible)
|
||||
├── ai_summary_view.py AI document analysis via Groq API (NEW)
|
||||
├── change_password_view.py Self-service password change (all roles)
|
||||
├── email_settings_view.py SMTP / scheduled report configuration
|
||||
├── login_view.py Login form with rate-limiting countdown
|
||||
@@ -52,26 +54,31 @@ website_checker/
|
||||
## 3. Runtime Files
|
||||
|
||||
### `config.ini`
|
||||
Created on first launch. Contains [database], [email], and [crypto] sections.
|
||||
Created on first launch. Contains [database], [email], [crypto], and [groq] sections.
|
||||
**Sensitive fields are DPAPI-encrypted** (see §6 Security). Encrypted values have a `dpapi:` prefix.
|
||||
|
||||
```ini
|
||||
[database]
|
||||
host=your-mysql-host
|
||||
port=3306
|
||||
database=website_checker
|
||||
user=your-db-user
|
||||
password=your-db-password
|
||||
user=dpapi:<base64-blob> ; encrypted
|
||||
password=dpapi:<base64-blob> ; encrypted
|
||||
|
||||
[email]
|
||||
enabled=false
|
||||
smtp_host=smtp.example.com
|
||||
smtp_port=587
|
||||
smtp_user=sender@example.com
|
||||
smtp_password=secret
|
||||
smtp_password=dpapi:<base64-blob> ; encrypted
|
||||
use_tls=true
|
||||
recipients=admin@example.com
|
||||
send_time=18:00
|
||||
|
||||
[groq]
|
||||
api_key=dpapi:<base64-blob> ; encrypted
|
||||
model=llama-3.3-70b-versatile
|
||||
|
||||
[crypto]
|
||||
salt=<base64 32-byte salt — auto-generated>
|
||||
```
|
||||
@@ -145,10 +152,19 @@ E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
|
||||
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
|
||||
- get_connection() → pool (pool_size=5)
|
||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations
|
||||
- load_config() / save_config() → config.ini read/write
|
||||
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
|
||||
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
|
||||
- config_exists() → True if host, database, user are set
|
||||
- reload_db_config() → re-reads config.ini, resets pool
|
||||
|
||||
### utils/config_crypto.py *(NEW)*
|
||||
Windows DPAPI-based encryption for config.ini sensitive values.
|
||||
- encrypt_value(plaintext) → "dpapi:<base64>" string
|
||||
- decrypt_value(stored) → plaintext; plain-text pass-through for legacy values
|
||||
- is_encrypted(value) → True if value starts with "dpapi:"
|
||||
- Tied to the current Windows user account — blob is unreadable on any other machine/account
|
||||
- Graceful fallback: if pywin32 not available, values stored/returned as plain text
|
||||
|
||||
### models.py
|
||||
Each function opens+closes its own connection. All writes call log_action().
|
||||
|
||||
@@ -177,13 +193,37 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
|
||||
### utils/scheduler.py
|
||||
- Daemon thread; polls every 60 seconds
|
||||
- Sends HTML email once per day when now >= send_time
|
||||
- smtp_password is DPAPI-encrypted on save (encrypt_value) / decrypted on load (decrypt_value)
|
||||
- last_sent_date in-memory (resets on restart)
|
||||
- start() / stop() called from app.py on login/logout (admin only)
|
||||
|
||||
### views/login_view.py
|
||||
- check_login_allowed(username) called before authenticate()
|
||||
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
|
||||
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
|
||||
### views/ai_summary_view.py *(NEW)*
|
||||
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
||||
|
||||
Key internals:
|
||||
- Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls
|
||||
- Groq API key and model selection are **visible only to admins** (user role sees neither)
|
||||
- Regular users use the stored API key transparently
|
||||
- Background thread for AI calls (prevents UI freeze)
|
||||
- Extraction prompt focused on procurement/solicitation fields:
|
||||
Solicitation Number/Type, Set-Aside, Description, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, Driving Distance from office (2815 Hartland Rd, Falls Church VA),
|
||||
Last Day for Questions, Due Date
|
||||
- Overall summary covers: Scope of Work, Contract Period, Proposal Submission Requirements,
|
||||
Key Deadlines & Action Items
|
||||
- Groq API key stored DPAPI-encrypted in config.ini [groq] section
|
||||
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
|
||||
|
||||
.doc reading (legacy binary Word format) — 3-tier fallback:
|
||||
1. win32com (Word COM automation — requires MS Word installed)
|
||||
2. docx2txt (pure Python)
|
||||
3. Raw ASCII scrape from binary
|
||||
|
||||
### views/admin_websites_view.py
|
||||
- Website CRUD dialog now has a **collapsible credentials section** (hidden by default)
|
||||
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
||||
- Auto-expands when editing a site that already has saved credentials
|
||||
- "+ Add Credential" auto-expands the section if collapsed
|
||||
|
||||
### views/user_dashboard_view.py
|
||||
Key internals:
|
||||
@@ -192,6 +232,15 @@ Key internals:
|
||||
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
|
||||
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy()
|
||||
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast
|
||||
- **Credentials popup no longer opens automatically on link click**
|
||||
- "🔑 Credentials" button appears on each site card only if the site has saved credentials
|
||||
- CredentialsPopup: 📋 copy button for username (2s flash); 📋 copy button for password
|
||||
(2s flash + clipboard auto-cleared after 15s for security); 👁 toggle to reveal password
|
||||
|
||||
### views/login_view.py
|
||||
- check_login_allowed(username) called before authenticate()
|
||||
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
|
||||
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
|
||||
|
||||
### views/admin_shifts_view.py
|
||||
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
|
||||
@@ -207,11 +256,19 @@ Key internals:
|
||||
| Concern | Implementation |
|
||||
|---|---|
|
||||
| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login |
|
||||
| Credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
|
||||
| Website credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
|
||||
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
|
||||
| Encrypted fields | DB user, DB password, SMTP password, Groq API key |
|
||||
| Login rate limiting | 5 attempts → 15-min lockout in DB |
|
||||
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
|
||||
| Password policy | 8+ chars, uppercase, digit, special character |
|
||||
| Self-service change | Requires current password; strength meter; same-as-current guard |
|
||||
| Clipboard security | Password copy auto-clears clipboard after 15 seconds |
|
||||
|
||||
### DPAPI Migration
|
||||
`migrate_plaintext_config()` is called automatically on every startup (in `_init_db`).
|
||||
It is a no-op if all sensitive fields are already encrypted (dpapi: prefix present).
|
||||
On first run after this feature was added, it encrypts any existing plain-text values in-place.
|
||||
|
||||
---
|
||||
|
||||
@@ -249,6 +306,8 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 |
|
||||
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
|
||||
| utils/crypto.py | _ITERATIONS | 100,000 |
|
||||
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
|
||||
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
|
||||
|
||||
---
|
||||
|
||||
@@ -262,8 +321,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
|
||||
3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour.
|
||||
|
||||
4. config.ini stores DB password and SMTP password in plaintext.
|
||||
Website credential passwords are Fernet-encrypted.
|
||||
4. config.ini sensitive fields are DPAPI-encrypted (dpapi: prefix).
|
||||
Website credential passwords are Fernet-encrypted (enc: prefix).
|
||||
Non-sensitive fields (host, port, db name, smtp_host, recipients) remain plain text.
|
||||
|
||||
5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user
|
||||
belongs to multiple shifts sharing the same website.
|
||||
@@ -276,6 +336,13 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||
cp1252 UnicodeEncodeError on Windows consoles.
|
||||
|
||||
9. DPAPI encrypted blobs are tied to the Windows user account that created them.
|
||||
If config.ini is copied to a different machine or user account, credentials
|
||||
cannot be decrypted. Users must re-enter credentials via the Settings dialog.
|
||||
|
||||
10. The AI summary view fetches credentials from the DB at card render time (not lazily).
|
||||
Avoid having hundreds of sites with credentials as this adds DB round-trips per render.
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependencies
|
||||
@@ -288,9 +355,15 @@ cryptography>=41.0.0
|
||||
matplotlib>=3.7.0
|
||||
plyer>=2.1.0
|
||||
reportlab>=4.0.0
|
||||
groq>=1.0.0
|
||||
pypdf>=3.0.0
|
||||
python-docx>=1.0.0
|
||||
pywin32>=306
|
||||
docx2txt>=0.8
|
||||
```
|
||||
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar, datetime
|
||||
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar,
|
||||
datetime, base64, re, tempfile
|
||||
|
||||
---
|
||||
|
||||
@@ -299,15 +372,56 @@ stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, cal
|
||||
1. _boot() → config_exists() → False → SettingsView (locked modal)
|
||||
2. User enters DB creds → Test Connection → Save & Connect
|
||||
3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
|
||||
4. Login screen shown
|
||||
5. ThemeManager(root, initial="light") applied; shell built
|
||||
4. migrate_plaintext_config() → encrypts any plain-text credentials in config.ini
|
||||
5. Login screen shown
|
||||
6. ThemeManager(root, initial="light") applied; shell built
|
||||
|
||||
---
|
||||
|
||||
## 12. Deployment Notes
|
||||
## 12. Sidebar Navigation
|
||||
|
||||
### Admin role
|
||||
Dashboard · Websites · Users · Shifts · Reports · Activity Log · 🤖 AI Summary · Settings · Sign Out
|
||||
|
||||
### User role
|
||||
My Shift · 🤖 AI Summary · Change Password · Sign Out
|
||||
|
||||
---
|
||||
|
||||
## 13. Deployment Notes
|
||||
|
||||
- Python 3.9+ required. 3.12 tested on Windows.
|
||||
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
|
||||
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
|
||||
- MySQL port 3306 must be reachable from client
|
||||
- Default admin: username=admin / password=admin123 — change immediately
|
||||
- pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32
|
||||
- Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt)
|
||||
- Groq API key required for AI Summary feature — free at https://console.groq.com
|
||||
|
||||
---
|
||||
|
||||
## 14. AI Summary Feature — Setup & Prompt Details
|
||||
|
||||
### Setup (Admin)
|
||||
1. Obtain a free Groq API key at https://console.groq.com
|
||||
2. Navigate to **🤖 AI Summary** in the sidebar
|
||||
3. Enter the API key and select a model
|
||||
4. Click **💾 Save Settings** — key is stored DPAPI-encrypted in config.ini
|
||||
|
||||
### Supported Models
|
||||
- llama-3.3-70b-versatile (default, recommended)
|
||||
- llama-3.1-8b-instant
|
||||
- gemma2-9b-it
|
||||
- mixtral-8x7b-32768
|
||||
|
||||
### Prompt Strategy
|
||||
The prompt instructs the AI to extract 12 labeled fields per document:
|
||||
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference,
|
||||
POC, Square Footage, **Driving Distance from 2815 Hartland Rd Falls Church VA** (calculated by AI),
|
||||
Last Day for Questions, Due Date, Other requirements.
|
||||
|
||||
Then produces an Overall Summary with 4 sections:
|
||||
A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines
|
||||
|
||||
The driving distance/time is an AI estimate using major highways. Actual times may vary with traffic.
|
||||
|
||||
Binary file not shown.
@@ -75,6 +75,12 @@ 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)
|
||||
try:
|
||||
from config import migrate_plaintext_config
|
||||
migrate_plaintext_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Config migration skipped: {e}")
|
||||
self._show_login()
|
||||
|
||||
def _show_login(self):
|
||||
|
||||
+13
-3
@@ -2,13 +2,23 @@
|
||||
host = 67.217.62.199
|
||||
port = 3306
|
||||
database = webchecker
|
||||
user = webchecker
|
||||
password = 7x+MxGmks_3U
|
||||
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAm1sPKS5A3aczt5vkmqeO+XXJLmKwA1ojVXeBzQZ9QIwAAAAADoAAAAACAAAgAAAA6CLkACCqBm32SUXekn49rHMseysgdxMtdhywY0+zxO4QAAAA6KGNWrsRGwpImEXQZfuQikAAAABHFvkj2Z2choaPIoFwNryk31E6oZfI11uv/Ms5eIrN0uSC9VSdjYC9IU7SKCkK2z7BYQB2Uy9QoXZF6VFDXE/r
|
||||
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAO3bOCycWnlLWFscvK6zmWysxARgAyABh7eIYDzK/KN8AAAAADoAAAAACAAAgAAAAFU2hwhMfmMnWGTZL16cWfpc2rs5/Oy1lq7aEqeC5GcUQAAAAExqVIbNsn/vH99tw8NMfBUAAAAA+pf+tJVrYOMikcjrPcFLRKS+dR6+4OGBj/BiM4IgR07Zbk8GNxddhdb9Hg8/2D51oaywFNV55lDd9aJcrtnHS
|
||||
|
||||
[crypto]
|
||||
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
|
||||
|
||||
[groq]
|
||||
api_key = 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
|
||||
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+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAMimxQU449tZCXoe+d12bn9uAi6ZwuSzZGg7Mg/XYa5sAAAAADoAAAAACAAAgAAAAAnuQcfyetLSl/SIMvKqsb9GPv8e0Boh2/KUMnCKrtVcQAAAAU2cmO41/lsCJ3CBdnIYo1EAAAACXyd4+ESZhFnrZKdgWbDCwzVRSRyuOn37sdNpFrHRmGzD3Zy8/FivfcNBbFnfJNheVpeKsld+wnGfOlxiW0Tmm
|
||||
use_tls = true
|
||||
recipients = da.nguyen8744@gmail.com
|
||||
send_time = 18:00
|
||||
|
||||
|
||||
@@ -42,12 +42,18 @@ APP_TITLE = "Website Checker"
|
||||
APP_VERSION = "1.0.0"
|
||||
|
||||
|
||||
# Sensitive fields that are DPAPI-encrypted in config.ini
|
||||
_DB_SENSITIVE = {"user", "password"}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""
|
||||
Load DB settings from config.ini.
|
||||
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.
|
||||
"""
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = _cp.ConfigParser()
|
||||
if not _os.path.exists(CONFIG_FILE):
|
||||
return {}
|
||||
@@ -55,28 +61,77 @@ def load_config() -> dict:
|
||||
if "database" not in cfg:
|
||||
return {}
|
||||
section = cfg["database"]
|
||||
try:
|
||||
return {
|
||||
"host": section.get("host", ""),
|
||||
"port": section.getint("port", 3306),
|
||||
"database": section.get("database", ""),
|
||||
"user": section.get("user", ""),
|
||||
"password": section.get("password", ""),
|
||||
"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
|
||||
|
||||
|
||||
def save_config(host: str, port: int, database: str, user: str, password: str):
|
||||
"""Persist DB connection settings to config.ini."""
|
||||
"""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"] = {
|
||||
"host": host,
|
||||
"port": str(port),
|
||||
"database": database,
|
||||
"user": user,
|
||||
"password": password,
|
||||
"user": encrypt_value(user),
|
||||
"password": encrypt_value(password),
|
||||
}
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info(f"Configuration saved to {CONFIG_FILE}.")
|
||||
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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.")
|
||||
|
||||
|
||||
def config_exists() -> bool:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
utils/config_crypto.py — Transparent DPAPI encryption for config.ini values.
|
||||
|
||||
Uses Windows Data Protection API (CryptProtectData / CryptUnprotectData)
|
||||
via pywin32. Encrypted values are stored with a "dpapi:" prefix in
|
||||
config.ini so the format is self-documenting.
|
||||
|
||||
Key properties:
|
||||
- Tied to the current Windows USER account (not just the machine).
|
||||
The encrypted blob is completely unreadable on any other machine or
|
||||
under any other Windows account.
|
||||
- No key to manage, distribute, or store — Windows manages the key
|
||||
transparently via the user's password-derived master key.
|
||||
- Graceful fallback: if pywin32 is not available (e.g. running on a
|
||||
dev Linux box), values are stored/returned as plain text with a
|
||||
warning. This keeps the dev workflow intact.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from utils.config_crypto import encrypt_value, decrypt_value
|
||||
|
||||
stored = encrypt_value("my-secret") # "dpapi:AAAA..."
|
||||
secret = decrypt_value(stored) # "my-secret"
|
||||
|
||||
# For values that may already be plain text (migration):
|
||||
secret = decrypt_value("plain-text") # "plain-text" (no-op)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("config_crypto")
|
||||
|
||||
_DPAPI_PREFIX = "dpapi:"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level DPAPI wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dpapi_protect(plaintext: str) -> bytes:
|
||||
"""Encrypt a string with Windows DPAPI (current-user scope)."""
|
||||
import win32crypt # noqa: PLC0415
|
||||
data = plaintext.encode("utf-8")
|
||||
encrypted = win32crypt.CryptProtectData(
|
||||
data,
|
||||
"WebChecker config", # optional descriptive label
|
||||
None, # optional entropy (None = no extra entropy)
|
||||
None, # reserved
|
||||
None, # no UI prompt
|
||||
0, # flags: 0 = user-scope (default)
|
||||
)
|
||||
return encrypted
|
||||
|
||||
|
||||
def _dpapi_unprotect(ciphertext_bytes: bytes) -> str:
|
||||
"""Decrypt bytes produced by _dpapi_protect."""
|
||||
import win32crypt # noqa: PLC0415
|
||||
_desc, plaintext_bytes = win32crypt.CryptUnprotectData(
|
||||
ciphertext_bytes,
|
||||
None, # optional entropy
|
||||
None, # reserved
|
||||
None, # no UI prompt
|
||||
0, # flags
|
||||
)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""
|
||||
Encrypt *plaintext* and return a "dpapi:<base64>" string suitable for
|
||||
storage in config.ini.
|
||||
|
||||
If the value is already encrypted (starts with "dpapi:"), it is returned
|
||||
unchanged. If DPAPI is unavailable, the plain value is returned with a
|
||||
warning.
|
||||
"""
|
||||
if not plaintext:
|
||||
return plaintext
|
||||
|
||||
if plaintext.startswith(_DPAPI_PREFIX):
|
||||
return plaintext # already encrypted
|
||||
|
||||
try:
|
||||
ciphertext = _dpapi_protect(plaintext)
|
||||
return _DPAPI_PREFIX + base64.b64encode(ciphertext).decode("ascii")
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pywin32 not available — config values stored as plain text. "
|
||||
"Install pywin32 for credential protection."
|
||||
)
|
||||
return plaintext
|
||||
except Exception as exc:
|
||||
logger.error(f"DPAPI encryption failed: {exc}. Storing plain text.")
|
||||
return plaintext
|
||||
|
||||
|
||||
def decrypt_value(stored: str) -> str:
|
||||
"""
|
||||
Decrypt a value returned by encrypt_value().
|
||||
|
||||
- If *stored* starts with "dpapi:", it is decrypted and the plaintext
|
||||
is returned.
|
||||
- Otherwise the value is assumed to be plain text and returned as-is
|
||||
(handles legacy / non-Windows environments).
|
||||
|
||||
Raises RuntimeError if the DPAPI decryption fails (e.g. wrong user
|
||||
account or corrupted data).
|
||||
"""
|
||||
if not stored:
|
||||
return stored
|
||||
|
||||
if not stored.startswith(_DPAPI_PREFIX):
|
||||
return stored # plain text — pass through (legacy / no-DPAPI env)
|
||||
|
||||
b64_part = stored[len(_DPAPI_PREFIX):]
|
||||
try:
|
||||
ciphertext = base64.b64decode(b64_part)
|
||||
return _dpapi_unprotect(ciphertext)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pywin32 not available — cannot decrypt DPAPI value. "
|
||||
"Returning raw stored value."
|
||||
)
|
||||
return stored
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to decrypt a protected config value.\n\n"
|
||||
f"This usually means the config.ini was created by a different "
|
||||
f"Windows user account or on a different machine.\n\n"
|
||||
f"Please re-enter your settings in the Settings dialog.\n\n"
|
||||
f"Technical detail: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def is_encrypted(value: str) -> bool:
|
||||
"""Return True if *value* has already been DPAPI-encrypted."""
|
||||
return value.startswith(_DPAPI_PREFIX)
|
||||
+5
-3
@@ -38,6 +38,7 @@ _stop_event = threading.Event()
|
||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_email_config() -> dict:
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return {}
|
||||
@@ -50,7 +51,7 @@ def load_email_config() -> dict:
|
||||
"smtp_host": s.get("smtp_host", ""),
|
||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||
"smtp_user": s.get("smtp_user", ""),
|
||||
"smtp_password": s.get("smtp_password", ""),
|
||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
||||
"use_tls": s.getboolean("use_tls", fallback=True),
|
||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||
"send_time": s.get("send_time", "18:00"),
|
||||
@@ -60,6 +61,7 @@ def load_email_config() -> dict:
|
||||
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||
smtp_user: str, smtp_password: str, use_tls: bool,
|
||||
recipients: str, send_time: str):
|
||||
from utils.config_crypto import encrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
cfg["email"] = {
|
||||
@@ -67,14 +69,14 @@ def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": str(smtp_port),
|
||||
"smtp_user": smtp_user,
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_password": encrypt_value(smtp_password),
|
||||
"use_tls": str(use_tls).lower(),
|
||||
"recipients": recipients,
|
||||
"send_time": send_time,
|
||||
}
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info("Email configuration saved.")
|
||||
logger.info("Email configuration saved (password encrypted).")
|
||||
|
||||
|
||||
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple:
|
||||
|
||||
@@ -288,18 +288,34 @@ class WebsiteDialog(tk.Toplevel):
|
||||
# Hide initially; shown when visibility='assigned'
|
||||
self._user_assign_frame.pack_forget()
|
||||
|
||||
# ── Credentials section ───────────────────────────────────────────────
|
||||
# ── Credentials section (collapsed by default) ───────────────────────
|
||||
ttk.Separator(self.inner, orient="horizontal").pack(
|
||||
fill="x", padx=24, pady=12)
|
||||
cred_header = ttk.Frame(self.inner)
|
||||
cred_header.pack(fill="x", padx=24)
|
||||
ttk.Label(cred_header, text="Login Credentials",
|
||||
style="Heading.TLabel").pack(side="left")
|
||||
ttk.Button(cred_header, text="+ Add Credential",
|
||||
command=self._add_cred_row).pack(side="right")
|
||||
|
||||
self.creds_container = ttk.Frame(self.inner)
|
||||
self.creds_container.pack(fill="x", padx=24, pady=8)
|
||||
# Toggle header row
|
||||
cred_toggle = ttk.Frame(self.inner)
|
||||
cred_toggle.pack(fill="x", padx=24)
|
||||
|
||||
ttk.Label(cred_toggle, text="Login Credentials",
|
||||
style="Heading.TLabel").pack(side="left")
|
||||
|
||||
self._cred_expanded = False
|
||||
self._cred_toggle_btn = ttk.Button(
|
||||
cred_toggle,
|
||||
text="🔑 Show Credentials",
|
||||
style="Ghost.TButton",
|
||||
command=self._toggle_credentials,
|
||||
)
|
||||
self._cred_toggle_btn.pack(side="right")
|
||||
|
||||
ttk.Button(cred_toggle, text="+ Add Credential",
|
||||
command=self._add_cred_row_visible).pack(side="right", padx=(0, 6))
|
||||
|
||||
# Collapsible body
|
||||
self._cred_body = ttk.Frame(self.inner)
|
||||
self.creds_container = ttk.Frame(self._cred_body)
|
||||
self.creds_container.pack(fill="x")
|
||||
# _cred_body is NOT packed initially — hidden by default
|
||||
|
||||
# ── Buttons ───────────────────────────────────────────────────────────
|
||||
ttk.Separator(self.inner, orient="horizontal").pack(
|
||||
@@ -325,10 +341,13 @@ class WebsiteDialog(tk.Toplevel):
|
||||
for uid, var in self._user_vars.items():
|
||||
var.set(uid in assigned_ids)
|
||||
self._on_visibility_change()
|
||||
for cred in d.get("credentials", []):
|
||||
existing_creds = d.get("credentials", [])
|
||||
for cred in existing_creds:
|
||||
self._add_cred_row(cred)
|
||||
else:
|
||||
self._add_cred_row()
|
||||
# Auto-expand credentials section if creds already exist
|
||||
if existing_creds:
|
||||
self._expand_credentials()
|
||||
# For new websites: credentials section stays collapsed
|
||||
|
||||
def _on_visibility_change(self):
|
||||
"""Show or hide the user assignment panel based on visibility selection."""
|
||||
@@ -338,6 +357,27 @@ class WebsiteDialog(tk.Toplevel):
|
||||
else:
|
||||
self._user_assign_frame.pack_forget()
|
||||
|
||||
def _expand_credentials(self):
|
||||
"""Show the credentials body and update the toggle button label."""
|
||||
self._cred_expanded = True
|
||||
self._cred_body.pack(fill="x", padx=24, pady=(4, 0))
|
||||
self._cred_toggle_btn.config(text="🔒 Hide Credentials")
|
||||
|
||||
def _toggle_credentials(self):
|
||||
"""Show or hide the collapsible credentials section."""
|
||||
if self._cred_expanded:
|
||||
self._cred_expanded = False
|
||||
self._cred_body.pack_forget()
|
||||
self._cred_toggle_btn.config(text="🔑 Show Credentials")
|
||||
else:
|
||||
self._expand_credentials()
|
||||
|
||||
def _add_cred_row_visible(self):
|
||||
"""Expand the section (if collapsed) then add an empty credential row."""
|
||||
if not self._cred_expanded:
|
||||
self._expand_credentials()
|
||||
self._add_cred_row()
|
||||
|
||||
def _add_cred_row(self, cred=None):
|
||||
frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
|
||||
frame.pack(fill="x", pady=4, ipady=4)
|
||||
|
||||
@@ -156,11 +156,12 @@ class AiSummaryView(ttk.Frame):
|
||||
try:
|
||||
import configparser
|
||||
from config import CONFIG_FILE
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
if cfg.has_section(_CFG_SECTION):
|
||||
self._api_key_var.set(
|
||||
cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback=""))
|
||||
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
|
||||
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:
|
||||
@@ -169,15 +170,17 @@ class AiSummaryView(ttk.Frame):
|
||||
logger.warning(f"Could not load Groq config: {e}")
|
||||
|
||||
def _save_config(self):
|
||||
"""Persist API key / model to config.ini [groq] section."""
|
||||
"""Persist API key / model to config.ini [groq] section (key encrypted)."""
|
||||
try:
|
||||
import configparser
|
||||
from config import CONFIG_FILE
|
||||
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, self._api_key_var.get().strip())
|
||||
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)
|
||||
|
||||
@@ -362,6 +362,23 @@ class UserDashboardView(ttk.Frame):
|
||||
activeforeground=COLOURS["accent"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
).pack(pady=(0, 6))
|
||||
|
||||
# 🔑 Credentials button — only shown if this site has saved credentials
|
||||
try:
|
||||
from models import get_website_credentials
|
||||
creds = get_website_credentials(wid)
|
||||
except Exception:
|
||||
creds = []
|
||||
if creds:
|
||||
tk.Button(
|
||||
btn_frame, text="🔑 Credentials",
|
||||
command=lambda s=site, c=creds: CredentialsPopup(self, s["name"], c),
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
||||
activebackground=COLOURS["accent"],
|
||||
activeforeground=COLOURS["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=10, pady=4,
|
||||
).pack()
|
||||
|
||||
# ─── Bulk Actions ─────────────────────────────────────────────────────────
|
||||
@@ -507,11 +524,6 @@ class UserDashboardView(ttk.Frame):
|
||||
except Exception as e:
|
||||
show_error(f"Could not open URL:\n{e}")
|
||||
|
||||
from models import get_website_credentials
|
||||
creds = get_website_credentials(site["id"])
|
||||
if creds:
|
||||
CredentialsPopup(self, site["name"], creds)
|
||||
|
||||
def _mark_checked(self, site: dict):
|
||||
try:
|
||||
from models import mark_website_checked
|
||||
@@ -647,22 +659,39 @@ class CredentialsPopup(tk.Toplevel):
|
||||
label = cred.get("label") or "Default"
|
||||
tk.Label(frame, text=label, font=FONT_BOLD,
|
||||
bg=COLOURS["surface"], fg=COLOURS["accent"]).grid(
|
||||
row=0, column=0, columnspan=4, sticky="w", padx=10, pady=(4, 2))
|
||||
row=0, column=0, columnspan=6, sticky="w", padx=10, pady=(4, 2))
|
||||
|
||||
# ── Username row ──────────────────────────────────────────────────
|
||||
tk.Label(frame, text="Username:", bg=COLOURS["surface"],
|
||||
fg=COLOURS["text_dim"]).grid(
|
||||
row=1, column=0, sticky="w", padx=(10, 4))
|
||||
tk.Label(frame, text=cred["username"], bg=COLOURS["surface"],
|
||||
fg=COLOURS["text"], font=FONT_BOLD).grid(
|
||||
row=1, column=1, sticky="w", padx=(0, 20))
|
||||
row=1, column=1, sticky="w", padx=(0, 8))
|
||||
|
||||
user_copy_btn = tk.Button(
|
||||
frame, text="📋", width=3,
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
|
||||
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
|
||||
relief="flat", cursor="hand2", font=FONT_SMALL,
|
||||
)
|
||||
user_copy_btn.grid(row=1, column=2, padx=(0, 16))
|
||||
|
||||
def _copy_user(c=cred, b=user_copy_btn):
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(c["username"])
|
||||
b.config(text="✔", fg=COLOURS["success"])
|
||||
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
|
||||
user_copy_btn.config(command=_copy_user)
|
||||
|
||||
# ── Password row ──────────────────────────────────────────────────
|
||||
tk.Label(frame, text="Password:", bg=COLOURS["surface"],
|
||||
fg=COLOURS["text_dim"]).grid(
|
||||
row=1, column=2, sticky="w", padx=(0, 4))
|
||||
row=1, column=3, sticky="w", padx=(0, 4))
|
||||
pw_var = tk.StringVar(value="••••••••")
|
||||
tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"],
|
||||
fg=COLOURS["text"], font=FONT_BOLD).grid(
|
||||
row=1, column=3, sticky="w")
|
||||
row=1, column=4, sticky="w", padx=(0, 4))
|
||||
|
||||
revealed = [False]
|
||||
def toggle(c=cred, v=pw_var, r=revealed):
|
||||
@@ -671,7 +700,24 @@ class CredentialsPopup(tk.Toplevel):
|
||||
tk.Button(frame, text="👁", command=toggle,
|
||||
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
|
||||
relief="flat", cursor="hand2").grid(
|
||||
row=1, column=4, padx=(8, 10))
|
||||
row=1, column=5, padx=(0, 4))
|
||||
|
||||
pw_copy_btn = tk.Button(
|
||||
frame, text="📋", width=3,
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
|
||||
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
|
||||
relief="flat", cursor="hand2", font=FONT_SMALL,
|
||||
)
|
||||
pw_copy_btn.grid(row=1, column=6, padx=(0, 10))
|
||||
|
||||
def _copy_pw(c=cred, b=pw_copy_btn):
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(c["password"])
|
||||
b.config(text="✔", fg=COLOURS["success"])
|
||||
# Auto-clear clipboard after 15 s for security
|
||||
self.after(15_000, self.clipboard_clear)
|
||||
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
|
||||
pw_copy_btn.config(command=_copy_pw)
|
||||
|
||||
ttk.Button(self, text="Close", command=self.destroy).pack(pady=16)
|
||||
self._centre()
|
||||
|
||||
Reference in New Issue
Block a user