diff --git a/app/database.py b/app/database.py index 4f876c7..33e974e 100644 --- a/app/database.py +++ b/app/database.py @@ -163,6 +163,15 @@ class Database: ) return cursor.rowcount > 0 + def hard_delete_contact(self, public_key: str) -> bool: + """Permanently delete a contact from the database.""" + with self._connect() as conn: + cursor = conn.execute( + "DELETE FROM contacts WHERE public_key = ?", + (public_key.lower(),) + ) + return cursor.rowcount > 0 + def downgrade_stale_device_contacts(self, active_device_keys: set) -> int: """Downgrade contacts marked 'device' that are no longer on the device.""" with self._connect() as conn: diff --git a/app/device_manager.py b/app/device_manager.py index da035df..2097a9a 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -629,6 +629,34 @@ class DeviceManager: source='advert', ) + # If manual mode: add cache-only contacts to pending list + # (meshcore may fire ADVERTISEMENT instead of NEW_CONTACT for + # contacts already in mc.pending_contacts or after restart) + if (self._is_manual_approval_enabled() + and pubkey not in (self.mc.contacts or {}) + and pubkey not in (self.mc.pending_contacts or {}) + and not self.db.is_contact_ignored(pubkey) + and not self.db.is_contact_blocked(pubkey)): + # Add to pending_contacts so it shows in pending list + if self.mc.pending_contacts is None: + self.mc.pending_contacts = {} + self.mc.pending_contacts[pubkey] = { + 'public_key': pubkey, + 'adv_name': name, + 'name': name, + 'type': adv_type, + 'adv_lat': adv_lat, + 'adv_lon': adv_lon, + 'last_advert': int(time.time()), + } + logger.info(f"Cache contact added to pending (advert): {name} ({pubkey[:8]}...)") + if self.socketio: + self.socketio.emit('pending_contact', { + 'public_key': pubkey, + 'name': name, + 'type': adv_type, + }, namespace='/chat') + logger.info(f"Advert from '{name}' ({pubkey[:8]}...) type={adv_type}") except Exception as e: @@ -1164,7 +1192,7 @@ class DeviceManager: try: self.execute(self.mc.commands.remove_contact(pubkey)) - self.db.delete_contact(pubkey) # soft-delete: sets source='deleted' + self.db.delete_contact(pubkey) # soft-delete: sets source='advert' # Also remove from in-memory contacts cache if self.mc.contacts and pubkey in self.mc.contacts: del self.mc.contacts[pubkey] @@ -1173,6 +1201,20 @@ class DeviceManager: logger.error(f"Failed to delete contact: {e}") return {'success': False, 'error': str(e)} + def delete_cached_contact(self, pubkey: str) -> Dict: + """Hard-delete a cache-only contact from the database.""" + try: + # Don't delete if contact is on device + if self.mc and self.mc.contacts and pubkey in self.mc.contacts: + return {'success': False, 'error': 'Contact is on device, use delete_contact instead'} + deleted = self.db.hard_delete_contact(pubkey) + if deleted: + return {'success': True, 'message': 'Cache contact deleted'} + return {'success': False, 'error': 'Contact not found in cache'} + except Exception as e: + logger.error(f"Failed to delete cached contact: {e}") + return {'success': False, 'error': str(e)} + def reset_path(self, pubkey: str) -> Dict: """Reset path to a contact.""" if not self.is_connected: @@ -1322,6 +1364,19 @@ class DeviceManager: try: contact = (self.mc.pending_contacts or {}).get(pubkey) + # Also check DB cache for contacts not in meshcore's pending list + if not contact: + db_contact = self.db.get_contact(pubkey) + if db_contact and db_contact.get('source') == 'advert': + contact = { + 'public_key': pubkey, + 'name': db_contact.get('name', ''), + 'adv_name': db_contact.get('name', ''), + 'type': db_contact.get('type', 0), + 'adv_lat': db_contact.get('adv_lat'), + 'adv_lon': db_contact.get('adv_lon'), + 'last_advert': db_contact.get('last_advert'), + } if not contact: return {'success': False, 'error': 'Contact not in pending list'} @@ -1373,6 +1428,11 @@ class DeviceManager: removed = self.mc.pending_contacts.pop(pubkey, None) if removed: return {'success': True, 'message': 'Contact rejected'} + # Also check DB cache - remove cache-only contacts on reject + db_contact = self.db.get_contact(pubkey) + if db_contact and db_contact.get('source') == 'advert': + self.db.hard_delete_contact(pubkey) + return {'success': True, 'message': 'Contact rejected'} return {'success': False, 'error': 'Contact not in pending list'} except Exception as e: logger.error(f"Failed to reject contact: {e}") diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 3f835ba..2d87b14 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -224,6 +224,19 @@ def delete_contact(selector: str) -> Tuple[bool, str]: return False, str(e) +def delete_cached_contact(public_key: str) -> Tuple[bool, str]: + """Hard-delete a cache-only contact from the database.""" + if not public_key or not public_key.strip(): + return False, "Public key is required" + + try: + dm = _get_dm() + result = dm.delete_cached_contact(public_key.strip()) + return result['success'], result.get('message', result.get('error', '')) + except Exception as e: + return False, str(e) + + def clean_inactive_contacts(hours: int = 48) -> Tuple[bool, str]: """Remove contacts inactive for specified hours. Simplified in v2.""" # TODO: implement time-based cleanup via database query diff --git a/app/routes/api.py b/app/routes/api.py index eac323b..b453e65 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2494,6 +2494,37 @@ def delete_contact_api(): }), 500 +@api_bp.route('/contacts/cached/delete', methods=['POST']) +def delete_cached_contact_api(): + """Delete a cache-only contact from the database.""" + 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.delete_cached_contact(public_key) + + if success: + return jsonify({'success': True, 'message': message}), 200 + else: + return jsonify({'success': False, 'error': message}), 500 + + except Exception as e: + logger.error(f"Error deleting cached contact: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/contacts/protected', methods=['GET']) def get_protected_contacts_api(): """ diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index c9a4fe7..f92cabf 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -2193,6 +2193,15 @@ function createExistingContactCard(contact, index) { actionsDiv.appendChild(deleteBtn); } + // Delete button for cache-only contacts + if (contact.on_device === false) { + const deleteCacheBtn = document.createElement('button'); + deleteCacheBtn.className = 'btn btn-sm btn-outline-danger'; + deleteCacheBtn.innerHTML = ' Delete'; + deleteCacheBtn.onclick = () => showDeleteModal(contact); + actionsDiv.appendChild(deleteCacheBtn); + } + // Ignore/Block/Unignore/Unblock buttons if (contact.is_blocked) { const unblockBtn = document.createElement('button'); @@ -2317,17 +2326,19 @@ async function confirmDelete() { } try { - // Use contact name for deletion (meshcli remove_contact only works with name) - const selector = contactToDelete.name; + // Use different endpoint for cache-only vs device contacts + const isCacheOnly = contactToDelete.on_device === false; + const url = isCacheOnly ? '/api/contacts/cached/delete' : '/api/contacts/delete'; + const body = isCacheOnly + ? { public_key: contactToDelete.public_key } + : { selector: contactToDelete.public_key }; - const response = await fetch('/api/contacts/delete', { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - selector: selector - }) + body: JSON.stringify(body) }); const data = await response.json();