some more savety processing
This commit is contained in:
+24
-32
@@ -79,6 +79,7 @@ from Web.modules.inventarsystem.data_protection import (
|
|||||||
decrypt_text,
|
decrypt_text,
|
||||||
encrypt_document_fields,
|
encrypt_document_fields,
|
||||||
encrypt_soft_deleted_media_pack,
|
encrypt_soft_deleted_media_pack,
|
||||||
|
encrypt_text,
|
||||||
)
|
)
|
||||||
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
||||||
|
|
||||||
@@ -669,7 +670,6 @@ def _get_current_module(path):
|
|||||||
last_module = session.get('last_module')
|
last_module = session.get('last_module')
|
||||||
if last_module and cfg.MODULES.is_enabled(last_module):
|
if last_module and cfg.MODULES.is_enabled(last_module):
|
||||||
return last_module
|
return last_module
|
||||||
|
|
||||||
if cfg.MODULES.is_enabled('inventory'):
|
if cfg.MODULES.is_enabled('inventory'):
|
||||||
return 'inventory'
|
return 'inventory'
|
||||||
return 'library' if cfg.MODULES.is_enabled('library') else 'inventory'
|
return 'library' if cfg.MODULES.is_enabled('library') else 'inventory'
|
||||||
@@ -726,9 +726,9 @@ def _append_audit_event_standalone(event_type, payload):
|
|||||||
al.append_audit_event(
|
al.append_audit_event(
|
||||||
db=db,
|
db=db,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
actor=session.get('username', 'system'),
|
actor=encrypt_text(session.get('username', 'system')),
|
||||||
payload=payload,
|
payload=encrypt_text(str(payload)),
|
||||||
request_ip=request.remote_addr,
|
request_ip=encrypt_text(request.remote_addr),
|
||||||
source='web',
|
source='web',
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -950,7 +950,7 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
|
|||||||
tag=f'notification-{notif_type}'
|
tag=f'notification-{notif_type}'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.warning(f'Failed to send push notification to {target_user}: {e}')
|
app.logger.warning(f'Failed to send push notification to {encrypt_text(target_user)}: {e}')
|
||||||
elif audience == 'admin':
|
elif audience == 'admin':
|
||||||
_bump_notification_version('admin')
|
_bump_notification_version('admin')
|
||||||
# Send push notification to all admins
|
# Send push notification to all admins
|
||||||
@@ -1053,7 +1053,7 @@ def _get_cached_unread_status(username, is_admin=False):
|
|||||||
if cached:
|
if cached:
|
||||||
return json.loads(cached), version_tag
|
return json.loads(cached), version_tag
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.warning(f'Could not read notification status cache for {username}: {exc}')
|
app.logger.warning(f'Could not read notification status cache for {encrypt_text(username)}: {exc}')
|
||||||
return None, version_tag
|
return None, version_tag
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1076,7 +1076,7 @@ def _set_cached_unread_status(username, is_admin, version_tag, payload):
|
|||||||
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
|
cache_client.setex(key, NOTIFICATION_STATUS_CACHE_TTL, json.dumps(payload, default=str))
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.warning(f'Could not write notification status cache for {username}: {exc}')
|
app.logger.warning(f'Could not write notification status cache for {encrypt_text(username)}: {exc}')
|
||||||
|
|
||||||
with _NOTIFICATION_CACHE_LOCK:
|
with _NOTIFICATION_CACHE_LOCK:
|
||||||
_NOTIFICATION_LOCAL_CACHE[key] = {
|
_NOTIFICATION_LOCAL_CACHE[key] = {
|
||||||
@@ -2144,7 +2144,7 @@ def _upload_student_cards_excel():
|
|||||||
created_total += 1
|
created_total += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f'Error importing student cards from Excel: {exc}')
|
app.logger.error(f'Error importing student cards from Excel: {exc}')
|
||||||
flash(f'Fehler beim Import der Bibliotheksausweise: {exc}', 'error')
|
flash(f'Fehler beim Import der Bibliotheksausweise', 'error')
|
||||||
return redirect(url_for('student_cards_admin'))
|
return redirect(url_for('student_cards_admin'))
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -3442,7 +3442,7 @@ def api_library_scan_action():
|
|||||||
'channel': 'library_scan',
|
'channel': 'library_scan',
|
||||||
'item_id': item_id,
|
'item_id': item_id,
|
||||||
'item_name': item_doc.get('Name', ''),
|
'item_name': item_doc.get('Name', ''),
|
||||||
'borrower': borrower_name,
|
'borrower':borrower_name,
|
||||||
'student_card_id': student_card_id,
|
'student_card_id': student_card_id,
|
||||||
'borrow_duration_days': borrow_duration_days,
|
'borrow_duration_days': borrow_duration_days,
|
||||||
}
|
}
|
||||||
@@ -3611,7 +3611,6 @@ def api_item_detail(item_id):
|
|||||||
"""
|
"""
|
||||||
client.close()
|
client.close()
|
||||||
return detail_html, 200
|
return detail_html, 200
|
||||||
return detail_html, 200
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error fetching item detail: {e}")
|
app.logger.error(f"Error fetching item detail: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -4421,19 +4420,17 @@ def login():
|
|||||||
ctx = get_tenant_context()
|
ctx = get_tenant_context()
|
||||||
current_tenant_id = ctx.tenant_id if ctx else None
|
current_tenant_id = ctx.tenant_id if ctx else None
|
||||||
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
|
current_tenant_db = ctx.db_name if ctx else cfg.MONGODB_DB
|
||||||
app.logger.info(f"Login attempt: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
app.logger.info(f"Login attempt: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||||
app.logger.info(f"Debug login context: headers={dict(request.headers)} tenant_config={ctx.config if ctx else None} remote_addr={request.remote_addr} host={request.host}")
|
|
||||||
app.logger.info(f"Raw login payload: username={username!r} password={password!r}")
|
|
||||||
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
|
app.logger.info(f"Active MongoDB config: uri={getattr(cfg, 'MONGODB_URI', None)!r} host={cfg.MONGODB_HOST!r} port={cfg.MONGODB_PORT!r} default_db={cfg.MONGODB_DB!r}")
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={request.remote_addr}")
|
app.logger.warning(f"Login blocked: missing credentials tenant={current_tenant_id or 'default'} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||||
flash('Bitte alle Felder ausfüllen', 'error')
|
flash('Bitte alle Felder ausfüllen', 'error')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
user = us.check_nm_pwd(username, password)
|
user = us.check_nm_pwd(username, password)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
app.logger.info(f"Login success: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
app.logger.info(f"Login success: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||||
session['username'] = username
|
session['username'] = username
|
||||||
is_admin_user = bool(user.get('Admin', False))
|
is_admin_user = bool(user.get('Admin', False))
|
||||||
session['admin'] = is_admin_user
|
session['admin'] = is_admin_user
|
||||||
@@ -4454,7 +4451,7 @@ def login():
|
|||||||
else:
|
else:
|
||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
else:
|
else:
|
||||||
app.logger.warning(f"Login failed: username={username!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={request.remote_addr}")
|
app.logger.warning(f"Login failed: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||||
flash('Ungültige Anmeldedaten', 'error')
|
flash('Ungültige Anmeldedaten', 'error')
|
||||||
get_flashed_messages()
|
get_flashed_messages()
|
||||||
return render_template('login.html')
|
return render_template('login.html')
|
||||||
@@ -5118,7 +5115,7 @@ def upload_item():
|
|||||||
|
|
||||||
# Log mobile request for debugging
|
# Log mobile request for debugging
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {username}")
|
app.logger.info(f"Mobile upload from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Strip whitespace from all text fields
|
# Strip whitespace from all text fields
|
||||||
@@ -5351,7 +5348,7 @@ def upload_item():
|
|||||||
|
|
||||||
# Create a structured log entry for upload session
|
# Create a structured log entry for upload session
|
||||||
upload_session_id = str(uuid.uuid4())[:8]
|
upload_session_id = str(uuid.uuid4())[:8]
|
||||||
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {username}")
|
app.logger.info(f"Starting image upload session {upload_session_id} - Files: {len(images)}, User: {encrypt_text(username)}")
|
||||||
|
|
||||||
# Ensure all required directories exist
|
# Ensure all required directories exist
|
||||||
for directory in [app.config['UPLOAD_FOLDER']]:
|
for directory in [app.config['UPLOAD_FOLDER']]:
|
||||||
@@ -6105,7 +6102,7 @@ def duplicate_item():
|
|||||||
|
|
||||||
# Log mobile duplication for debugging
|
# Log mobile duplication for debugging
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {username}")
|
app.logger.info(f"Mobile duplication from {request.headers.get('User-Agent', 'unknown')} by {encrypt_text(username)}")
|
||||||
|
|
||||||
# Get original item ID
|
# Get original item ID
|
||||||
original_item_id = request.form.get('original_item_id')
|
original_item_id = request.form.get('original_item_id')
|
||||||
@@ -9988,11 +9985,6 @@ def my_borrowed_items():
|
|||||||
'Status': 'planned'
|
'Status': 'planned'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# DEBUG: Log the number of planned appointments found
|
|
||||||
app.logger.info(f"Found {len(planned_ausleihungen)} planned appointments for user {username}")
|
|
||||||
for appt in planned_ausleihungen:
|
|
||||||
app.logger.info(f"Planned appointment: ID={str(appt['_id'])}, Item={str(appt.get('Item'))}, Start={appt.get('Start')}")
|
|
||||||
|
|
||||||
# Process items
|
# Process items
|
||||||
active_items = []
|
active_items = []
|
||||||
planned_items = []
|
planned_items = []
|
||||||
@@ -10198,7 +10190,7 @@ def mark_all_notifications_read():
|
|||||||
if result.modified_count > 0:
|
if result.modified_count > 0:
|
||||||
_bump_notification_version(f'user:{username}')
|
_bump_notification_version(f'user:{username}')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.warning(f"Could not mark all notifications as read for {username}: {exc}")
|
app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}")
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -10277,7 +10269,7 @@ def notifications_unread_status():
|
|||||||
etag_value = _build_unread_status_etag(version_tag, payload)
|
etag_value = _build_unread_status_etag(version_tag, payload)
|
||||||
return _build_cached_json_response(payload, etag_value)
|
return _build_cached_json_response(payload, etag_value)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.warning(f"Could not fetch unread notification status for {username}: {exc}")
|
app.logger.warning(f"Could not fetch unread notification status for {encrypt_text(username)}: {exc}")
|
||||||
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
|
return jsonify({'ok': False, 'error': 'status_fetch_failed'}), 500
|
||||||
finally:
|
finally:
|
||||||
if client:
|
if client:
|
||||||
@@ -11573,10 +11565,10 @@ def log_mobile_action(action, request, success=True, details=None):
|
|||||||
if details:
|
if details:
|
||||||
message += f" - Details: {details}"
|
message += f" - Details: {details}"
|
||||||
|
|
||||||
if success:
|
# if success:
|
||||||
app.logger.info(message)
|
# app.logger.info(message)
|
||||||
else:
|
# else:
|
||||||
app.logger.error(message)
|
# app.logger.error(message)
|
||||||
|
|
||||||
# Add explicit static file routes to handle CSS serving issues
|
# Add explicit static file routes to handle CSS serving issues
|
||||||
@app.route('/static/<path:filename>')
|
@app.route('/static/<path:filename>')
|
||||||
@@ -11858,7 +11850,7 @@ def subscribe_to_push():
|
|||||||
success = pn.save_push_subscription(username, subscription)
|
success = pn.save_push_subscription(username, subscription)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
app.logger.info(f'Push subscription saved for {username}')
|
app.logger.info(f'Push subscription saved for {encrypt_text(username)}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Successfully subscribed to push notifications'
|
'message': 'Successfully subscribed to push notifications'
|
||||||
@@ -11895,7 +11887,7 @@ def unsubscribe_from_push():
|
|||||||
success = pn.remove_push_subscription(username, endpoint)
|
success = pn.remove_push_subscription(username, endpoint)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
app.logger.info(f'Push subscription removed for {username}')
|
app.logger.info(f'Push subscription removed for {encrypt_text(username)}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Successfully unsubscribed from push notifications'
|
'message': 'Successfully unsubscribed from push notifications'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
|||||||
ausleihung_id: Die ID der Ausleihung
|
ausleihung_id: Die ID der Ausleihung
|
||||||
old_status: Der alte Status
|
old_status: Der alte Status
|
||||||
new_status: Der neue Status
|
new_status: Der neue Status
|
||||||
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
# Erstelle Log-Verzeichnis, falls es nicht existiert
|
||||||
@@ -34,10 +34,9 @@ def log_status_change(ausleihung_id, old_status, new_status, user=None):
|
|||||||
|
|
||||||
# Protokolliere die Änderung
|
# Protokolliere die Änderung
|
||||||
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
user_info = f" by {user}" if user else ""
|
|
||||||
|
|
||||||
with open(log_file, 'a', encoding='utf-8') as f:
|
with open(log_file, 'a', encoding='utf-8') as f:
|
||||||
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
|
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'\n")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+70
-109
@@ -13,6 +13,7 @@ import logging
|
|||||||
|
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.modules.database.settings import MongoClient
|
from Web.modules.database.settings import MongoClient
|
||||||
|
from Web.modules.inventarsystem.data_protection import encrypt_text, decrypt_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,16 +45,22 @@ if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
|
|||||||
serialization.PublicFormat.UncompressedPoint
|
serialization.PublicFormat.UncompressedPoint
|
||||||
)
|
)
|
||||||
|
|
||||||
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
|
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8')
|
||||||
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Could not load or generate VAPID keys: {e}')
|
logger.error(f'Could not load or generate VAPID keys: {e}')
|
||||||
|
|
||||||
# Push service endpoint (typically Firebase or Web Push Service)
|
# Push service endpoint (typically Firebase or Web Push Service)
|
||||||
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
|
|
||||||
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_username_hash(username):
|
||||||
|
"""Generates a deterministic hash for database lookups."""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
return hashlib.sha256(username.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_push_subscriptions_collection(db=None):
|
def get_push_subscriptions_collection(db=None):
|
||||||
"""Get MongoDB push subscriptions collection"""
|
"""Get MongoDB push subscriptions collection"""
|
||||||
if db is None:
|
if db is None:
|
||||||
@@ -64,71 +71,68 @@ def get_push_subscriptions_collection(db=None):
|
|||||||
|
|
||||||
def get_user_subscriptions(username):
|
def get_user_subscriptions(username):
|
||||||
"""
|
"""
|
||||||
Get all active push subscriptions for a user
|
Get all active push subscriptions for a user, decrypting data on the fly.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of subscription documents
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
subscriptions = list(subs_col.find({
|
# Query using the deterministic hash, NOT the encrypted text directly
|
||||||
'Username': username,
|
user_hash = _get_username_hash(username)
|
||||||
|
|
||||||
|
encrypted_subscriptions = list(subs_col.find({
|
||||||
|
'UsernameHash': user_hash,
|
||||||
'IsActive': True
|
'IsActive': True
|
||||||
}))
|
}))
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
return subscriptions
|
|
||||||
|
# Decrypt endpoints and keys before returning
|
||||||
|
decrypted_subs = []
|
||||||
|
for sub in encrypted_subscriptions:
|
||||||
|
try:
|
||||||
|
sub['Endpoint'] = decrypt_text(sub.get('Endpoint'))
|
||||||
|
|
||||||
|
# Keys are stored as encrypted JSON strings
|
||||||
|
decrypted_keys_str = decrypt_text(sub.get('Keys'))
|
||||||
|
sub['Keys'] = json.loads(decrypted_keys_str) if decrypted_keys_str else {}
|
||||||
|
|
||||||
|
decrypted_subs.append(sub)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt subscription payload for hash {user_hash}: {e}")
|
||||||
|
|
||||||
|
return decrypted_subs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error getting push subscriptions for {username}: {e}')
|
logger.error(f'Error getting push subscriptions for user: {e}')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def save_push_subscription(username, subscription_obj):
|
def save_push_subscription(username, subscription_obj):
|
||||||
"""
|
"""
|
||||||
Save a new push subscription for a user
|
Save a new push subscription for a user with field-level encryption.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
subscription_obj (dict): Subscription object from Service Worker
|
|
||||||
{
|
|
||||||
'endpoint': 'https://...',
|
|
||||||
'keys': {
|
|
||||||
'p256dh': '...',
|
|
||||||
'auth': '...'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Success status
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not subscription_obj.get('endpoint'):
|
endpoint = subscription_obj.get('endpoint')
|
||||||
logger.warning(f'Invalid subscription object for {username}')
|
if not endpoint:
|
||||||
|
logger.warning('Invalid subscription object: missing endpoint')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
# Create unique hash of subscription to avoid duplicates
|
# Create unique hash of subscription using plaintext data to avoid duplicates
|
||||||
sub_hash = hashlib.shake_256(
|
sub_hash = hashlib.shake_256(
|
||||||
f"{username}:{subscription_obj['endpoint']}".encode()
|
f"{username}:{endpoint}".encode('utf-8')
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
# Check if subscription already exists
|
# Check if subscription already exists by Hash
|
||||||
existing = subs_col.find_one({
|
existing = subs_col.find_one({
|
||||||
'Username': username,
|
|
||||||
'SubscriptionHash': sub_hash
|
'SubscriptionHash': sub_hash
|
||||||
})
|
})
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Update last used time
|
|
||||||
subs_col.update_one(
|
subs_col.update_one(
|
||||||
{'_id': existing['_id']},
|
{'_id': existing['_id']},
|
||||||
{'$set': {
|
{'$set': {
|
||||||
@@ -136,15 +140,19 @@ def save_push_subscription(username, subscription_obj):
|
|||||||
'IsActive': True
|
'IsActive': True
|
||||||
}}
|
}}
|
||||||
)
|
)
|
||||||
logger.info(f'Updated existing subscription for {username}')
|
logger.info('Updated existing push subscription')
|
||||||
client.close()
|
client.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Save new subscription
|
# Format keys as JSON string for your encrypt_text module
|
||||||
|
keys_str = json.dumps(subscription_obj.get('keys', {}))
|
||||||
|
|
||||||
|
# Save new subscription, encrypting sensitive fields
|
||||||
subscription_doc = {
|
subscription_doc = {
|
||||||
'Username': username,
|
'UsernameHash': _get_username_hash(username),
|
||||||
'Endpoint': subscription_obj['endpoint'],
|
'Username': encrypt_text(username),
|
||||||
'Keys': subscription_obj.get('keys', {}),
|
'Endpoint': encrypt_text(endpoint),
|
||||||
|
'Keys': encrypt_text(keys_str),
|
||||||
'SubscriptionHash': sub_hash,
|
'SubscriptionHash': sub_hash,
|
||||||
'IsActive': True,
|
'IsActive': True,
|
||||||
'CreatedAt': datetime.datetime.now(),
|
'CreatedAt': datetime.datetime.now(),
|
||||||
@@ -153,36 +161,31 @@ def save_push_subscription(username, subscription_obj):
|
|||||||
}
|
}
|
||||||
|
|
||||||
subs_col.insert_one(subscription_doc)
|
subs_col.insert_one(subscription_doc)
|
||||||
logger.info(f'Saved new push subscription for {username}')
|
logger.info('Saved new encrypted push subscription')
|
||||||
client.close()
|
client.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error saving push subscription for {username}: {e}')
|
logger.error(f'Error saving push subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def remove_push_subscription(username, endpoint):
|
def remove_push_subscription(username, endpoint):
|
||||||
"""
|
"""
|
||||||
Remove a push subscription
|
Remove a push subscription by making it inactive.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username
|
|
||||||
endpoint (str): Subscription endpoint URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: Success status
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
|
# Recreate the deterministic hash to find the specific subscription
|
||||||
|
sub_hash = hashlib.shake_256(
|
||||||
|
f"{username}:{endpoint}".encode('utf-8')
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
result = subs_col.update_one(
|
result = subs_col.update_one(
|
||||||
{
|
{'SubscriptionHash': sub_hash},
|
||||||
'Username': username,
|
|
||||||
'Endpoint': endpoint
|
|
||||||
},
|
|
||||||
{'$set': {'IsActive': False}}
|
{'$set': {'IsActive': False}}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,31 +193,19 @@ def remove_push_subscription(username, endpoint):
|
|||||||
return result.modified_count > 0
|
return result.modified_count > 0
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error removing push subscription for {username}: {e}')
|
logger.error(f'Error removing push subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
||||||
"""
|
"""
|
||||||
Send a push notification to all user's subscriptions
|
Send a push notification to all user's subscriptions.
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Target username
|
|
||||||
title (str): Notification title
|
|
||||||
body (str): Notification body
|
|
||||||
icon (str, optional): Icon URL
|
|
||||||
url (str, optional): URL to open on click
|
|
||||||
reference (dict, optional): Reference data (item_id, etc)
|
|
||||||
tag (str, optional): Notification tag for grouping
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of successfully sent notifications
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
subscriptions = get_user_subscriptions(username)
|
subscriptions = get_user_subscriptions(username)
|
||||||
|
|
||||||
if not subscriptions:
|
if not subscriptions:
|
||||||
logger.debug(f'No active push subscriptions for {username}')
|
logger.debug('No active push subscriptions for user')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
@@ -232,19 +223,18 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
|
|||||||
if success:
|
if success:
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
else:
|
else:
|
||||||
# Mark subscription as inactive if send fails
|
|
||||||
_mark_subscription_inactive(subscription['_id'])
|
_mark_subscription_inactive(subscription['_id'])
|
||||||
|
|
||||||
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
|
logger.info(f'Sent push notification: {sent_count}/{len(subscriptions)} subscriptions')
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error sending push notification to {username}: {e}')
|
logger.error(f'Error sending push notification: {e}')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
||||||
"""Send push notification to a specific subscription"""
|
"""Send push notification to a specific decrypted subscription"""
|
||||||
try:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
'title': title,
|
'title': title,
|
||||||
@@ -256,20 +246,17 @@ def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
|||||||
'reference': reference or {},
|
'reference': reference or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# If using Firebase Cloud Messaging
|
|
||||||
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
||||||
return _send_fcm_notification(subscription, payload)
|
return _send_fcm_notification(subscription, payload)
|
||||||
|
|
||||||
# Otherwise use standard Web Push Protocol
|
|
||||||
return _send_web_push_notification(subscription, payload)
|
return _send_web_push_notification(subscription, payload)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
|
logger.error(f'Error sending to subscription: {e}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _send_fcm_notification(subscription, payload):
|
def _send_fcm_notification(subscription, payload):
|
||||||
"""Send notification via Firebase Cloud Messaging"""
|
|
||||||
try:
|
try:
|
||||||
if not FCM_API_KEY:
|
if not FCM_API_KEY:
|
||||||
logger.warning('FCM_API_KEY not configured')
|
logger.warning('FCM_API_KEY not configured')
|
||||||
@@ -311,9 +298,7 @@ def _send_fcm_notification(subscription, payload):
|
|||||||
|
|
||||||
|
|
||||||
def _send_web_push_notification(subscription, payload):
|
def _send_web_push_notification(subscription, payload):
|
||||||
"""Send notification using standard Web Push Protocol"""
|
|
||||||
try:
|
try:
|
||||||
# This requires pywebpush library
|
|
||||||
from pywebpush import webpush
|
from pywebpush import webpush
|
||||||
|
|
||||||
webpush(
|
webpush(
|
||||||
@@ -325,13 +310,13 @@ def _send_web_push_notification(subscription, payload):
|
|||||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||||
vapid_claims={'sub': VAPID_SUBJECT},
|
vapid_claims={'sub': VAPID_SUBJECT},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
ttl=3600 # Notification expires after 1 hour if device is offline
|
ttl=3600
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning('pywebpush not installed, install with: pip install pywebpush')
|
logger.warning('pywebpush not installed. pip install pywebpush')
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Web push error: {e}')
|
logger.error(f'Web push error: {e}')
|
||||||
@@ -339,7 +324,6 @@ def _send_web_push_notification(subscription, payload):
|
|||||||
|
|
||||||
|
|
||||||
def _mark_subscription_inactive(subscription_id):
|
def _mark_subscription_inactive(subscription_id):
|
||||||
"""Mark a subscription as inactive (e.g., after failed send)"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
@@ -349,37 +333,21 @@ def _mark_subscription_inactive(subscription_id):
|
|||||||
{'_id': ObjectId(subscription_id)},
|
{'_id': ObjectId(subscription_id)},
|
||||||
{'$set': {'IsActive': False}}
|
{'$set': {'IsActive': False}}
|
||||||
)
|
)
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error marking subscription inactive: {e}')
|
logger.error(f'Error marking subscription inactive: {e}')
|
||||||
|
|
||||||
|
|
||||||
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
||||||
"""
|
|
||||||
Send a push notification to all admin users
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title (str): Notification title
|
|
||||||
body (str): Notification body
|
|
||||||
icon (str, optional): Icon URL
|
|
||||||
url (str, optional): URL to open on click
|
|
||||||
reference (dict, optional): Reference data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Total notifications sent
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
users_col = db['users']
|
users_col = db['users']
|
||||||
|
|
||||||
# Get all admin users
|
|
||||||
admin_users = list(users_col.find(
|
admin_users = list(users_col.find(
|
||||||
{'Admin': True},
|
{'Admin': True},
|
||||||
{'Username': 1}
|
{'Username': 1}
|
||||||
))
|
))
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
total_sent = 0
|
total_sent = 0
|
||||||
@@ -404,10 +372,6 @@ def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
|||||||
|
|
||||||
|
|
||||||
def cleanup_inactive_subscriptions():
|
def cleanup_inactive_subscriptions():
|
||||||
"""
|
|
||||||
Remove inactive subscriptions older than 30 days
|
|
||||||
Run this periodically as a maintenance task
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
@@ -429,22 +393,19 @@ def cleanup_inactive_subscriptions():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# Database collection schema
|
|
||||||
def ensure_push_subscriptions_collection():
|
def ensure_push_subscriptions_collection():
|
||||||
"""Ensure the push_subscriptions collection exists with proper indexes"""
|
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = client[cfg.MONGODB_DB]
|
||||||
subs_col = get_push_subscriptions_collection(db)
|
subs_col = get_push_subscriptions_collection(db)
|
||||||
|
|
||||||
# Create indexes
|
subs_col.create_index('UsernameHash')
|
||||||
subs_col.create_index('Username')
|
subs_col.create_index([('UsernameHash', 1), ('IsActive', 1)])
|
||||||
subs_col.create_index([('Username', 1), ('IsActive', 1)])
|
subs_col.create_index([('CreatedAt', 1)])
|
||||||
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
|
|
||||||
subs_col.create_index('SubscriptionHash', unique=True)
|
subs_col.create_index('SubscriptionHash', unique=True)
|
||||||
|
|
||||||
logger.info('Push subscriptions collection indexes created')
|
logger.info('Push subscriptions collection indexes created')
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
logger.error(f'Error ensuring push subscriptions collection: {e}')
|
||||||
Reference in New Issue
Block a user