-
Notifications
You must be signed in to change notification settings - Fork 0
Fix settings file safety, export filtering, container permissions, and i18n in install flow #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,14 +2,17 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import json | ||
| import logging | ||
| import os | ||
| import tempfile | ||
| from datetime import datetime, timedelta, timezone | ||
| from pathlib import Path | ||
| from typing import Any, Dict, List | ||
|
|
||
| from .config import SETTINGS_FILE | ||
| from .settings import _settings_file_lock | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -73,49 +76,68 @@ def save_email_config(config: Dict[str, Any]) -> bool: | |
| """Save email configuration into app_settings.json.""" | ||
| SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| # Read the current file so we keep all other keys intact | ||
| existing: Dict[str, Any] = {} | ||
| if SETTINGS_FILE.exists(): | ||
| try: | ||
| with SETTINGS_FILE.open("r", encoding="utf-8") as handle: | ||
| existing = json.load(handle) | ||
| except Exception: | ||
| pass | ||
|
|
||
| # Merge with defaults | ||
| persisted = DEFAULT_EMAIL_CONFIG.copy() | ||
| persisted.update(config) | ||
|
|
||
| if 'lastSent' not in config: | ||
| persisted['lastSent'] = existing.get('lastSent') | ||
|
|
||
| # lastSent will be preserved from the existing file inside the lock below | ||
| persisted.pop('lastSent', None) | ||
|
|
||
| # Validate file types | ||
| valid_file_types = {"svg", "png", "pdf", "xlsx"} | ||
| persisted["fileTypes"] = [ | ||
| ft for ft in persisted.get("fileTypes", []) | ||
| ft for ft in persisted.get("fileTypes", []) | ||
| if ft in valid_file_types | ||
| ] | ||
|
|
||
| # Validate frequency | ||
| if persisted.get("frequency") not in ("daily", "weekly", "monthly"): | ||
| persisted["frequency"] = "weekly" | ||
|
|
||
| # Validate day of week | ||
| valid_days = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} | ||
| if persisted.get("dayOfWeek", "").lower() not in valid_days: | ||
| persisted["dayOfWeek"] = "monday" | ||
|
|
||
| # Validate day of month | ||
| if persisted.get("dayOfMonth") not in ("first", "last"): | ||
| persisted["dayOfMonth"] = "first" | ||
|
|
||
| # Write email keys back into the shared config file | ||
| existing.update(persisted) | ||
|
|
||
|
|
||
| logger.info("Saving email configuration to: %s", SETTINGS_FILE) | ||
| try: | ||
| with SETTINGS_FILE.open("w", encoding="utf-8") as handle: | ||
| json.dump(existing, handle, indent=2) | ||
| with _settings_file_lock: | ||
| # Read the current file so we keep all other keys intact | ||
| existing: Dict[str, Any] = {} | ||
| if SETTINGS_FILE.exists(): | ||
| try: | ||
| with SETTINGS_FILE.open("r", encoding="utf-8") as handle: | ||
| loaded = json.load(handle) | ||
| if isinstance(loaded, dict): | ||
| existing = loaded | ||
| except Exception: | ||
| pass | ||
|
|
||
| # Preserve existing lastSent when caller didn't explicitly supply one | ||
| if 'lastSent' not in config: | ||
| persisted['lastSent'] = existing.get('lastSent') | ||
|
|
||
| # Write email keys back into the shared config file | ||
| existing.update(persisted) | ||
|
|
||
| # Atomic write: write to a temp file then replace | ||
| tmp_fd, tmp_path = tempfile.mkstemp( | ||
| dir=SETTINGS_FILE.parent, suffix=".tmp" | ||
| ) | ||
| try: | ||
| with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp_handle: | ||
| json.dump(existing, tmp_handle, indent=2) | ||
| os.replace(tmp_path, SETTINGS_FILE) | ||
| except Exception: | ||
|
Comment on lines
+109
to
+136
|
||
| with contextlib.suppress(OSError): | ||
| os.unlink(tmp_path) | ||
| raise | ||
|
|
||
| logger.info("Email configuration saved successfully") | ||
| return True | ||
| except Exception as error: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,14 +2,22 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import json | ||
| import logging | ||
| import os | ||
| import re | ||
| import tempfile | ||
| import threading | ||
| from typing import Any, Dict, Iterable, Set | ||
|
|
||
| from .config import SETTINGS_FILE | ||
|
|
||
| # Shared in-process lock protecting all reads/writes to SETTINGS_FILE. | ||
| # Both settings.py and email_config.py import this lock so that concurrent | ||
| # writes from different code paths (scheduler, HTTP handlers) are serialised. | ||
| _settings_file_lock = threading.Lock() | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DEFAULT_SETTINGS: Dict[str, Any] = { | ||
|
|
@@ -151,15 +159,6 @@ def save_settings(settings: Dict[str, Any]) -> bool: | |
| """Persist settings to disk, returning True on success.""" | ||
| SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| # Read the current file so we keep all other keys intact | ||
| existing: Dict[str, Any] = {} | ||
| if SETTINGS_FILE.exists(): | ||
| try: | ||
| with SETTINGS_FILE.open("r", encoding="utf-8") as handle: | ||
| existing = json.load(handle) | ||
| except Exception: | ||
| pass | ||
|
|
||
| # Update stored defaults with provided overrides | ||
| persisted = DEFAULT_SETTINGS.copy() | ||
| persisted.update(settings) | ||
|
|
@@ -180,12 +179,34 @@ def save_settings(settings: Dict[str, Any]) -> bool: | |
| for level, color in default_node_colors.items() | ||
| } | ||
|
|
||
| existing.update(persisted) | ||
|
|
||
| logger.info("Attempting to save settings to: %s", SETTINGS_FILE) | ||
| try: | ||
| with SETTINGS_FILE.open("w", encoding="utf-8") as handle: | ||
| json.dump(existing, handle, indent=2) | ||
| with _settings_file_lock: | ||
| # Read the current file so we keep all other keys intact | ||
| existing: Dict[str, Any] = {} | ||
| if SETTINGS_FILE.exists(): | ||
| try: | ||
| with SETTINGS_FILE.open("r", encoding="utf-8") as handle: | ||
| loaded = json.load(handle) | ||
| if isinstance(loaded, dict): | ||
| existing = loaded | ||
| except Exception: | ||
| pass | ||
|
|
||
| existing.update(persisted) | ||
|
|
||
| # Atomic write: write to a temp file then replace | ||
| tmp_fd, tmp_path = tempfile.mkstemp( | ||
| dir=SETTINGS_FILE.parent, suffix=".tmp" | ||
| ) | ||
| try: | ||
| with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp_handle: | ||
| json.dump(existing, tmp_handle, indent=2) | ||
| os.replace(tmp_path, SETTINGS_FILE) | ||
|
Comment on lines
+184
to
+205
|
||
| except Exception: | ||
| with contextlib.suppress(OSError): | ||
| os.unlink(tmp_path) | ||
| raise | ||
| except Exception as error: # noqa: BLE001 - mirror legacy behaviour | ||
| logger.error("Error saving settings to %s: %s", SETTINGS_FILE, error) | ||
| return False | ||
|
|
@@ -288,6 +309,7 @@ def employee_is_ignored(name: str | None, email: str | None, user_principal_name | |
|
|
||
| __all__ = [ | ||
| "DEFAULT_SETTINGS", | ||
| "_settings_file_lock", | ||
| "department_is_ignored", | ||
|
Comment on lines
310
to
313
|
||
| "employee_is_ignored", | ||
| "load_settings", | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2337,15 +2337,15 @@ async function _initUserScannerConfigUI(isEnabled) { | |||||||||||||
| const data = await resp.json(); | ||||||||||||||
|
|
||||||||||||||
| if (!data.installed) { | ||||||||||||||
| statusEl.textContent = 'Not installed — download to enable the scanner.'; | ||||||||||||||
| statusEl.textContent = resolveTranslation('configure.userScanner.install.notInstalled', 'Not installed — download to enable the scanner.'); | ||||||||||||||
| if (installRow) installRow.style.display = 'flex'; | ||||||||||||||
| if (updateRow) updateRow.style.display = 'none'; | ||||||||||||||
|
|
||||||||||||||
| // Wire up install button | ||||||||||||||
| if (installBtn) { | ||||||||||||||
| installBtn.onclick = async () => { | ||||||||||||||
| installBtn.disabled = true; | ||||||||||||||
| if (installStatusEl) installStatusEl.textContent = 'Downloading…'; | ||||||||||||||
| if (installStatusEl) installStatusEl.textContent = resolveTranslation('configure.userScanner.install.downloading', 'Downloading…'); | ||||||||||||||
| try { | ||||||||||||||
| const installResp = await fetch(`${window.location.origin}/api/user-scanner/install`, { | ||||||||||||||
| method: 'POST', | ||||||||||||||
|
|
@@ -2354,14 +2354,41 @@ async function _initUserScannerConfigUI(isEnabled) { | |||||||||||||
| const result = await installResp.json(); | ||||||||||||||
| if (result.success) { | ||||||||||||||
| if (installStatusEl) installStatusEl.textContent = ''; | ||||||||||||||
| // Re-init to show installed state | ||||||||||||||
| _initUserScannerConfigUI(true); | ||||||||||||||
| // Refresh status UI without re-running full init to avoid duplicate listeners | ||||||||||||||
| try { | ||||||||||||||
| const statusResp = await fetch(`${window.location.origin}/api/user-scanner/status`, { | ||||||||||||||
| credentials: 'include', | ||||||||||||||
| }); | ||||||||||||||
| if (statusResp.ok) { | ||||||||||||||
| const latest = await statusResp.json(); | ||||||||||||||
| if (latest.installed) { | ||||||||||||||
| if (installRow) installRow.style.display = 'none'; | ||||||||||||||
| const ver = latest.version || 'unknown'; | ||||||||||||||
| statusEl.innerHTML = ''; | ||||||||||||||
| const versionText = document.createElement('span'); | ||||||||||||||
| versionText.textContent = `Installed — v${ver}`; | ||||||||||||||
| statusEl.appendChild(versionText); | ||||||||||||||
| const sep = document.createTextNode(' · '); | ||||||||||||||
| statusEl.appendChild(sep); | ||||||||||||||
| const repoLink = document.createElement('a'); | ||||||||||||||
| repoLink.href = 'https://github.com/kaifcodec/user-scanner'; | ||||||||||||||
| repoLink.target = '_blank'; | ||||||||||||||
| repoLink.rel = 'noopener noreferrer'; | ||||||||||||||
| repoLink.textContent = 'GitHub repo ↗'; | ||||||||||||||
| repoLink.style.fontSize = 'inherit'; | ||||||||||||||
|
Comment on lines
+2366
to
+2378
|
||||||||||||||
| statusEl.appendChild(repoLink); | ||||||||||||||
| if (updateRow) updateRow.style.display = 'flex'; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } catch { | ||||||||||||||
| // Ignore refresh errors; installation itself succeeded | ||||||||||||||
|
Comment on lines
+2383
to
+2384
|
||||||||||||||
| } catch { | |
| // Ignore refresh errors; installation itself succeeded | |
| } catch (refreshErr) { | |
| // Installation itself succeeded, but refreshing status failed; log and re-enable the button | |
| console.error('Failed to refresh user scanner status after install:', refreshErr); | |
| installBtn.disabled = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file-size check uses
uploaded.seek(0, 2)(magic number) whereas other upload handlers in this file useos.SEEK_END. Usingos.SEEK_ENDhere improves readability and consistency.