diff --git a/app/routes/api.py b/app/routes/api.py index f6f9f86..5de1676 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -115,6 +115,68 @@ def invalidate_contacts_cache(): logger.debug("Contacts cache invalidated") +# ============================================================================= +# Protected Contacts Management +# ============================================================================= + +def get_protected_contacts() -> list: + """ + Get list of protected contact public keys from settings. + + Returns: + List of public_key strings (64 hex chars, lowercase) + """ + from pathlib import Path + settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" + + try: + if not settings_path.exists(): + return [] + + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + # Return lowercase keys for consistent comparison + return [pk.lower() for pk in settings.get('protected_contacts', [])] + except Exception as e: + logger.error(f"Failed to read protected contacts: {e}") + return [] + + +def save_protected_contacts(protected_list: list) -> bool: + """ + Save protected contacts list to settings file (atomic write). + + Args: + protected_list: List of public_key strings + + Returns: + True if successful, False otherwise + """ + from pathlib import Path + settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json" + + try: + # Read existing settings + settings = {} + if settings_path.exists(): + with open(settings_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + + # Update protected contacts (store lowercase) + settings['protected_contacts'] = [pk.lower() for pk in protected_list] + + # Write back atomically + temp_file = settings_path.with_suffix('.tmp') + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + temp_file.replace(settings_path) + + return True + except Exception as e: + logger.error(f"Failed to save protected contacts: {e}") + return False + + @api_bp.route('/messages', methods=['GET']) def get_messages(): """ @@ -319,7 +381,7 @@ def _filter_contacts_by_criteria(contacts: list, criteria: dict) -> list: - days (int): Days of inactivity (0 = ignore) Returns: - List of contacts matching criteria + List of contacts matching criteria (excludes protected contacts) """ name_filter = criteria.get('name_filter', '').strip().lower() selected_types = criteria.get('types', [1, 2, 3, 4]) @@ -330,8 +392,15 @@ def _filter_contacts_by_criteria(contacts: list, criteria: dict) -> list: current_time = int(time.time()) days_threshold = days * 86400 # Convert days to seconds + # Get protected contacts list (exclude from cleanup) + protected_contacts = get_protected_contacts() + filtered = [] for contact in contacts: + # Skip protected contacts + if contact.get('public_key', '').lower() in protected_contacts: + continue + # Filter by type if contact.get('type') not in selected_types: continue @@ -1586,6 +1655,9 @@ def get_contacts_detailed_api(): type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'} contacts = [] + # Get protected contacts for is_protected field + protected_contacts = get_protected_contacts() + for public_key, details in contacts_detailed.items(): # Compute path display string out_path_len = details.get('out_path_len', -1) @@ -1614,6 +1686,7 @@ def get_contacts_detailed_api(): 'type_label': type_labels.get(details.get('type'), 'UNKNOWN'), 'path_or_mode': path_or_mode, # For UI display 'last_seen': details.get('last_advert'), # Alias for compatibility + 'is_protected': public_key.lower() in protected_contacts, # Protection status } contacts.append(contact) @@ -1694,6 +1767,134 @@ def delete_contact_api(): }), 500 +@api_bp.route('/contacts/protected', methods=['GET']) +def get_protected_contacts_api(): + """ + Get list of all protected contact public keys. + + Returns: + JSON with protected contacts: + { + "success": true, + "protected_contacts": ["public_key1", "public_key2", ...], + "count": 2 + } + """ + try: + protected = get_protected_contacts() + return jsonify({ + 'success': True, + 'protected_contacts': protected, + 'count': len(protected) + }), 200 + except Exception as e: + logger.error(f"Error getting protected contacts: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'protected_contacts': [], + 'count': 0 + }), 500 + + +@api_bp.route('/contacts//protect', methods=['POST']) +def toggle_contact_protection(public_key): + """ + Toggle protection status for a contact. + + Args: + public_key: Full public key (64 hex chars) or prefix (12+ chars) + + JSON body (optional): + { + "protected": true/false # If not provided, toggles current state + } + + Returns: + JSON with result: + { + "success": true, + "public_key": "full_public_key", + "protected": true, + "message": "Contact protected" + } + """ + try: + # Validate public_key format (at least 12 hex chars) + if not public_key or not re.match(r'^[a-fA-F0-9]{12,64}$', public_key): + return jsonify({ + 'success': False, + 'error': 'Invalid public_key format (must be 12-64 hex characters)' + }), 400 + + public_key = public_key.lower() + + # Get current protected list + protected_contacts = get_protected_contacts() + + # Find matching full public_key if prefix provided + if len(public_key) < 64: + # Fetch contacts to resolve prefix to full key + success, contacts_dict, error = cli.get_contacts_with_last_seen() + if not success: + return jsonify({ + 'success': False, + 'error': error or 'Failed to get contacts' + }), 500 + + # Find matching contact + full_key = None + for pk in contacts_dict.keys(): + if pk.lower().startswith(public_key): + full_key = pk.lower() + break + + if not full_key: + return jsonify({ + 'success': False, + 'error': f'Contact not found with public_key prefix: {public_key}' + }), 404 + + public_key = full_key + + # Check if explicit protected value provided + data = request.get_json() or {} + if 'protected' in data: + should_protect = data['protected'] + else: + # Toggle current state + should_protect = public_key not in protected_contacts + + # Update protected list + if should_protect: + if public_key not in protected_contacts: + protected_contacts.append(public_key) + else: + if public_key in protected_contacts: + protected_contacts.remove(public_key) + + # Save updated list + if not save_protected_contacts(protected_contacts): + return jsonify({ + 'success': False, + 'error': 'Failed to save protected contacts' + }), 500 + + return jsonify({ + 'success': True, + 'public_key': public_key, + 'protected': should_protect, + 'message': 'Contact protected' if should_protect else 'Contact unprotected' + }), 200 + + except Exception as e: + logger.error(f"Error toggling contact protection: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + # ============================================================================= # Contact Management (Pending Contacts & Settings) # ============================================================================= diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index c38f188..03e96e2 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -45,6 +45,7 @@ let filteredPendingContacts = []; // Filtered pending contacts (for pending page let existingContacts = []; let filteredContacts = []; let contactToDelete = null; +let protectedContacts = []; // List of protected public_keys // Sort state (for existing page) let sortBy = 'last_advert'; // 'name' or 'last_advert' @@ -515,8 +516,10 @@ function initExistingPage() { // Parse sort parameters from URL parseSortParamsFromURL(); - // Load existing contacts - loadExistingContacts(); + // Load protected contacts first, then load existing contacts + loadProtectedContacts().then(() => { + loadExistingContacts(); + }); // Attach event listeners for existing page attachExistingEventListeners(); @@ -647,6 +650,132 @@ function updateApprovalUI(enabled) { } } +// ============================================================================= +// Protected Contacts Management +// ============================================================================= + +/** + * Load protected contacts list from server. + * Called on page load to populate local state. + */ +async function loadProtectedContacts() { + try { + const response = await fetch('/api/contacts/protected'); + const data = await response.json(); + + if (data.success) { + protectedContacts = data.protected_contacts || []; + console.log('Loaded protected contacts:', protectedContacts.length); + } else { + console.error('Failed to load protected contacts:', data.error); + } + } catch (error) { + console.error('Error loading protected contacts:', error); + } +} + +/** + * Toggle protection status for a contact. + * @param {string} publicKey - Full public key of contact + * @param {HTMLElement} buttonEl - Button element for visual feedback + */ +async function toggleContactProtection(publicKey, buttonEl) { + const originalHTML = buttonEl.innerHTML; + buttonEl.disabled = true; + buttonEl.innerHTML = ''; + + try { + const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/protect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }); + + const data = await response.json(); + + if (data.success) { + // Update local state + const pkLower = publicKey.toLowerCase(); + if (data.protected) { + if (!protectedContacts.includes(pkLower)) { + protectedContacts.push(pkLower); + } + } else { + protectedContacts = protectedContacts.filter(pk => pk !== pkLower); + } + + // Update UI + updateProtectionUI(publicKey, data.protected, buttonEl); + + showToast(data.message, 'success'); + } else { + showToast('Failed to update protection: ' + data.error, 'danger'); + buttonEl.innerHTML = originalHTML; + buttonEl.disabled = false; + } + } catch (error) { + console.error('Error toggling protection:', error); + showToast('Network error', 'danger'); + buttonEl.innerHTML = originalHTML; + buttonEl.disabled = false; + } +} + +/** + * Update UI elements based on protection status. + * @param {string} publicKey - Public key of contact + * @param {boolean} isProtected - New protection status + * @param {HTMLElement} buttonEl - Protect button element + */ +function updateProtectionUI(publicKey, isProtected, buttonEl) { + const cardEl = buttonEl.closest('.existing-contact-card'); + + // Update button appearance + buttonEl.disabled = false; + if (isProtected) { + buttonEl.innerHTML = ' Protected'; + buttonEl.classList.remove('btn-outline-warning'); + buttonEl.classList.add('btn-warning'); + } else { + buttonEl.innerHTML = ' Protect'; + buttonEl.classList.remove('btn-warning'); + buttonEl.classList.add('btn-outline-warning'); + } + + // Update card lock indicator + const nameDiv = cardEl.querySelector('.contact-name'); + let lockIcon = cardEl.querySelector('.protection-indicator'); + + if (isProtected) { + if (!lockIcon && nameDiv) { + const indicator = document.createElement('span'); + indicator.className = 'protection-indicator ms-2'; + indicator.innerHTML = ''; + nameDiv.appendChild(indicator); + } + } else { + if (lockIcon) lockIcon.remove(); + } + + // Enable/disable delete button + const deleteBtn = cardEl.querySelector('.btn-outline-danger'); + if (deleteBtn) { + deleteBtn.disabled = isProtected; + deleteBtn.title = isProtected ? 'Cannot delete protected contact' : ''; + } +} + +/** + * Check if a contact is protected. + * @param {string} publicKey - Public key to check + * @returns {boolean} True if protected + */ +function isContactProtected(publicKey) { + return protectedContacts.includes(publicKey.toLowerCase()); +} + // ============================================================================= // Pending Type Filter (localStorage persistence) // ============================================================================= @@ -1477,6 +1606,9 @@ function createExistingContactCard(contact, index) { card.className = 'existing-contact-card'; card.id = `existing-contact-${index}`; + // Check if contact is protected + const isProtected = contact.is_protected || isContactProtected(contact.public_key); + // Contact info row (name + type badge) const infoRow = document.createElement('div'); infoRow.className = 'contact-info-row'; @@ -1485,6 +1617,14 @@ function createExistingContactCard(contact, index) { nameDiv.className = 'contact-name flex-grow-1'; nameDiv.textContent = contact.name; + // Add protection indicator if protected + if (isProtected) { + const lockIndicator = document.createElement('span'); + lockIndicator.className = 'protection-indicator ms-2'; + lockIndicator.innerHTML = ''; + nameDiv.appendChild(lockIndicator); + } + const typeBadge = document.createElement('span'); typeBadge.className = 'badge type-badge'; typeBadge.textContent = contact.type_label; @@ -1577,11 +1717,24 @@ function createExistingContactCard(contact, index) { actionsDiv.appendChild(mapBtn); } - // Delete button + // Protect button + const protectBtn = document.createElement('button'); + protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning'; + protectBtn.innerHTML = isProtected + ? ' Protected' + : ' Protect'; + protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn); + actionsDiv.appendChild(protectBtn); + + // Delete button (disabled if protected) const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-sm btn-outline-danger'; deleteBtn.innerHTML = ' Delete'; deleteBtn.onclick = () => showDeleteModal(contact); + deleteBtn.disabled = isProtected; + if (isProtected) { + deleteBtn.title = 'Cannot delete protected contact'; + } actionsDiv.appendChild(deleteBtn); diff --git a/app/templates/contacts_base.html b/app/templates/contacts_base.html index 4768687..e050d75 100644 --- a/app/templates/contacts_base.html +++ b/app/templates/contacts_base.html @@ -125,6 +125,16 @@ font-weight: 600; } + /* Protected contact styling */ + .protection-indicator { + font-size: 0.85rem; + } + + .btn-outline-danger:disabled { + opacity: 0.5; + cursor: not-allowed; + } + /* Clickable type filter badges */ .type-filter-badge { display: inline-block;