diff --git a/README.md b/README.md index a84bd2c..57a9553 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - ๐Ÿ” **Channel sharing** - Share channels via QR code or encrypted keys - ๐Ÿ”“ **Public channels** - Join public channels (starting with #) without encryption keys - ๐ŸŽฏ **Reply to users** - Quick reply with `@[UserName]` format +- ๐Ÿ‘ฅ **Contact management** - Manual contact approval mode with pending contacts list (persistent settings) - ๐Ÿงน **Clean contacts** - Remove inactive contacts with configurable threshold - ๐Ÿ“ฆ **Message archiving** - Automatic daily archiving with browse-by-date selector - โšก **Efficient polling** - Lightweight update checks every 10s, UI refreshes only when needed @@ -307,7 +308,64 @@ Access the Direct Messages feature: - Each conversation shows unread indicator (*) in the dropdown - DM badge in the menu shows total unread DM count -### Managing Contacts +### Contact Management + +Access the Contact Management feature to control who can connect to your node: + +**From the menu:** +1. Click the menu icon (โ˜ฐ) in the navbar +2. Select "Contact Management" from the menu +3. Opens the contact management page + +#### Manual Contact Approval + +By default, new contacts attempting to connect are automatically added to your contacts list. You can enable manual approval to control who can communicate with your node. + +**Enable manual approval:** +1. On the Contact Management page, toggle the "Manual Contact Approval" switch +2. When enabled, new contact requests will appear in the Pending Contacts list +3. This setting persists across container restarts + +**Security benefits:** +- **Control over network access** - Only approved contacts can communicate with your node +- **Prevention of spam/unwanted contacts** - Filter out random nodes attempting connection +- **Explicit trust model** - You decide who to trust on the mesh network + +#### Pending Contacts + +When manual approval is enabled, new contacts appear in the Pending Contacts list for review: + +**Approve a contact:** +1. View the contact name and truncated public key +2. Click "Copy Full Key" to copy the complete public key (useful for verification) +3. Click "Approve" to add the contact to your contacts list +4. The contact is moved from pending to regular contacts + +**Note:** Always use the full public key for approval (not name or prefix). This ensures compatibility with all contact types (CLI, ROOM, REP, SENS). + +**Refresh pending list:** +- Click the "Refresh" button to check for new pending contacts +- The page automatically loads pending contacts when first opened + +#### Debugging + +If you encounter issues with contact management: + +**Check logs:** +```bash +# mc-webui container logs +docker compose logs -f mc-webui + +# meshcore-bridge container logs (where settings are applied) +docker compose logs -f meshcore-bridge +``` + +**Look for:** +- "Loaded webui settings" - confirms settings file is being read +- "manual_add_contacts set to on/off" - confirms setting is applied to meshcli session +- "Saved manual_add_contacts=..." - confirms setting is persisted to file + +### Managing Contacts (Cleanup) Access the settings panel to clean up inactive contacts: 1. Click the settings icon diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 94d3292..61c4d02 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -4,7 +4,9 @@ MeshCore CLI wrapper - executes meshcli commands via HTTP bridge import logging import re +import json import requests +from pathlib import Path from typing import Tuple, Optional, List, Dict from app.config import config @@ -391,3 +393,159 @@ def send_dm(recipient: str, text: str) -> Tuple[bool, str]: success, stdout, stderr = _run_command(['msg', recipient.strip(), text.strip()]) return success, stdout or stderr + + +# ============================================================================= +# Contact Management (Pending Contacts) +# ============================================================================= + +def get_pending_contacts() -> Tuple[bool, List[Dict], str]: + """ + Get list of contacts awaiting manual approval. + + Returns: + Tuple of (success, pending_contacts_list, error_message) + Each contact dict: { + 'name': str, + 'public_key': str + } + """ + try: + response = requests.get( + f"{config.MC_BRIDGE_URL.replace('/cli', '/pending_contacts')}", + timeout=DEFAULT_TIMEOUT + 5 + ) + + if response.status_code != 200: + return False, [], f'Bridge HTTP error: {response.status_code}' + + data = response.json() + + if not data.get('success', False): + error = data.get('error', 'Failed to get pending contacts') + return False, [], error + + pending = data.get('pending', []) + return True, pending, "" + + except requests.exceptions.Timeout: + return False, [], 'Bridge timeout' + except requests.exceptions.ConnectionError: + return False, [], 'Cannot connect to meshcore-bridge service' + except Exception as e: + return False, [], str(e) + + +def approve_pending_contact(public_key: str) -> Tuple[bool, str]: + """ + Approve and add a pending contact by public key. + + Args: + public_key: Full public key of the contact to approve (REQUIRED - full key works for all contact types) + + Returns: + Tuple of (success, message) + """ + if not public_key or not public_key.strip(): + return False, "Public key is required" + + try: + response = requests.post( + f"{config.MC_BRIDGE_URL.replace('/cli', '/add_pending')}", + json={'selector': public_key.strip()}, + timeout=DEFAULT_TIMEOUT + 5 + ) + + if response.status_code != 200: + return False, f'Bridge HTTP error: {response.status_code}' + + data = response.json() + + if not data.get('success', False): + error = data.get('stderr', 'Failed to approve contact') + return False, error + + stdout = data.get('stdout', 'Contact approved successfully') + return True, stdout + + except requests.exceptions.Timeout: + return False, 'Bridge timeout' + except requests.exceptions.ConnectionError: + return False, 'Cannot connect to meshcore-bridge service' + except Exception as e: + return False, str(e) + + +# ============================================================================= +# Device Settings (Persistent Configuration) +# ============================================================================= + +def get_device_settings() -> Tuple[bool, Dict]: + """ + Get persistent device settings from .webui_settings.json. + + Returns: + Tuple of (success, settings_dict) + Settings dict currently contains: + { + 'manual_add_contacts': bool + } + """ + settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" + + try: + if not settings_path.exists(): + # Return defaults if file doesn't exist + return True, {'manual_add_contacts': False} + + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + # Ensure manual_add_contacts exists + if 'manual_add_contacts' not in settings: + settings['manual_add_contacts'] = False + return True, settings + + except Exception as e: + logger.error(f"Failed to read device settings: {e}") + return False, {'manual_add_contacts': False} + + +def set_manual_add_contacts(enabled: bool) -> Tuple[bool, str]: + """ + Enable or disable manual contact approval mode. + + This setting is: + 1. Saved to .webui_settings.json for persistence across container restarts + 2. Applied immediately to the running meshcli session via bridge + + Args: + enabled: True to enable manual approval, False for automatic + + Returns: + Tuple of (success, message) + """ + try: + response = requests.post( + f"{config.MC_BRIDGE_URL.replace('/cli', '/set_manual_add_contacts')}", + json={'enabled': enabled}, + timeout=DEFAULT_TIMEOUT + 5 + ) + + if response.status_code != 200: + return False, f'Bridge HTTP error: {response.status_code}' + + data = response.json() + + if not data.get('success', False): + error = data.get('error', 'Failed to set manual_add_contacts') + return False, error + + message = data.get('message', f"manual_add_contacts set to {'on' if enabled else 'off'}") + return True, message + + except requests.exceptions.Timeout: + return False, 'Bridge timeout' + except requests.exceptions.ConnectionError: + return False, 'Cannot connect to meshcore-bridge service' + except Exception as e: + return False, str(e) diff --git a/app/routes/api.py b/app/routes/api.py index 7991775..cb7c17d 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1198,3 +1198,207 @@ def get_dm_updates(): 'success': False, 'error': str(e) }), 500 + + +# ============================================================================= +# Contact Management (Pending Contacts & Settings) +# ============================================================================= + +@api_bp.route('/contacts/pending', methods=['GET']) +def get_pending_contacts_api(): + """ + Get list of contacts awaiting manual approval. + + Returns: + JSON with pending contacts list: + { + "success": true, + "pending": [ + { + "name": "Skyllancer", + "public_key": "f9ef123abc..." + }, + ... + ], + "count": 2 + } + """ + try: + success, pending, error = cli.get_pending_contacts() + + if success: + return jsonify({ + 'success': True, + 'pending': pending, + 'count': len(pending) + }), 200 + else: + return jsonify({ + 'success': False, + 'error': error or 'Failed to get pending contacts', + 'pending': [] + }), 500 + + except Exception as e: + logger.error(f"Error getting pending contacts: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'pending': [] + }), 500 + + +@api_bp.route('/contacts/pending/approve', methods=['POST']) +def approve_pending_contact_api(): + """ + Approve and add a pending contact. + + JSON body: + { + "public_key": "" + } + + IMPORTANT: Always send the full public_key (not name or prefix). + Full public key works for all contact types (CLI, ROOM, REP, SENS). + + Returns: + JSON with approval result: + { + "success": true, + "message": "Contact approved successfully" + } + """ + try: + data = request.get_json() + + if not data or 'public_key' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required field: public_key' + }), 400 + + public_key = data['public_key'] + + if not isinstance(public_key, str) or not public_key.strip(): + return jsonify({ + 'success': False, + 'error': 'public_key must be a non-empty string' + }), 400 + + success, message = cli.approve_pending_contact(public_key) + + if success: + return jsonify({ + 'success': True, + 'message': message or 'Contact approved successfully' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error approving pending contact: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@api_bp.route('/device/settings', methods=['GET']) +def get_device_settings_api(): + """ + Get persistent device settings. + + Returns: + JSON with settings: + { + "success": true, + "settings": { + "manual_add_contacts": false + } + } + """ + try: + success, settings = cli.get_device_settings() + + if success: + return jsonify({ + 'success': True, + 'settings': settings + }), 200 + else: + return jsonify({ + 'success': False, + 'error': 'Failed to get device settings', + 'settings': {'manual_add_contacts': False} + }), 500 + + except Exception as e: + logger.error(f"Error getting device settings: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'settings': {'manual_add_contacts': False} + }), 500 + + +@api_bp.route('/device/settings', methods=['POST']) +def update_device_settings_api(): + """ + Update persistent device settings. + + JSON body: + { + "manual_add_contacts": true/false + } + + This setting is: + 1. Saved to .webui_settings.json for persistence across container restarts + 2. Applied immediately to the running meshcli session + + Returns: + JSON with update result: + { + "success": true, + "message": "manual_add_contacts set to on" + } + """ + try: + data = request.get_json() + + if not data or 'manual_add_contacts' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required field: manual_add_contacts' + }), 400 + + manual_add_contacts = data['manual_add_contacts'] + + if not isinstance(manual_add_contacts, bool): + return jsonify({ + 'success': False, + 'error': 'manual_add_contacts must be a boolean' + }), 400 + + success, message = cli.set_manual_add_contacts(manual_add_contacts) + + if success: + return jsonify({ + 'success': True, + 'message': message, + 'settings': {'manual_add_contacts': manual_add_contacts} + }), 200 + else: + return jsonify({ + 'success': False, + 'error': message + }), 500 + + except Exception as e: + logger.error(f"Error updating device settings: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/app/routes/views.py b/app/routes/views.py index d0e1da4..e72373e 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -41,6 +41,18 @@ def direct_messages(): ) +@views_bp.route('/contacts/manage') +def contact_management(): + """ + Contact Management view - manual approval settings and pending contacts list. + """ + return render_template( + 'contacts.html', + device_name=config.MC_DEVICE_NAME, + refresh_interval=config.MC_REFRESH_INTERVAL + ) + + @views_bp.route('/health') def health(): """ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js new file mode 100644 index 0000000..05227ee --- /dev/null +++ b/app/static/js/contacts.js @@ -0,0 +1,356 @@ +/** + * Contact Management UI + * + * Features: + * - Manual contact approval toggle (persistent across restarts) + * - Pending contacts list with approve/copy actions + * - Auto-refresh on page load + * - Mobile-first design + */ + +// ============================================================================= +// State Management +// ============================================================================= + +let manualApprovalEnabled = false; +let pendingContacts = []; + +// ============================================================================= +// Initialization +// ============================================================================= + +document.addEventListener('DOMContentLoaded', () => { + console.log('Contact Management UI initialized'); + + // Attach event listeners + attachEventListeners(); + + // Load initial state + loadSettings(); + loadPendingContacts(); +}); + +function attachEventListeners() { + // Manual approval toggle + const approvalSwitch = document.getElementById('manualApprovalSwitch'); + if (approvalSwitch) { + approvalSwitch.addEventListener('change', handleApprovalToggle); + } + + // Refresh button + const refreshBtn = document.getElementById('refreshPendingBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + loadPendingContacts(); + }); + } +} + +// ============================================================================= +// Settings Management +// ============================================================================= + +async function loadSettings() { + try { + const response = await fetch('/api/device/settings'); + const data = await response.json(); + + if (data.success) { + manualApprovalEnabled = data.settings.manual_add_contacts || false; + updateApprovalUI(manualApprovalEnabled); + } else { + console.error('Failed to load settings:', data.error); + showToast('Failed to load settings', 'danger'); + } + } catch (error) { + console.error('Error loading settings:', error); + showToast('Network error loading settings', 'danger'); + } +} + +async function handleApprovalToggle(event) { + const enabled = event.target.checked; + + try { + const response = await fetch('/api/device/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + manual_add_contacts: enabled + }) + }); + + const data = await response.json(); + + if (data.success) { + manualApprovalEnabled = enabled; + updateApprovalUI(enabled); + showToast( + enabled ? 'Manual approval enabled' : 'Manual approval disabled', + 'success' + ); + + // Reload pending contacts after toggle + setTimeout(() => loadPendingContacts(), 500); + } else { + console.error('Failed to update setting:', data.error); + showToast('Failed to update setting: ' + data.error, 'danger'); + + // Revert toggle on failure + event.target.checked = !enabled; + } + } catch (error) { + console.error('Error updating setting:', error); + showToast('Network error updating setting', 'danger'); + + // Revert toggle on failure + event.target.checked = !enabled; + } +} + +function updateApprovalUI(enabled) { + const switchEl = document.getElementById('manualApprovalSwitch'); + const labelEl = document.getElementById('switchLabel'); + const infoEl = document.getElementById('approvalInfo'); + + if (switchEl) { + switchEl.checked = enabled; + } + + if (labelEl) { + labelEl.textContent = enabled + ? 'Manual approval enabled' + : 'Automatic approval (default)'; + } + + if (infoEl) { + infoEl.style.display = enabled ? 'none' : 'inline-block'; + } +} + +// ============================================================================= +// Pending Contacts Management +// ============================================================================= + +async function loadPendingContacts() { + const loadingEl = document.getElementById('pendingLoading'); + const emptyEl = document.getElementById('pendingEmpty'); + const listEl = document.getElementById('pendingList'); + const errorEl = document.getElementById('pendingError'); + const countBadge = document.getElementById('pendingCount'); + + // Show loading state + if (loadingEl) loadingEl.style.display = 'block'; + if (emptyEl) emptyEl.style.display = 'none'; + if (listEl) listEl.innerHTML = ''; + if (errorEl) errorEl.style.display = 'none'; + if (countBadge) countBadge.style.display = 'none'; + + try { + const response = await fetch('/api/contacts/pending'); + const data = await response.json(); + + if (loadingEl) loadingEl.style.display = 'none'; + + if (data.success) { + pendingContacts = data.pending || []; + + if (pendingContacts.length === 0) { + // Show empty state + if (emptyEl) emptyEl.style.display = 'block'; + } else { + // Render pending contacts list + renderPendingList(pendingContacts); + + // Update count badge + if (countBadge) { + countBadge.textContent = pendingContacts.length; + countBadge.style.display = 'inline-block'; + } + } + } else { + console.error('Failed to load pending contacts:', data.error); + if (errorEl) { + const errorMsg = document.getElementById('errorMessage'); + if (errorMsg) errorMsg.textContent = data.error || 'Failed to load pending contacts'; + errorEl.style.display = 'block'; + } + } + } catch (error) { + console.error('Error loading pending contacts:', error); + if (loadingEl) loadingEl.style.display = 'none'; + if (errorEl) { + const errorMsg = document.getElementById('errorMessage'); + if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message; + errorEl.style.display = 'block'; + } + } +} + +function renderPendingList(contacts) { + const listEl = document.getElementById('pendingList'); + if (!listEl) return; + + listEl.innerHTML = ''; + + contacts.forEach((contact, index) => { + const card = createContactCard(contact, index); + listEl.appendChild(card); + }); +} + +function createContactCard(contact, index) { + const card = document.createElement('div'); + card.className = 'pending-contact-card'; + card.id = `contact-${index}`; + + // Contact name + const nameDiv = document.createElement('div'); + nameDiv.className = 'contact-name'; + nameDiv.textContent = contact.name; + + // Public key (truncated) + const keyDiv = document.createElement('div'); + keyDiv.className = 'contact-key'; + const truncatedKey = contact.public_key.substring(0, 16) + '...'; + keyDiv.textContent = truncatedKey; + keyDiv.title = contact.public_key; // Full key on hover + + // Action buttons + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'd-flex gap-2 flex-wrap'; + + // Approve button + const approveBtn = document.createElement('button'); + approveBtn.className = 'btn btn-success btn-action flex-grow-1'; + approveBtn.innerHTML = ' Approve'; + approveBtn.onclick = () => approveContact(contact, index); + + // Copy key button + const copyBtn = document.createElement('button'); + copyBtn.className = 'btn btn-outline-secondary btn-action'; + copyBtn.innerHTML = ' Copy Full Key'; + copyBtn.onclick = () => copyPublicKey(contact.public_key, copyBtn); + + actionsDiv.appendChild(approveBtn); + actionsDiv.appendChild(copyBtn); + + card.appendChild(nameDiv); + card.appendChild(keyDiv); + card.appendChild(actionsDiv); + + return card; +} + +async function approveContact(contact, index) { + const cardEl = document.getElementById(`contact-${index}`); + + // Disable buttons during approval + if (cardEl) { + const buttons = cardEl.querySelectorAll('button'); + buttons.forEach(btn => btn.disabled = true); + } + + try { + const response = await fetch('/api/contacts/pending/approve', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + public_key: contact.public_key // ALWAYS use full public_key (works for CLI, ROOM, etc.) + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Approved: ${contact.name}`, 'success'); + + // Remove from list with animation + if (cardEl) { + cardEl.style.opacity = '0'; + cardEl.style.transition = 'opacity 0.3s'; + setTimeout(() => { + cardEl.remove(); + + // Reload pending list to update count + loadPendingContacts(); + }, 300); + } + } else { + console.error('Failed to approve contact:', data.error); + showToast('Failed to approve: ' + data.error, 'danger'); + + // Re-enable buttons + if (cardEl) { + const buttons = cardEl.querySelectorAll('button'); + buttons.forEach(btn => btn.disabled = false); + } + } + } catch (error) { + console.error('Error approving contact:', error); + showToast('Network error: ' + error.message, 'danger'); + + // Re-enable buttons + if (cardEl) { + const buttons = cardEl.querySelectorAll('button'); + buttons.forEach(btn => btn.disabled = false); + } + } +} + +function copyPublicKey(publicKey, buttonEl) { + navigator.clipboard.writeText(publicKey).then(() => { + // Visual feedback + const originalHTML = buttonEl.innerHTML; + buttonEl.innerHTML = ' Copied!'; + buttonEl.classList.remove('btn-outline-secondary'); + buttonEl.classList.add('btn-success'); + + setTimeout(() => { + buttonEl.innerHTML = originalHTML; + buttonEl.classList.remove('btn-success'); + buttonEl.classList.add('btn-outline-secondary'); + }, 2000); + + showToast('Public key copied to clipboard', 'info'); + }).catch(err => { + console.error('Failed to copy:', err); + showToast('Failed to copy to clipboard', 'danger'); + }); +} + +// ============================================================================= +// Toast Notifications +// ============================================================================= + +function showToast(message, type = 'info') { + const toastEl = document.getElementById('contactToast'); + if (!toastEl) return; + + const bodyEl = toastEl.querySelector('.toast-body'); + if (!bodyEl) return; + + // Set message and style + bodyEl.textContent = message; + + // Apply color based on type + toastEl.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-warning'); + toastEl.classList.remove('text-white'); + + if (type === 'success' || type === 'danger' || type === 'warning') { + toastEl.classList.add(`bg-${type}`, 'text-white'); + } else if (type === 'info') { + toastEl.classList.add('bg-info', 'text-white'); + } + + // Show toast + const toast = new bootstrap.Toast(toastEl, { + autohide: true, + delay: 3000 + }); + toast.show(); +} diff --git a/app/templates/base.html b/app/templates/base.html index b827d03..a2af338 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -70,6 +70,10 @@ +
diff --git a/app/templates/contacts.html b/app/templates/contacts.html new file mode 100644 index 0000000..5e0f9c4 --- /dev/null +++ b/app/templates/contacts.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% block title %}Contact Management - mc-webui{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+

+ Contact Management +

+ +
+ + +
+
+ Manual Contact Approval +
+

+ When enabled, new contacts must be manually approved before they can communicate with your node. +

+ +
+ + +
+ + +
+ + +
+
+
+ Pending Contacts + +
+ +
+ + + + + + + + +
+ + + +
+
+
+
+ + +
+ +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py index f5f177c..1a55c9c 100644 --- a/meshcore-bridge/bridge.py +++ b/meshcore-bridge/bridge.py @@ -119,19 +119,51 @@ class MeshCLISession: logger.error(f"Failed to start meshcli session: {e}") raise + def _load_webui_settings(self): + """ + Load webui settings from .webui_settings.json file. + + Returns: + dict: Settings dictionary or empty dict if file doesn't exist + """ + settings_path = self.config_dir / ".webui_settings.json" + + if not settings_path.exists(): + logger.info("No webui settings file found, using defaults") + return {} + + try: + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + logger.info(f"Loaded webui settings: {settings}") + return settings + except Exception as e: + logger.error(f"Failed to load webui settings: {e}") + return {} + def _init_session_settings(self): - """Configure meshcli session for advert logging, message subscription, and manual contact approval""" + """Configure meshcli session for advert logging, message subscription, and user-configured settings""" logger.info("Configuring meshcli session settings") # Send configuration commands directly to stdin (bypass queue for init) if self.process and self.process.stdin: try: + # Core settings (always enabled) self.process.stdin.write('set json_log_rx on\n') self.process.stdin.write('set print_adverts on\n') - self.process.stdin.write('set manual_add_contacts on\n') self.process.stdin.write('msgs_subscribe\n') + + # User-configurable settings from .webui_settings.json + webui_settings = self._load_webui_settings() + manual_add_contacts = webui_settings.get('manual_add_contacts', False) + + if manual_add_contacts: + self.process.stdin.write('set manual_add_contacts on\n') + logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=on, msgs_subscribe") + else: + logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=off (default), msgs_subscribe") + self.process.stdin.flush() - logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=on, msgs_subscribe") except Exception as e: logger.error(f"Failed to apply session settings: {e}") @@ -645,6 +677,101 @@ def add_pending_contact(): }), 500 +@app.route('/set_manual_add_contacts', methods=['POST']) +def set_manual_add_contacts(): + """ + Enable or disable manual contact approval mode. + + This setting is: + 1. Saved to .webui_settings.json for persistence across container restarts + 2. Applied immediately to the running meshcli session + + Request JSON: + { + "enabled": true/false + } + + Response JSON: + { + "success": true, + "message": "manual_add_contacts set to on" + } + """ + try: + data = request.get_json() + + if not data or 'enabled' not in data: + return jsonify({ + 'success': False, + 'error': 'Missing required field: enabled' + }), 400 + + enabled = data['enabled'] + + if not isinstance(enabled, bool): + return jsonify({ + 'success': False, + 'error': 'enabled must be a boolean' + }), 400 + + # Save to persistent settings file + settings_path = meshcli_session.config_dir / ".webui_settings.json" + + try: + # Read existing settings or create new + if settings_path.exists(): + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + else: + settings = {} + + # Update manual_add_contacts setting + settings['manual_add_contacts'] = enabled + + # Write back to file + with open(settings_path, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + logger.info(f"Saved manual_add_contacts={enabled} to {settings_path}") + + except Exception as e: + logger.error(f"Failed to save settings file: {e}") + return jsonify({ + 'success': False, + 'error': f'Failed to save settings: {str(e)}' + }), 500 + + # Apply setting immediately to running session + if not meshcli_session or not meshcli_session.process: + return jsonify({ + 'success': False, + 'error': 'meshcli session not initialized' + }), 503 + + # Execute set manual_add_contacts on|off command + command_value = 'on' if enabled else 'off' + result = meshcli_session.execute_command(['set', 'manual_add_contacts', command_value], timeout=DEFAULT_TIMEOUT) + + if not result['success']: + return jsonify({ + 'success': False, + 'error': f"Failed to apply setting: {result.get('stderr', 'Unknown error')}" + }), 500 + + return jsonify({ + 'success': True, + 'message': f"manual_add_contacts set to {command_value}", + 'enabled': enabled + }), 200 + + except Exception as e: + logger.error(f"API error in /set_manual_add_contacts: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + if __name__ == '__main__': logger.info(f"Starting MeshCore Bridge on port 5001") logger.info(f"Serial port: {MC_SERIAL_PORT}")