fix: contact delete by pubkey, cache contacts as pending, cache delete button

1. Delete button now sends public_key instead of name to avoid matching
   wrong contacts when multiple share similar names.
2. _on_advertisement adds cache-only contacts to mc.pending_contacts when
   manual approval is enabled, so they appear in the pending list after
   advertising (even if meshcore fires ADVERTISEMENT instead of NEW_CONTACT).
3. Added Delete button for cache-only contacts with dedicated
   /api/contacts/cached/delete endpoint and hard_delete_contact DB method.
4. approve_contact/reject_contact now handle DB-only pending contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-18 07:59:49 +01:00
parent eb19f3cf76
commit f66e95ffa0
5 changed files with 131 additions and 7 deletions

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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

View File

@@ -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():
"""

View File

@@ -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 = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
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();