From 201d58c94c23cd8c17775dac9874b116a34e0ea9 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Sun, 15 Mar 2026 21:19:00 -0400 Subject: [PATCH 01/14] Auto-configure Drive folder and enable report backups --- app.py | 25 ++++++++----- nmapui/app_composition.py | 4 +++ nmapui/app_handler_registration.py | 2 ++ nmapui/app_task_bindings.py | 2 ++ nmapui/google_drive.py | 58 ++++++++++++++++++++++++++++++ nmapui/handlers/settings.py | 36 ++++++++++++++++--- nmapui/workflow_context.py | 2 ++ nmapui/workflows.py | 20 +++++++++++ static/js/settings_tab.js | 13 +++++++ 9 files changed, 150 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index 5d441fb..4862985 100644 --- a/app.py +++ b/app.py @@ -76,6 +76,7 @@ build_google_drive_auth_url, disconnect_google_drive, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, upload_files_to_google_drive, ) from nmapui.runtime import ( @@ -279,6 +280,14 @@ def safe_emit(event, data=None): ) run_traceroute = traceroute_bindings["run_traceroute"] +upload_report_artifacts_to_google_drive = lambda *, scan_path, file_paths, metadata, settings_state: upload_files_to_google_drive( + credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, + token_path=GOOGLE_DRIVE_TOKEN_FILE, + key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, + file_paths=file_paths, + folder_id=str((((settings_state or {}).get("sync") or {}).get("google_drive") or {}).get("folder_id", "") or "").strip(), + requests_module=requests, +) register_app_handlers( app=app, @@ -318,14 +327,7 @@ def safe_emit(event, data=None): startup_state=startup_state, runtime_store=runtime_store, get_auto_scan_thread=runtime_bindings["get_auto_scan_thread"], - upload_report_artifacts_to_google_drive=lambda *, scan_path, file_paths, metadata, settings_state: upload_files_to_google_drive( - credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, - token_path=GOOGLE_DRIVE_TOKEN_FILE, - key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, - file_paths=file_paths, - folder_id=str((((settings_state or {}).get("sync") or {}).get("google_drive") or {}).get("folder_id", "") or "").strip(), - requests_module=requests, - ), + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, get_customer_fingerprinter=lambda: customer_fingerprinter, get_current_customer=lambda: get_current_customer_state(request.sid), set_current_customer=lambda value: set_current_customer_state( @@ -379,6 +381,12 @@ def safe_emit(event, data=None): state=state, requests_module=requests, ), + ensure_google_drive_reports_folder=lambda: ensure_google_drive_reports_folder( + credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, + token_path=GOOGLE_DRIVE_TOKEN_FILE, + key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, + requests_module=requests, + ), disconnect_google_drive=lambda: disconnect_google_drive( token_path=GOOGLE_DRIVE_TOKEN_FILE, key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, @@ -432,6 +440,7 @@ def safe_emit(event, data=None): current_customer=current_customer, extract_scan_statistics=extract_scan_statistics, customer_fingerprinter=customer_fingerprinter, + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, runtime_store=runtime_store, find_latest_saved_scan_for_pdf=find_latest_saved_scan_for_pdf, load_json_document=load_json_document, diff --git a/nmapui/app_composition.py b/nmapui/app_composition.py index 3d0e770..13a5af0 100644 --- a/nmapui/app_composition.py +++ b/nmapui/app_composition.py @@ -95,6 +95,7 @@ def build_report_task_deps( save_scan_metadata, scans_dir, settings_state, + upload_report_artifacts_to_google_drive, socketio_sleep, split_subnet_into_chunks, stylesheet, @@ -117,6 +118,7 @@ def build_report_task_deps( "create_scan_folder": create_scan_folder, "scans_dir": scans_dir, "settings_state": settings_state, + "upload_report_artifacts_to_google_drive": upload_report_artifacts_to_google_drive, "sanitize_customer_dir_name": sanitize_customer_dir_name, "run_nmap_with_xml_output": run_nmap_with_xml_output, "merge_nmap_xml_files": merge_nmap_xml_files, @@ -373,6 +375,7 @@ def build_settings_routes_deps( get_google_drive_auth_status, build_google_drive_auth_url, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, disconnect_google_drive, ): return { @@ -383,6 +386,7 @@ def build_settings_routes_deps( "get_google_drive_auth_status": get_google_drive_auth_status, "build_google_drive_auth_url": build_google_drive_auth_url, "exchange_google_drive_auth_code": exchange_google_drive_auth_code, + "ensure_google_drive_reports_folder": ensure_google_drive_reports_folder, "disconnect_google_drive": disconnect_google_drive, } diff --git a/nmapui/app_handler_registration.py b/nmapui/app_handler_registration.py index 977ff32..86b0147 100644 --- a/nmapui/app_handler_registration.py +++ b/nmapui/app_handler_registration.py @@ -72,6 +72,7 @@ def register_app_handlers( get_google_drive_auth_status, build_google_drive_auth_url, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, disconnect_google_drive, logger, ): @@ -186,6 +187,7 @@ def register_app_handlers( get_google_drive_auth_status=get_google_drive_auth_status, build_google_drive_auth_url=build_google_drive_auth_url, exchange_google_drive_auth_code=exchange_google_drive_auth_code, + ensure_google_drive_reports_folder=ensure_google_drive_reports_folder, disconnect_google_drive=disconnect_google_drive, ), ) diff --git a/nmapui/app_task_bindings.py b/nmapui/app_task_bindings.py index 2e3b5da..e2658ed 100644 --- a/nmapui/app_task_bindings.py +++ b/nmapui/app_task_bindings.py @@ -49,6 +49,7 @@ def build_task_bindings( current_customer, extract_scan_statistics, customer_fingerprinter, + upload_report_artifacts_to_google_drive, runtime_store, find_latest_saved_scan_for_pdf, load_json_document, @@ -134,6 +135,7 @@ def generate_report_task(sid, data): create_scan_folder=create_scan_folder, scans_dir=scans_dir, settings_state=settings_state, + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, sanitize_customer_dir_name=sanitize_customer_dir_name, run_nmap_with_xml_output=run_nmap_with_xml_output, merge_nmap_xml_files=merge_nmap_xml_files, diff --git a/nmapui/google_drive.py b/nmapui/google_drive.py index 1df2f19..1427f4a 100644 --- a/nmapui/google_drive.py +++ b/nmapui/google_drive.py @@ -15,6 +15,8 @@ GOOGLE_DRIVE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke" GOOGLE_DRIVE_UPLOAD_ENDPOINT = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink" GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file" +GOOGLE_DRIVE_FILES_ENDPOINT = "https://www.googleapis.com/drive/v3/files" +GOOGLE_DRIVE_FOLDER_MIME = "application/vnd.google-apps.folder" ENCRYPTED_TOKEN_SCHEMA_VERSION = 1 @@ -355,3 +357,59 @@ def upload_files_to_google_drive( "uploaded": uploaded, "status": f"Uploaded {len(uploaded)} file(s) to Google Drive", } + + +def ensure_google_drive_reports_folder( + *, + credentials_path: Path, + token_path: Path, + key_path: Path | None = None, + requests_module, + folder_name: str = "nmapui-reports", +) -> dict: + access_token = ensure_google_drive_access_token( + credentials_path=credentials_path, + token_path=token_path, + key_path=key_path, + requests_module=requests_module, + ) + headers = {"Authorization": f"Bearer {access_token}"} + query = ( + "mimeType='application/vnd.google-apps.folder' " + f"and name='{folder_name}' and trashed=false" + ) + response = requests_module.get( + GOOGLE_DRIVE_FILES_ENDPOINT, + headers=headers, + params={"q": query, "fields": "files(id,name)"}, + timeout=10, + ) + payload = response.json() + if response.status_code >= 400: + return { + "success": False, + "error": payload.get("error", {}).get("message") or "Failed to query Drive folder", + } + + files = payload.get("files") or [] + if files: + folder_id = files[0].get("id") + return {"success": True, "folder_id": folder_id, "status": "Drive folder ready"} + + create_response = requests_module.post( + GOOGLE_DRIVE_FILES_ENDPOINT, + headers={**headers, "Content-Type": "application/json"}, + json={"name": folder_name, "mimeType": GOOGLE_DRIVE_FOLDER_MIME}, + timeout=10, + ) + create_payload = create_response.json() + if create_response.status_code >= 400: + return { + "success": False, + "error": create_payload.get("error", {}).get("message") or "Failed to create Drive folder", + } + return { + "success": True, + "folder_id": create_payload.get("id"), + "status": "Drive folder created", + } diff --git a/nmapui/handlers/settings.py b/nmapui/handlers/settings.py index ee88333..9dc9ec2 100644 --- a/nmapui/handlers/settings.py +++ b/nmapui/handlers/settings.py @@ -12,6 +12,7 @@ def register_settings_routes(app, deps): get_google_drive_auth_status = deps["get_google_drive_auth_status"] build_google_drive_auth_url = deps["build_google_drive_auth_url"] exchange_google_drive_auth_code = deps["exchange_google_drive_auth_code"] + ensure_google_drive_reports_folder = deps["ensure_google_drive_reports_folder"] disconnect_google_drive = deps["disconnect_google_drive"] @app.route("/api/settings") @@ -87,10 +88,37 @@ def google_drive_callback_route(): f"

{result.get('error', 'Unknown error')}

", 400, ) - return ( - "

Google Drive connected

" - "

You can close this window and return to NmapUI.

" - ) + folder_result = ensure_google_drive_reports_folder() + normalized = normalize_settings_document(settings_state) + google_drive_state = normalized.get("sync", {}).get("google_drive", {}) + if folder_result.get("success"): + google_drive_state.update( + { + "enabled": True, + "folder_id": folder_result.get("folder_id", ""), + "status": "Connected", + } + ) + else: + google_drive_state.update( + { + "enabled": False, + "status": "Connected (folder setup failed)", + } + ) + normalized["sync"]["google_drive"] = google_drive_state + normalized = save_settings(normalized) + settings_state.clear() + settings_state.update(normalized) + message = "Google Drive connected." + if folder_result.get("success"): + message = "Google Drive connected. Reports will sync to nmapui-reports." + else: + message = ( + "Google Drive connected, but the default folder could not be created. " + "Check Settings to finish setup." + ) + return f"

{message}

You can close this window and return to NmapUI.

" @app.route("/api/settings/google-drive/disconnect", methods=["POST"]) @require_auth diff --git a/nmapui/workflow_context.py b/nmapui/workflow_context.py index fe83084..4edb5a2 100644 --- a/nmapui/workflow_context.py +++ b/nmapui/workflow_context.py @@ -66,6 +66,7 @@ class ReportWorkflowContext: customer_fingerprinter: Any runtime_store: Any = None settings_state: Any = None + upload_report_artifacts_to_google_drive: Any = None web_stylesheet: Any = None pdf_stylesheet: Any = None on_job_end: Any = None @@ -181,6 +182,7 @@ def build_report_workflow_context(deps): customer_fingerprinter=deps["customer_fingerprinter"], runtime_store=deps.get("runtime_store"), settings_state=deps.get("settings_state"), + upload_report_artifacts_to_google_drive=deps.get("upload_report_artifacts_to_google_drive"), web_stylesheet=deps.get("web_stylesheet"), pdf_stylesheet=deps.get("pdf_stylesheet"), on_job_end=deps.get("on_job_end"), diff --git a/nmapui/workflows.py b/nmapui/workflows.py index 4e8e5ff..cf1e93f 100644 --- a/nmapui/workflows.py +++ b/nmapui/workflows.py @@ -325,6 +325,7 @@ def generate_report_task(context, sid, data): current_customer = context.current_customer extract_scan_statistics = context.extract_scan_statistics customer_fingerprinter = context.customer_fingerprinter + upload_report_artifacts_to_google_drive = context.upload_report_artifacts_to_google_drive on_job_end = context.on_job_end operation_id = f"report_generation:{sid}" idle_state_manager.start_operation(operation_id) @@ -579,6 +580,25 @@ def generate_report_task(context, sid, data): "nmap": scan_dir / "scan.nmap", "gnmap": scan_dir / "scan.gnmap", } + if upload_report_artifacts_to_google_drive and (context.settings_state or {}).get("sync", {}).get("google_drive", {}).get("enabled"): + try: + file_paths = [path for path in files.values() if path.exists()] + if file_paths: + upload_result = upload_report_artifacts_to_google_drive( + scan_path=str(scan_dir.relative_to(scans_dir)), + file_paths=file_paths, + metadata=current_metadata, + settings_state=context.settings_state, + ) + if upload_result.get("success"): + emit_to_client(sid, "scan_feedback", "☁️ Google Drive backup complete") + else: + emit_to_client(sid, "scan_feedback", f"⚠️ Google Drive backup failed: {upload_result.get('error', 'Unknown error')}") + socketio_sleep(0) + except Exception as exc: + logger.warning("Google Drive upload failed: %s", exc) + emit_to_client(sid, "scan_feedback", "⚠️ Google Drive backup failed") + socketio_sleep(0) end_time = datetime.now() duration = end_time - start_time diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 19c816c..e2f785e 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -343,12 +343,25 @@ async function connectGoogleDrive() { } window.open(payload.auth_url, '_blank', 'noopener,noreferrer'); setSyncStatus('settings-google-drive-status', 'Google Drive authorization opened in a new tab.'); + waitForGoogleDriveConnection(); } catch (error) { console.error('Error starting Google Drive auth:', error); setSyncStatus('settings-google-drive-status', error.message || 'Failed to start Google Drive auth.', true); } } +async function waitForGoogleDriveConnection(timeoutMs = 90000, intervalMs = 4000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + await loadGoogleDriveAuthStatus(); + if (googleDriveAuthStatus?.connected) { + await loadSettingsTab(true); + return; + } + } +} + async function disconnectGoogleDriveAccount() { setSyncStatus('settings-google-drive-status', 'Disconnecting Google Drive...'); try { From 79f95a6a03fd559161c35e6d981c20b70352bf82 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Sun, 15 Mar 2026 21:26:08 -0400 Subject: [PATCH 02/14] Guide Drive setup and allow credentials upload --- app.py | 5 +++++ nmapui/app_composition.py | 2 ++ nmapui/app_handler_registration.py | 2 ++ nmapui/google_drive.py | 8 ++++++++ nmapui/handlers/settings.py | 11 ++++++++++ static/js/settings_tab.js | 32 ++++++++++++++++++++++++++++++ templates/index.html | 12 +++++++++++ 7 files changed, 72 insertions(+) diff --git a/app.py b/app.py index 4862985..f94c926 100644 --- a/app.py +++ b/app.py @@ -77,6 +77,7 @@ disconnect_google_drive, exchange_google_drive_auth_code, ensure_google_drive_reports_folder, + save_google_drive_credentials, upload_files_to_google_drive, ) from nmapui.runtime import ( @@ -387,6 +388,10 @@ def safe_emit(event, data=None): key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, requests_module=requests, ), + save_google_drive_credentials=lambda credentials: save_google_drive_credentials( + GOOGLE_DRIVE_CREDENTIALS_FILE, + credentials, + ), disconnect_google_drive=lambda: disconnect_google_drive( token_path=GOOGLE_DRIVE_TOKEN_FILE, key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, diff --git a/nmapui/app_composition.py b/nmapui/app_composition.py index 13a5af0..0ac8374 100644 --- a/nmapui/app_composition.py +++ b/nmapui/app_composition.py @@ -376,6 +376,7 @@ def build_settings_routes_deps( build_google_drive_auth_url, exchange_google_drive_auth_code, ensure_google_drive_reports_folder, + save_google_drive_credentials, disconnect_google_drive, ): return { @@ -387,6 +388,7 @@ def build_settings_routes_deps( "build_google_drive_auth_url": build_google_drive_auth_url, "exchange_google_drive_auth_code": exchange_google_drive_auth_code, "ensure_google_drive_reports_folder": ensure_google_drive_reports_folder, + "save_google_drive_credentials": save_google_drive_credentials, "disconnect_google_drive": disconnect_google_drive, } diff --git a/nmapui/app_handler_registration.py b/nmapui/app_handler_registration.py index 86b0147..743c4ac 100644 --- a/nmapui/app_handler_registration.py +++ b/nmapui/app_handler_registration.py @@ -73,6 +73,7 @@ def register_app_handlers( build_google_drive_auth_url, exchange_google_drive_auth_code, ensure_google_drive_reports_folder, + save_google_drive_credentials, disconnect_google_drive, logger, ): @@ -188,6 +189,7 @@ def register_app_handlers( build_google_drive_auth_url=build_google_drive_auth_url, exchange_google_drive_auth_code=exchange_google_drive_auth_code, ensure_google_drive_reports_folder=ensure_google_drive_reports_folder, + save_google_drive_credentials=save_google_drive_credentials, disconnect_google_drive=disconnect_google_drive, ), ) diff --git a/nmapui/google_drive.py b/nmapui/google_drive.py index 1427f4a..2c0148b 100644 --- a/nmapui/google_drive.py +++ b/nmapui/google_drive.py @@ -84,6 +84,14 @@ def load_google_drive_credentials(credentials_path: Path) -> dict: return payload if isinstance(payload, dict) else {} +def save_google_drive_credentials(credentials_path: Path, payload: dict) -> dict: + if not isinstance(payload, dict): + return {"success": False, "error": "Invalid credentials payload"} + _save_json_file(credentials_path, payload) + _set_owner_only_permissions(credentials_path) + return {"success": True, "status": "Google Drive credentials saved"} + + def load_google_drive_token_state(token_path: Path, key_path: Path | None = None) -> dict: key_path = key_path or token_path.with_suffix(".key") try: diff --git a/nmapui/handlers/settings.py b/nmapui/handlers/settings.py index 9dc9ec2..b33dee7 100644 --- a/nmapui/handlers/settings.py +++ b/nmapui/handlers/settings.py @@ -13,6 +13,7 @@ def register_settings_routes(app, deps): build_google_drive_auth_url = deps["build_google_drive_auth_url"] exchange_google_drive_auth_code = deps["exchange_google_drive_auth_code"] ensure_google_drive_reports_folder = deps["ensure_google_drive_reports_folder"] + save_google_drive_credentials = deps["save_google_drive_credentials"] disconnect_google_drive = deps["disconnect_google_drive"] @app.route("/api/settings") @@ -70,6 +71,16 @@ def google_drive_auth_url_route(): status_code = 200 if result.get("success") else 400 return jsonify(result), status_code + @app.route("/api/settings/google-drive/credentials", methods=["POST"]) + @require_auth + def google_drive_credentials_route(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict) or "credentials" not in payload: + return jsonify({"success": False, "error": "Invalid credentials payload"}), 400 + result = save_google_drive_credentials(payload.get("credentials")) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + @app.route("/api/settings/google-drive/callback") def google_drive_callback_route(): code = request.args.get("code", "") diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index e2f785e..cf4689b 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -336,6 +336,9 @@ async function loadSettingsTab(force = false) { async function connectGoogleDrive() { setSyncStatus('settings-google-drive-status', 'Starting Google Drive authorization...'); try { + if (!googleDriveAuthStatus?.configured) { + throw new Error('Missing Google Drive OAuth credentials. Upload the JSON credentials file first.'); + } const response = await fetch('/api/settings/google-drive/auth-url'); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.auth_url) { @@ -377,6 +380,31 @@ async function disconnectGoogleDriveAccount() { } } +async function uploadGoogleDriveCredentials(file) { + if (!file) { + return; + } + setSyncStatus('settings-google-drive-status', 'Uploading Google Drive credentials...'); + try { + const text = await file.text(); + const credentials = JSON.parse(text); + const response = await fetch('/api/settings/google-drive/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentials }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success !== true) { + throw new Error(payload.error || `Failed to save credentials (${response.status})`); + } + setSyncStatus('settings-google-drive-status', payload.status || 'Credentials saved', false); + await loadGoogleDriveAuthStatus(); + } catch (error) { + console.error('Error uploading Google Drive credentials:', error); + setSyncStatus('settings-google-drive-status', error.message || 'Failed to upload credentials.', true); + } +} + async function saveSettingsTab() { const nextState = getSettingsFormState(); @@ -615,6 +643,10 @@ function initializeSettingsTab() { document.getElementById('settings-google-drive-test-btn')?.addEventListener('click', testGoogleDriveSettings); document.getElementById('settings-google-drive-connect-btn')?.addEventListener('click', connectGoogleDrive); document.getElementById('settings-google-drive-disconnect-btn')?.addEventListener('click', disconnectGoogleDriveAccount); + document.getElementById('settings-google-drive-credentials')?.addEventListener('change', (event) => { + uploadGoogleDriveCredentials(event.target.files?.[0]); + event.target.value = ''; + }); document.getElementById('settings-remote-sync-test-btn')?.addEventListener('click', testRemoteSyncSettings); document.getElementById('settings-runtime-backfill-btn')?.addEventListener('click', runRuntimeBackfill); document.getElementById('settings-runtime-retention-btn')?.addEventListener('click', runRuntimeRetention); diff --git a/templates/index.html b/templates/index.html index 8e67101..c7a0eb2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -797,6 +797,14 @@

Sync Configuration

@@ -804,6 +812,10 @@

Sync Configuration

+
From f25a7e69bf1de7a53438b92defdcbff5374b748b Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Sun, 15 Mar 2026 21:31:26 -0400 Subject: [PATCH 03/14] Use official Google Drive icon in settings --- static/google_drive_icon.svg | 8 ++++++++ templates/index.html | 7 +------ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 static/google_drive_icon.svg diff --git a/static/google_drive_icon.svg b/static/google_drive_icon.svg new file mode 100644 index 0000000..a8cefd5 --- /dev/null +++ b/static/google_drive_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index c7a0eb2..237d074 100644 --- a/templates/index.html +++ b/templates/index.html @@ -798,12 +798,7 @@

Sync Configuration