Initial commit

This commit is contained in:
Henrik Højmark committed 2026-04-11 04:38:11 +02:00
commit 9c7df76fbd
91 files changed
+36572

No files matched your search

+794
View File
@@ -0,0 +1,794 @@
"""
app_config.py — Configuration, i18n, keywords, profiles, settings,
SMTP config, file sources, and Fernet encryption for GDPRScanner.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re as _re
import time
import uuid as _uuid
from pathlib import Path
logger = logging.getLogger(__name__)
_DATA_DIR = Path.home() / ".gdprscanner"
_DATA_DIR.mkdir(exist_ok=True)
from typing import Optional
# ── i18n ──────────────────────────────────────────────────────────────────────
def _load_lang() -> dict:
import locale, sys as _sys, os as _os, subprocess as _sp
from pathlib import Path as _Path
_here = _Path(_sys._MEIPASS) if getattr(_sys, "frozen", False) else _Path(__file__).parent
lang_dir = _here / "lang"
lang_code = "en"
try:
if _sys.platform == "darwin":
try:
r = _sp.run(["defaults", "read", "-g", "AppleLocale"],
capture_output=True, text=True, timeout=3)
if r.returncode == 0 and r.stdout.strip():
lang_code = r.stdout.strip().split("_")[0].split("-")[0].lower()
except Exception:
pass
if lang_code == "en":
try:
r = _sp.run(["defaults", "read", "-g", "AppleLanguages"],
capture_output=True, text=True, timeout=3)
import re as _re
m = _re.search(r'"([a-z]{2})[-_]', r.stdout, _re.I)
if m:
lang_code = m.group(1).lower()
except Exception:
pass
else:
loc = (locale.getlocale()[0] or _os.environ.get("LC_ALL") or
_os.environ.get("LANG") or "en")
lang_code = loc.split("_")[0].split(".")[0].split("-")[0].lower() or "en"
except Exception:
lang_code = "en"
def _parse(path) -> dict:
import json as _json
out = {}
try:
if path.suffix == ".json":
out = _json.loads(path.read_text(encoding="utf-8"))
else:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
except Exception:
pass
return out
for code in [lang_code, "en"]:
# Prefer .json, fall back to .lang for backward compatibility
for ext in [".json", ".lang"]:
p = lang_dir / f"{code}{ext}"
if p.exists():
result = _parse(p)
result["_lang_code"] = code
logger.info("[i18n] loaded %s (%d keys)", p, len(result))
return result
return {}
def _load_lang_forced(code: str) -> dict:
import sys as _sys
from pathlib import Path as _Path
_here = _Path(_sys._MEIPASS) if getattr(_sys, "frozen", False) else _Path(__file__).parent
lang_dir = _here / "lang"
def _parse(path) -> dict:
import json as _json
out = {}
try:
if path.suffix == ".json":
out = _json.loads(path.read_text(encoding="utf-8"))
else:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
except Exception:
pass
return out
for c in [code, "en"]:
for ext in [".json", ".lang"]:
p = lang_dir / f"{c}{ext}"
if p.exists():
result = _parse(p)
result["_lang_code"] = c
return result
return {}
_LANG_OVERRIDE_FILE = _DATA_DIR / "lang"
def _lang_override() -> "str | None":
try:
v = _LANG_OVERRIDE_FILE.read_text().strip()
return v if v else None
except Exception:
return None
def _set_lang_override(code: str) -> None:
try:
_LANG_OVERRIDE_FILE.write_text(code.strip())
except Exception:
pass
# ── Display name resolver (used by scan_engine) ───────────────────────────────
import re as _re2
_GUID_RE = _re2.compile(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', _re2.I
)
_GENERIC_DISPLAY_NAMES = {
"microsoft konto", "microsoft account", "microsoftkonto",
"microsoft-konto", "compte microsoft", "cuenta de microsoft",
}
def _resolve_display_name(display_name: str, email: str = "", upn: str = "") -> str:
"""Return the best human-readable name for a Microsoft 365 user."""
dn = (display_name or "").strip()
if not dn or _GUID_RE.match(dn) or dn.lower() in _GENERIC_DISPLAY_NAMES:
return email or upn or dn
return dn
LANG = _load_lang_forced(_lang_override()) if _lang_override() else _load_lang()
logger.info("[i18n] gdpr lang=%s keys=%d", LANG.get("_lang_code", "?"), len(LANG))
# ── Article 9 sensitive keywords ──────────────────────────────────────────────
import re as _re
_KEYWORDS_PATH = Path(__file__).parent / "keywords"
_keyword_data: dict = {}
_keyword_flat: list = [] # (keyword, category_key) kept for reference / len count
_compiled_keywords: dict = {} # cat_key → compiled re.Pattern (#13)
_KEYWORD_WINDOW = 150 # characters around a keyword to check for CPR proximity
def _load_keywords(lang: str = "da") -> None:
"""Load keyword list from keywords/{lang}.json and compile one regex per
Article 9 category. Falls back to da.json if unavailable.
Each category pattern is an alternation of all its keywords, sorted
longest-first and anchored with negative-lookbehind/lookahead so that
short tokens (≤4 chars) require a word boundary while longer ones are
matched as substrings. The compiled regex is ~10–50× faster than the
previous sequential str.find() loop for large texts. (#13)
"""
global _keyword_data, _keyword_flat, _compiled_keywords
for candidate in [lang, "da"]:
p = _KEYWORDS_PATH / f"{candidate}.json"
if p.exists():
try:
import json as _kjson
_keyword_data = _kjson.loads(p.read_text(encoding="utf-8"))
flat: list = []
categories: dict = {}
for cat_key, cat_val in _keyword_data.items():
if cat_key.startswith("_") or not isinstance(cat_val, dict):
continue
kws = [kw.lower() for kw in cat_val.get("keywords", [])]
for kw in kws:
flat.append((kw, cat_key))
categories[cat_key] = kws
_keyword_flat = sorted(flat, key=lambda x: -len(x[0]))
# Compile one alternation regex per category (#13)
compiled: dict = {}
for cat, kws in categories.items():
if not kws:
continue
# Sort longest-first so the engine prefers the most specific match
sorted_kws = sorted(kws, key=len, reverse=True)
parts = []
for kw in sorted_kws:
esc = _re.escape(kw)
if len(kw) <= 4:
# Whole-word boundary for short tokens
parts.append(r"(?<!\w)" + esc + r"(?!\w)")
else:
parts.append(esc)
compiled[cat] = _re.compile(
"(?:" + "|".join(parts) + ")",
_re.IGNORECASE,
)
_compiled_keywords = compiled
logger.info("[keywords] Loaded %d keywords (%d categories compiled) from keywords/%s.json",
len(_keyword_flat), len(compiled), candidate)
return
except Exception as e:
logger.warning("[keywords] Failed to load %s: %s", p, e)
_load_keywords(LANG.get("_lang_code", "da"))
def _check_special_category(text: str, cprs: list) -> list:
"""Return sorted list of Article 9 category keys detected near a CPR number.
Uses compiled per-category regex patterns for efficient matching (#13).
A keyword counts only when within _KEYWORD_WINDOW characters of a CPR
in the same text. If no CPRs are present, any keyword occurrence triggers.
Returns e.g. ['health', 'criminal'] — empty list if none detected.
"""
if not _compiled_keywords or not text:
return []
text_lower = text.lower()
found_cats: set = set()
# Locate CPR positions for proximity check
cpr_positions: list = []
if cprs:
for m in _re.finditer(r"\d{6}[-\s]?\d{4}", text_lower):
cpr_positions.append(m.start())
for cat, pattern in _compiled_keywords.items():
# Use compiled regex — single-pass alternation match per category
for m in pattern.finditer(text_lower):
idx = m.start()
if not cpr_positions or any(
abs(idx - cp) <= _KEYWORD_WINDOW for cp in cpr_positions
):
found_cats.add(cat)
break # One match per category is enough
return sorted(found_cats)
_CONFIG_FILE = _DATA_DIR / "config.json"
import hashlib as _hashlib
_ADMIN_PIN_KEY = "admin_pin_hash"
def _get_admin_pin_hash() -> str:
"""Return the stored admin PIN hash, or empty string if not set."""
cfg = _load_config()
return cfg.get(_ADMIN_PIN_KEY, "")
def _set_admin_pin(pin: str) -> None:
"""Hash and store the admin PIN in the config file."""
h = _hashlib.sha256(pin.encode()).hexdigest()
cfg = _load_config()
cfg[_ADMIN_PIN_KEY] = h
_save_config(cfg)
def _verify_admin_pin(pin: str) -> bool:
"""Return True if the PIN matches the stored hash."""
stored = _get_admin_pin_hash()
if not stored:
return False
return _hashlib.sha256(pin.encode()).hexdigest() == stored
def _admin_pin_is_set() -> bool:
return bool(_get_admin_pin_hash())
def _load_config() -> dict:
if _CONFIG_FILE.exists():
try:
return json.loads(_CONFIG_FILE.read_text())
except Exception:
pass
return {}
def _save_config(cfg: dict):
try:
_CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
except Exception:
pass
# ── Profile storage (15a) ─────────────────────────────────────────────────────
_SETTINGS_PATH = _DATA_DIR / "settings.json"
_SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json"
def _load_src_toggles() -> dict:
"""Load persisted source toggle state."""
try:
if _SRC_TOGGLES_PATH.exists():
return json.loads(_SRC_TOGGLES_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def _save_src_toggles(state: dict) -> None:
"""Persist source toggle state."""
try:
existing = _load_src_toggles()
existing.update(state)
tmp = _SRC_TOGGLES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_SRC_TOGGLES_PATH)
except Exception as e:
logger.error("[src_toggles] write failed: %s", e)
def _profiles_load() -> list:
"""Return list of all profiles from settings file."""
try:
if not _SETTINGS_PATH.exists():
return []
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
# Migrate: old flat settings → wrapped in a default profile
if isinstance(data, dict) and "profiles" not in data and (
"sources" in data or "user_ids" in data
):
data = {"profiles": [_profile_from_settings(data, name="Default")]}
_profiles_write(data)
return data.get("profiles", [])
except Exception:
return []
def _profiles_write(data: dict) -> None:
"""Write the full settings dict (including profiles) atomically."""
try:
tmp = _SETTINGS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str),
encoding="utf-8")
tmp.replace(_SETTINGS_PATH)
except Exception as e:
logger.error("[profiles] write failed: %s", e)
def _profiles_save_all(profiles: list) -> None:
"""Overwrite the profiles list, preserving any other top-level keys."""
try:
data = {}
if _SETTINGS_PATH.exists():
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data["profiles"] = profiles
_profiles_write(data)
def _profile_from_settings(settings: dict, name: str = "Default",
description: str = "") -> dict:
"""Wrap a flat settings dict as a profile."""
import uuid as _uuid
return {
"id": str(_uuid.uuid4()),
"name": name,
"description": description,
"sources": settings.get("sources", []),
"google_sources": settings.get("google_sources", []),
"user_ids": settings.get("user_ids", []),
"options": settings.get("options", {}),
"retention_years": settings.get("retention_years"),
"fiscal_year_end": settings.get("fiscal_year_end"),
"email_to": settings.get("email_to", ""),
"file_sources": settings.get("file_sources", []),
"last_run": settings.get("last_run"),
"last_scan_id": settings.get("last_scan_id"),
}
def _profile_get(name_or_id: str) -> dict | None:
"""Find a profile by name (case-insensitive) or ID."""
for p in _profiles_load():
if p.get("id") == name_or_id or \
p.get("name", "").lower() == name_or_id.lower():
return p
return None
def _profile_save(profile: dict) -> dict:
"""Insert or update a profile. Assigns a new UUID if id is missing."""
import uuid as _uuid
if not profile.get("id"):
profile["id"] = str(_uuid.uuid4())
profiles = _profiles_load()
for i, p in enumerate(profiles):
if p.get("id") == profile["id"]:
profiles[i] = profile
_profiles_save_all(profiles)
return profile
profiles.append(profile)
_profiles_save_all(profiles)
return profile
def _profile_delete(name_or_id: str) -> bool:
"""Delete a profile by name or ID. Returns True if found and deleted."""
profiles = _profiles_load()
before = len(profiles)
profiles = [p for p in profiles
if p.get("id") != name_or_id
and p.get("name", "").lower() != name_or_id.lower()]
if len(profiles) == before:
return False
_profiles_save_all(profiles)
return True
def _profile_touch(profile_id: str, scan_id: int) -> None:
"""Update last_run and last_scan_id after a successful scan."""
import datetime as _dt2
profiles = _profiles_load()
for p in profiles:
if p.get("id") == profile_id:
p["last_run"] = _dt2.datetime.now().isoformat(timespec="seconds")
p["last_scan_id"] = scan_id
break
_profiles_save_all(profiles)
# ── Legacy shim — keep _save_settings / _load_settings working ────────────────
def _save_settings(payload: dict, profile_name: str | None = None,
profile_id: str | None = None) -> None:
"""Save settings. Upserts the active profile (or 'Default' if none).
profile_id takes precedence over profile_name when both are given."""
profiles = _profiles_load()
# Resolve profile: ID → name → first profile → "Default"
existing = None
if profile_id:
existing = _profile_get(profile_id)
if not existing and profile_name:
existing = _profile_get(profile_name)
if not existing and profiles:
existing = profiles[0]
name = existing["name"] if existing else (profile_name or "Default")
merged = _profile_from_settings(payload, name=name,
description=existing.get("description", "") if existing else "")
if existing:
merged["id"] = existing["id"]
merged["last_run"] = existing.get("last_run")
merged["last_scan_id"] = existing.get("last_scan_id")
# Scan start payloads only include M365 sources/user_ids/options.
# Preserve google_sources and file_sources so a single-source scan
# doesn't clobber the profile's other source selections.
_M365_IDS = {"email", "onedrive", "sharepoint", "teams"}
google_src = payload.get("google_sources", existing.get("google_sources", []))
file_src = payload.get("file_sources") or existing.get("file_sources", [])
merged["google_sources"] = google_src
merged["file_sources"] = file_src
# Rebuild combined sources: incoming M365 selection + preserved google/file
m365_src = [s for s in merged.get("sources", []) if s in _M365_IDS]
merged["sources"] = m365_src + google_src + file_src
_profile_save(merged)
def _load_settings() -> dict | None:
"""Return the first (default) profile as a flat settings dict."""
profiles = _profiles_load()
if not profiles:
return None
p = profiles[0]
return {
"sources": p.get("sources", []),
"user_ids": p.get("user_ids", []),
"options": p.get("options", {}),
"retention_years": p.get("retention_years"),
"fiscal_year_end": p.get("fiscal_year_end"),
"email_to": p.get("email_to", ""),
}
# ── SMTP / email report sending ───────────────────────────────────────────────
_SMTP_CONFIG_PATH = _DATA_DIR / "smtp.json"
_ROLE_OVERRIDES_PATH = _DATA_DIR / "role_overrides.json"
def _load_role_overrides() -> dict:
"""Return {user_id: 'student'|'staff'|'other'} manual overrides dict."""
try:
if _ROLE_OVERRIDES_PATH.exists():
return json.loads(_ROLE_OVERRIDES_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def _save_role_overrides(overrides: dict) -> None:
"""Atomically write the role overrides dict to disk."""
try:
tmp = _ROLE_OVERRIDES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_ROLE_OVERRIDES_PATH)
except Exception as e:
logger.error("[role_overrides] write failed: %s", e)
# ── File source settings (#8) ─────────────────────────────────────────────────
_FILE_SOURCES_PATH = _DATA_DIR / "file_sources.json"
def _load_file_sources() -> list:
"""Return saved file source definitions.
Each entry: {id, label, path, smb_host, smb_user, smb_domain, keychain_key}
"""
try:
if _FILE_SOURCES_PATH.exists():
return json.loads(_FILE_SOURCES_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return []
def _save_file_sources(sources: list) -> None:
"""Atomically write the file sources list to disk."""
try:
tmp = _FILE_SOURCES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(sources, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_FILE_SOURCES_PATH)
except Exception as e:
logger.error("[file_sources] write failed: %s", e)
# ── Viewer tokens ────────────────────────────────────────────────────────────
# Read-only viewer tokens allow sharing scan results with a DPO or compliance
# officer without exposing scan controls or credentials. Each token is a
# 64-character hex string stored in viewer_tokens.json alongside other data files.
_VIEWER_TOKENS_PATH = _DATA_DIR / "viewer_tokens.json"
def _load_viewer_tokens() -> list:
"""Return list of viewer token dicts (empty list if file missing or corrupt)."""
try:
if _VIEWER_TOKENS_PATH.exists():
return json.loads(_VIEWER_TOKENS_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return []
def _save_viewer_tokens(tokens: list) -> None:
"""Atomically write viewer tokens to disk."""
try:
tmp = _VIEWER_TOKENS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(tokens, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_VIEWER_TOKENS_PATH)
except Exception as e:
logger.error("[viewer_tokens] write failed: %s", e)
def create_viewer_token(label: str = "", expires_days: int | None = None) -> dict:
"""Generate a new viewer token, persist it, and return the token dict.
Args:
label: Human-readable description (e.g. "DPO review April 2026").
expires_days: Days until expiry. None = no expiry.
"""
import secrets as _secrets
token = _secrets.token_hex(32) # 64-char URL-safe hex string
now = time.time()
entry: dict = {
"token": token,
"label": label or "",
"created_at": now,
"expires_at": now + expires_days * 86400 if expires_days else None,
"last_used_at": None,
}
tokens = _load_viewer_tokens()
tokens.append(entry)
_save_viewer_tokens(tokens)
return entry
def validate_viewer_token(token: str) -> dict | None:
"""Return the token dict if the token is valid and not expired, else None.
Updates last_used_at as a best-effort side effect.
"""
if not token:
return None
tokens = _load_viewer_tokens()
now = time.time()
found: dict | None = None
for entry in tokens:
if entry.get("token") == token:
exp = entry.get("expires_at")
if exp is not None and now > exp:
return None # expired — treat as not found
found = entry
break
if found is None:
return None
found["last_used_at"] = now
_save_viewer_tokens(tokens) # best-effort; ignore failures
return found
def revoke_viewer_token(token: str) -> bool:
"""Remove a token from storage. Returns True if found and removed."""
tokens = _load_viewer_tokens()
before = len(tokens)
tokens = [t for t in tokens if t.get("token") != token]
if len(tokens) == before:
return False
_save_viewer_tokens(tokens)
return True
def cleanup_expired_viewer_tokens() -> int:
"""Delete all expired tokens from storage. Returns count removed."""
tokens = _load_viewer_tokens()
now = time.time()
active = [t for t in tokens if t.get("expires_at") is None or now <= t["expires_at"]]
removed = len(tokens) - len(active)
if removed:
_save_viewer_tokens(active)
return removed
# ── Viewer PIN ───────────────────────────────────────────────────────────────
# A numeric PIN that grants a browser session read-only viewer access at /view.
# The PIN is stored as a salted SHA-256 hash inside viewer_tokens.json under a
# top-level "__pin__" key so it lives in the same file as the token list.
_PIN_META_KEY = "__pin__"
def _load_pin_store() -> dict:
"""Load the full viewer_tokens.json as a dict (tokens list + optional pin meta)."""
try:
if _VIEWER_TOKENS_PATH.exists():
raw = json.loads(_VIEWER_TOKENS_PATH.read_text(encoding="utf-8"))
if isinstance(raw, list):
# Legacy format — just a list; promote to dict
return {"tokens": raw}
if isinstance(raw, dict):
return raw
except Exception:
pass
return {"tokens": []}
def _save_pin_store(store: dict) -> None:
try:
tmp = _VIEWER_TOKENS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_VIEWER_TOKENS_PATH)
except Exception as e:
logger.error("[viewer_pin] write failed: %s", e)
# Rewrite the token helpers to use the new store format transparently.
def _load_viewer_tokens() -> list: # type: ignore[misc] # noqa: F811
return _load_pin_store().get("tokens", [])
def _save_viewer_tokens(tokens: list) -> None: # type: ignore[misc] # noqa: F811
store = _load_pin_store()
store["tokens"] = tokens
_save_pin_store(store)
def get_viewer_pin_hash() -> "str | None":
"""Return the stored PIN hash dict, or None if no PIN is set."""
return _load_pin_store().get(_PIN_META_KEY)
def set_viewer_pin(pin: str) -> None:
"""Hash and store a viewer PIN."""
import hashlib as _hl, secrets as _sec
if not pin:
raise ValueError("PIN must not be empty")
salt = _sec.token_hex(16)
h = _hl.sha256((salt + pin).encode()).hexdigest()
store = _load_pin_store()
store[_PIN_META_KEY] = {"hash": h, "salt": salt}
_save_pin_store(store)
def verify_viewer_pin(pin: str) -> bool:
"""Return True if *pin* matches the stored hash."""
import hashlib as _hl
meta = get_viewer_pin_hash()
if not meta:
return False
h = _hl.sha256((meta["salt"] + pin).encode()).hexdigest()
return h == meta["hash"]
def clear_viewer_pin() -> None:
"""Remove the viewer PIN."""
store = _load_pin_store()
store.pop(_PIN_META_KEY, None)
_save_pin_store(store)
# ── SMTP password encryption ─────────────────────────────────────────────────
# The SMTP password is encrypted at rest using Fernet symmetric encryption.
# The encryption key is derived from a stable machine-specific UUID stored in
# ~/.gdpr_scanner_machine_id. This key is only usable on the same machine —
# the encrypted password cannot be decrypted if the config file is copied to
# another host.
_MACHINE_ID_PATH = _DATA_DIR / "machine_id"
try:
from cryptography.fernet import Fernet as _Fernet
import base64 as _b64
_CRYPTO_OK = True
except ImportError:
_CRYPTO_OK = False
def _get_fernet() -> "Optional[_Fernet]":
"""Return a Fernet instance keyed to this machine, or None if unavailable."""
if not _CRYPTO_OK:
return None
try:
if _MACHINE_ID_PATH.exists():
machine_key = _MACHINE_ID_PATH.read_bytes()
else:
machine_key = _Fernet.generate_key()
_MACHINE_ID_PATH.write_bytes(machine_key)
try:
_MACHINE_ID_PATH.chmod(0o600)
except Exception:
pass
return _Fernet(machine_key)
except Exception:
return None
def _encrypt_password(plaintext: str) -> str:
"""Encrypt a password string; returns a 'enc:' prefixed ciphertext string."""
if not plaintext:
return ""
f = _get_fernet()
if f is None:
return plaintext # fallback: store as-is (no cryptography lib)
try:
return "enc:" + f.encrypt(plaintext.encode()).decode()
except Exception:
return plaintext
def _decrypt_password(stored: str) -> str:
"""Decrypt a stored password; handles both encrypted and legacy plaintext."""
if not stored:
return ""
if not stored.startswith("enc:"):
return stored # legacy plaintext — return as-is
f = _get_fernet()
if f is None:
return ""
try:
return f.decrypt(stored[4:].encode()).decode()
except Exception:
return ""
def _load_smtp_config() -> dict:
"""Return saved SMTP config, decrypting the password field."""
try:
if _SMTP_CONFIG_PATH.exists():
cfg = json.loads(_SMTP_CONFIG_PATH.read_text(encoding="utf-8"))
if cfg.get("password"):
cfg["password"] = _decrypt_password(cfg["password"])
return cfg
except Exception:
pass
return {}
def _save_smtp_config(cfg: dict) -> None:
"""Save SMTP config, encrypting the password field."""
try:
to_save = dict(cfg)
if to_save.get("password"):
to_save["password"] = _encrypt_password(to_save["password"])
tmp = _SMTP_CONFIG_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(to_save, ensure_ascii=False), encoding="utf-8")
tmp.replace(_SMTP_CONFIG_PATH)
try:
_SMTP_CONFIG_PATH.chmod(0o600)
except Exception:
pass
except Exception as e:
logger.error("[smtp] config save failed: %s", e)