diff --git a/WEB_PUSH_NOTIFICATIONS.md b/WEB_PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..a007cbf --- /dev/null +++ b/WEB_PUSH_NOTIFICATIONS.md @@ -0,0 +1,442 @@ +# Web Push Notifications für Inventarsystem + +## Überblick + +Web Push Notifications ermöglichen es, Benutzer über wichtige Ereignisse in Echtzeit zu benachrichtigen, auch wenn sie die Anwendung nicht aktiv nutzen. Diese Implementierung nutzt: + +- **Service Workers** für Hintergrundprozesse und Offline-Unterstützung +- **Web Push API** für Benachrichtigungen auf Desktop und Mobil +- **MongoDB** zur Speicherung von Subscriptions +- **VAPID-Authentifizierung** für sichere Push-Kommunikation + +## Architektur + +``` +┌─────────────────────────────────────────────────────┐ +│ Browser / Client-Side │ +├─────────────────────────────────────────────────────┤ +│ • Service Worker (static/service-worker.js) │ +│ • Push Notification Manager (js/push-notifications) │ +│ • Web App Manifest (manifest.json) │ +└─────────────────────────────────────────────────────┘ + ↕ (Push Subscriptions / Notifications) +┌─────────────────────────────────────────────────────┐ +│ Server / Backend │ +├─────────────────────────────────────────────────────┤ +│ • Flask API Endpoints (/api/push/*) │ +│ • Push Notification Manager (push_notifications.py)│ +│ • MongoDB Collections (push_subscriptions) │ +│ • VAPID Key Management │ +└─────────────────────────────────────────────────────┘ + ↕ (VAPID-signed push messages) +┌─────────────────────────────────────────────────────┐ +│ Push Service (Firebase, Web Push Service) │ +├─────────────────────────────────────────────────────┤ +│ • Stores subscriptions │ +│ • Delivers push messages to browsers │ +└─────────────────────────────────────────────────────┘ +``` + +## Setup & Konfiguration + +### 1. VAPID-Schlüssel generieren + +VAPID-Schlüssel sind erforderlich für die Authentifizierung mit dem Push-Dienst: + +```bash +cd /path/to/Inventarsystem +bash generate-vapid-keys.sh +``` + +Dies erzeugt ein Schlüsselpaar. **Speichern Sie den Private Key sicher!** + +Beispielausgabe: +``` +PUBLIC KEY (share with browsers): +BBxyz...xyz + +PRIVATE KEY (keep secret!): +AAabc...abc +``` + +### 2. Umgebungsvariablen setzen + +Speichern Sie die Schlüssel als Umgebungsvariablen: + +```bash +export VAPID_PUBLIC_KEY="BBxyz...xyz" +export VAPID_PRIVATE_KEY="AAabc...abc" +export VAPID_SUBJECT="mailto:admin@inventarsystem.local" +``` + +Für Docker: +```bash +# In .env oder docker-compose.yml +VAPID_PUBLIC_KEY=BBxyz...xyz +VAPID_PRIVATE_KEY=AAabc...abc +VAPID_SUBJECT=mailto:admin@inventarsystem.local +``` + +### 3. Abhängigkeiten installieren + +```bash +pip install -r requirements.txt +# oder +pip install pywebpush +``` + +### 4. MongoDB Collection initialisieren + +Die `push_subscriptions` Collection wird automatisch erstellt beim ersten Speichern einer Subscription. Indizes werden automatisch erstellt durch: + +```python +from Web.push_notifications import ensure_push_subscriptions_collection +ensure_push_subscriptions_collection() +``` + +## API Endpoints + +### `POST /api/push/subscribe` + +Speichert eine Notification Subscription des Benutzers. + +**Request:** +```json +{ + "subscription": { + "endpoint": "https://fcm.googleapis.com/fcm/send/...", + "keys": { + "p256dh": "BCOA...", + "auth": "kXA..." + } + } +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Successfully subscribed to push notifications" +} +``` + +--- + +### `POST /api/push/unsubscribe` + +Deaktiviert eine Notification Subscription. + +**Request:** +```json +{ + "endpoint": "https://fcm.googleapis.com/fcm/send/..." +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Successfully unsubscribed from push notifications" +} +``` + +--- + +### `GET /api/push/subscriptions` + +Listet alle aktiven Subscriptions des aktuellen Benutzers auf. + +**Response:** +```json +{ + "success": true, + "count": 2, + "subscriptions": [ + { + "id": "507f1f77bcf86cd799439011", + "endpoint": "https://...", + "created_at": "2026-04-10T14:30:00", + "last_used": "2026-04-10T15:45:00", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..." + } + ] +} +``` + +--- + +### `GET /api/push/vapid-key` + +Ruft den öffentlichen VAPID-Schlüssel ab (erforderlich für Browser). + +**Response:** +```json +{ + "success": true, + "vapid_key": "BBxyz...xyz" +} +``` + +--- + +### `POST /api/push/test` (Admin only) + +Sendet eine Test-Benachrichtigung. + +**Request:** +```json +{ + "target_user": "username" // optional, default: current user +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Test push sent to 2 subscription(s)" +} +``` + +## Frontend Integration + +### Aktivierung in Settings + +Fügen Sie einen Container in Ihre Einstellungsseite ein: + +```html +
+ + + +``` + +Dies zeigt einen Button und Subscription-Status an. + +### Manuelle Steuerung + +```javascript +// Initialisieren +await pushNotificationManager.init(); + +// Benachrichtigungen aktivieren +await pushNotificationManager.subscribe(); + +// Benachrichtigungen deaktivieren +await pushNotificationManager.unsubscribe(); + +// Status prüfen +const isSubscribed = await pushNotificationManager.isSubscribed(); + +// Test-Benachrichtigung senden (Admin) +await pushNotificationManager.sendTestNotification(); +``` + +## Backend Integration + +### Benachrichtigungen versenden + +```python +from Web import push_notifications as pn + +# Benachrichtigung an einen Benutzer +pn.send_push_notification( + 'username', + 'Titel', + 'Nachricht', + url='/my_borrowed_items', + reference={'item_id': '123', 'type': 'borrowing'} +) + +# Benachrichtigung an alle Admins +pn.send_push_to_all_admins( + 'Admin Alert', + 'Wichtiges Ereignis', + url='/main_admin' +) +``` + +### Integration mit Notification-System + +Benachrichtigungen werden automatisch über Push versendet, wenn erstellt: + +```python +# In app.py +_create_notification( + db, + audience='user', + notif_type='borrowing_activated', + title='Ausleihung aktiviert', + message='Ihre geplante Ausleihung ist jetzt aktiv', + target_user='john_doe', + reference={'url': '/my_borrowed_items', 'item_id': '123'} +) +# → Schreibt in DB + sendet Push-Benachrichtigung +``` + +## MongoDB Schema + +### Collection: `push_subscriptions` + +```json +{ + "_id": ObjectId("..."), + "Username": "john_doe", + "Endpoint": "https://fcm.googleapis.com/fcm/send/...", + "Keys": { + "p256dh": "BCOA...", + "auth": "kXA..." + }, + "SubscriptionHash": "abc123def456...", + "IsActive": true, + "CreatedAt": ISODate("2026-04-10T14:30:00Z"), + "LastUsed": ISODate("2026-04-10T15:45:00Z"), + "UserAgent": "Mozilla/5.0..." +} +``` + +**Indizes:** +- `Username` - Schnelle Abfrage nach Benutzer +- `{Username: 1, IsActive: 1}` - Abfrage aktiver Subscriptions +- `CreatedAt` - Zeitbasierte Bereinigung +- `SubscriptionHash` - Eindeutigkeit, Duplikat-Verhinderung + +## Service Worker + +### Funktionen + +Die Service Worker (`static/service-worker.js`) behandelt: + +1. **Push Events** - Empfängt und zeigt Benachrichtigungen an +2. **Click Handler** - Öffnet relevante URLs bei Benachrichtigungs-Klick +3. **Offline Caching** - Speichert statische Assets offline +4. **Background Sync** - Synchronisiert Daten im Hintergrund +5. **Installation** - Installiert sich selbst und lädt Cache vor + +### Push-Payload-Format + +```json +{ + "title": "Ausleihung aktiviert", + "body": "Ihre geplante Ausleihung ist jetzt aktiv", + "icon": "/static/img/logo-192x192.png", + "badge": "/static/img/badge-72x72.png", + "tag": "notification-borrowing_activated", + "url": "/my_borrowed_items", + "reference": { + "item_id": "123", + "type": "borrowing" + } +} +``` + +## Troubleshooting + +### Benachrichtigungen werden nicht empfangen + +1. **VAPID-Schlüssel nicht gesetzt** + ```bash + # Überprüfen + curl http://localhost:5000/api/push/vapid-key + # Sollte VAPID_PUBLIC_KEY zurückgeben, nicht "nicht konfiguriert" + ``` + +2. **Service Worker nicht registriert** + - Browser DevTools → Application → Service Workers + - Sollte "activated and running" anzeigen + - Überprüfen Sie Console auf Fehler + +3. **Notification Permission verweigert** + - Browser-Einstellungen überprüfen + - Site-Benachrichtigungsberechtigungen zurücksetzen + - `chrome://settings/content/notifications` (Chrome) + +4. **No active subscriptions** + ```bash + # MongoDB überprüfen + db.push_subscriptions.find({Username: "username"}) + # Sollte aktive Subscriptions anzeigen + ``` + +### Debug-Befehle + +```bash +# Alle Subscriptions anzeigen +docker exec inventarsystem-mongodb mongosh --eval \ + 'db.push_subscriptions.find({}).pretty()' Inventarsystem + +# Test-Push senden +curl -X POST http://localhost:5000/api/push/test \ + -H "Content-Type: application/json" \ + -c cookies.txt -b cookies.txt + +# Logs überprüfen +docker logs inventarsystem-app | grep -i "push\|notification" +``` + +## Browser-Unterstützung + +| Browser | Desktop | Mobile | Service Worker | Push API | +|---------|---------|--------|-----------------|----------| +| Chrome | ✅ | ✅ | ✅ | ✅ | +| Firefox | ✅ | ✅ | ✅ | ✅ | +| Safari | ⚠️ | ✅ | ⚠️ | ⚠️ | +| Edge | ✅ | ✅ | ✅ | ✅ | + +**Safari-Hinweis:** Verwendet Web Push über APNs mit Einschränkungen. + +## Best Practices + +### 1. Notification Häufigkeit +- Nicht mehr als 1 Benachrichtigung pro Minute pro Benutzer +- Sammelns Sie verwandte Events + +### 2. Payload-Größe +- Halten Sie Nachrichten kurz (<100 Zeichen) +- Verwenden Sie `reference` für Kontext, nicht `body` + +### 3. Sicherheit +- **Private Key**: Niemals in Code commiten! +- **Secrets**: Nur als Umgebungsvariablen speichern +- **Validate**: Server-seitig alle Subscription-Daten validieren + +### 4. Datenschutz +- Dokumentieren Sie Push-Sammlung (DSGVO) +- Bieten Sie einfache Opt-out-Möglichkeit +- Speichern Sie keine PII in Push-Nachrichten + +### 5. Wartung + +Stale Subscriptions automatisch bereinigen: + +```python +# In Scheduler oder Cron-Job +from Web.push_notifications import cleanup_inactive_subscriptions +cleanup_inactive_subscriptions() # Entfernt inaktive Subs älter als 30 Tage +``` + +## Performance-Tipps + +1. **Batch-Sends**: Senden Sie mehrere Pushes in einem Query +2. **Async**: Verwenden Sie Background Tasks für Push-Sending +3. **Redis**: Nutzen Sie Cache für häufig angeforderte VAPID-Keys +4. **Indexes**: MongoDB-Indexes auf `Username`, `IsActive` sollten vorhanden sein + +## Zukünftige Erweiterungen + +- [ ] Action Buttons in Benachrichtigungen (Approve/Deny) +- [ ] Benachrichtigungs-Kategorien und Gruppierung +- [ ] Rich-Media-Unterstützung (Bilder, Video) +- [ ] Scheduled Notifications +- [ ] Analytics & Delivery Tracking + +--- + +**Version:** 1.0 (Inventarsystem v0.5+) +**Letzte Aktualisierung:** April 2026 diff --git a/Web/app.py b/Web/app.py index ba7258a..fd5e3cb 100755 --- a/Web/app.py +++ b/Web/app.py @@ -32,6 +32,7 @@ import user as us import items as it import ausleihung as au import audit_log as al +import push_notifications as pn import datetime from apscheduler.schedulers.background import BackgroundScheduler from bson.objectid import ObjectId @@ -745,8 +746,30 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use if audience == 'user' and target_user: _bump_notification_version(f'user:{target_user}') + # Send push notification to user + try: + pn.send_push_notification( + target_user, + title, + message, + url=reference.get('url', '/') if reference else '/', + reference=reference, + tag=f'notification-{notif_type}' + ) + except Exception as e: + app.logger.warning(f'Failed to send push notification to {target_user}: {e}') elif audience == 'admin': _bump_notification_version('admin') + # Send push notification to all admins + try: + pn.send_push_to_all_admins( + title, + message, + url=reference.get('url', '/') if reference else '/', + reference=reference + ) + except Exception as e: + app.logger.warning(f'Failed to send push to admins: {e}') return True @@ -10740,3 +10763,182 @@ def get_optimal_image_quality(img, target_size_kb=80): return quality return best_quality + + +# ============================================================================ +# PUSH NOTIFICATION API ENDPOINTS +# ============================================================================ + +@app.route('/api/push/subscribe', methods=['POST']) +def subscribe_to_push(): + """ + Subscribe a user to push notifications + + Expects JSON payload: + { + 'subscription': { + 'endpoint': '...', + 'keys': {'p256dh': '...', 'auth': '...'} + } + } + """ + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + + try: + data = request.get_json() or {} + subscription = data.get('subscription') + + if not subscription or not subscription.get('endpoint'): + return jsonify({'success': False, 'error': 'Invalid subscription'}), 400 + + username = session['username'] + success = pn.save_push_subscription(username, subscription) + + if success: + app.logger.info(f'Push subscription saved for {username}') + return jsonify({ + 'success': True, + 'message': 'Successfully subscribed to push notifications' + }) + else: + return jsonify({'success': False, 'error': 'Failed to save subscription'}), 500 + + except Exception as e: + app.logger.error(f'Error subscribing to push: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/push/unsubscribe', methods=['POST']) +def unsubscribe_from_push(): + """ + Unsubscribe a user from push notifications + + Expects JSON payload: + { + 'endpoint': '...' + } + """ + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + + try: + data = request.get_json() or {} + endpoint = data.get('endpoint') + + if not endpoint: + return jsonify({'success': False, 'error': 'Missing endpoint'}), 400 + + username = session['username'] + success = pn.remove_push_subscription(username, endpoint) + + if success: + app.logger.info(f'Push subscription removed for {username}') + return jsonify({ + 'success': True, + 'message': 'Successfully unsubscribed from push notifications' + }) + else: + return jsonify({'success': False, 'error': 'Subscription not found'}), 404 + + except Exception as e: + app.logger.error(f'Error unsubscribing from push: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/push/subscriptions', methods=['GET']) +def get_push_subscriptions(): + """ + Get all push subscriptions for the current user + """ + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + + try: + username = session['username'] + subscriptions = pn.get_user_subscriptions(username) + + # Convert ObjectIds to strings for JSON serialization + subs_data = [] + for sub in subscriptions: + subs_data.append({ + 'id': str(sub['_id']), + 'endpoint': sub.get('Endpoint', ''), + 'created_at': sub.get('CreatedAt', '').isoformat() if sub.get('CreatedAt') else '', + 'last_used': sub.get('LastUsed', '').isoformat() if sub.get('LastUsed') else '', + 'user_agent': sub.get('UserAgent', ''), + }) + + return jsonify({ + 'success': True, + 'subscriptions': subs_data, + 'count': len(subs_data) + }) + + except Exception as e: + app.logger.error(f'Error getting push subscriptions: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/push/vapid-key', methods=['GET']) +def get_vapid_key(): + """ + Get the VAPID public key for push notifications + Used by the service worker to communicate with push service + """ + try: + vapid_key = pn.VAPID_PUBLIC_KEY + if not vapid_key: + app.logger.warning('VAPID_PUBLIC_KEY not configured') + return jsonify({ + 'success': False, + 'error': 'Push notifications not configured on server' + }), 501 + + return jsonify({ + 'success': True, + 'vapid_key': vapid_key + }) + + except Exception as e: + app.logger.error(f'Error getting VAPID key: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/push/test', methods=['POST']) +def test_push_notification(): + """ + Send a test push notification (admin only) + """ + if 'username' not in session: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + + if not us.is_admin(session['username']): + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + try: + data = request.get_json() or {} + target_user = data.get('target_user', session['username']) + + sent = pn.send_push_notification( + target_user, + 'Test Benachrichtigung', + 'Dies ist eine Test-Benachrichtigung von Inventarsystem', + url='/', + tag='test-notification' + ) + + if sent > 0: + return jsonify({ + 'success': True, + 'message': f'Test push sent to {sent} subscription(s)' + }) + else: + return jsonify({ + 'success': False, + 'error': 'No active subscriptions for user' + }), 404 + + except Exception as e: + app.logger.error(f'Error sending test push: {e}') + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/Web/push_notifications.py b/Web/push_notifications.py new file mode 100644 index 0000000..763383a --- /dev/null +++ b/Web/push_notifications.py @@ -0,0 +1,421 @@ +""" +Push Notification Management System +Handles Web Push notifications for users via Service Workers +""" + +import os +import json +import datetime +from pymongo import MongoClient +from bson import ObjectId +import requests +import hashlib +import logging + +import settings as cfg + +logger = logging.getLogger(__name__) + +# VAPID keys for push notifications (should be in environment variables) +VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '') +VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '') +VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', f'mailto:admin@{os.getenv("SERVER_NAME", "localhost")}') + +# 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 + + +def get_push_subscriptions_collection(db=None): + """Get MongoDB push subscriptions collection""" + if db is None: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + return db['push_subscriptions'] + + +def get_user_subscriptions(username): + """ + Get all active push subscriptions for a user + + Args: + username (str): Username + + Returns: + list: List of subscription documents + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + subscriptions = list(subs_col.find({ + 'Username': username, + 'IsActive': True + })) + + client.close() + return subscriptions + except Exception as e: + logger.error(f'Error getting push subscriptions for {username}: {e}') + return [] + + +def save_push_subscription(username, subscription_obj): + """ + Save a new push subscription for a user + + Args: + username (str): Username + subscription_obj (dict): Subscription object from Service Worker + { + 'endpoint': 'https://...', + 'keys': { + 'p256dh': '...', + 'auth': '...' + } + } + + Returns: + bool: Success status + """ + try: + if not subscription_obj.get('endpoint'): + logger.warning(f'Invalid subscription object for {username}') + return False + + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + # Create unique hash of subscription to avoid duplicates + sub_hash = hashlib.md5( + f"{username}:{subscription_obj['endpoint']}".encode() + ).hexdigest() + + # Check if subscription already exists + existing = subs_col.find_one({ + 'Username': username, + 'SubscriptionHash': sub_hash + }) + + if existing: + # Update last used time + subs_col.update_one( + {'_id': existing['_id']}, + {'$set': { + 'LastUsed': datetime.datetime.now(), + 'IsActive': True + }} + ) + logger.info(f'Updated existing subscription for {username}') + client.close() + return True + + # Save new subscription + subscription_doc = { + 'Username': username, + 'Endpoint': subscription_obj['endpoint'], + 'Keys': subscription_obj.get('keys', {}), + 'SubscriptionHash': sub_hash, + 'IsActive': True, + 'CreatedAt': datetime.datetime.now(), + 'LastUsed': datetime.datetime.now(), + 'UserAgent': subscription_obj.get('userAgent', ''), + } + + subs_col.insert_one(subscription_doc) + logger.info(f'Saved new push subscription for {username}') + client.close() + return True + + except Exception as e: + logger.error(f'Error saving push subscription for {username}: {e}') + return False + + +def remove_push_subscription(username, endpoint): + """ + Remove a push subscription + + Args: + username (str): Username + endpoint (str): Subscription endpoint URL + + Returns: + bool: Success status + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + result = subs_col.update_one( + { + 'Username': username, + 'Endpoint': endpoint + }, + {'$set': {'IsActive': False}} + ) + + client.close() + return result.modified_count > 0 + + except Exception as e: + logger.error(f'Error removing push subscription for {username}: {e}') + return False + + +def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'): + """ + 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: + subscriptions = get_user_subscriptions(username) + + if not subscriptions: + logger.debug(f'No active push subscriptions for {username}') + return 0 + + sent_count = 0 + + for subscription in subscriptions: + success = _send_to_subscription( + subscription, + title, + body, + icon, + url, + reference, + tag + ) + if success: + sent_count += 1 + else: + # Mark subscription as inactive if send fails + _mark_subscription_inactive(subscription['_id']) + + logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions') + return sent_count + + except Exception as e: + logger.error(f'Error sending push notification to {username}: {e}') + return 0 + + +def _send_to_subscription(subscription, title, body, icon, url, reference, tag): + """Send push notification to a specific subscription""" + try: + payload = { + 'title': title, + 'body': body, + 'icon': icon or '/static/img/logo-192x192.png', + 'badge': '/static/img/badge-72x72.png', + 'tag': tag, + 'url': url, + 'reference': reference or {}, + } + + # If using Firebase Cloud Messaging + if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'): + return _send_fcm_notification(subscription, payload) + + # Otherwise use standard Web Push Protocol + return _send_web_push_notification(subscription, payload) + + except Exception as e: + logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}') + return False + + +def _send_fcm_notification(subscription, payload): + """Send notification via Firebase Cloud Messaging""" + try: + if not FCM_API_KEY: + logger.warning('FCM_API_KEY not configured') + return False + + fcm_payload = { + 'to': subscription['Endpoint'], + 'notification': { + 'title': payload['title'], + 'body': payload['body'], + 'icon': payload['icon'], + 'badge': payload['badge'], + 'tag': payload['tag'], + 'click_action': payload['url'], + }, + 'data': { + 'url': payload['url'], + 'type': payload.get('type', 'info'), + } + } + + headers = { + 'Authorization': f'key={FCM_API_KEY}', + 'Content-Type': 'application/json' + } + + response = requests.post( + 'https://fcm.googleapis.com/fcm/send', + json=fcm_payload, + headers=headers, + timeout=10 + ) + + return response.status_code == 200 + + except Exception as e: + logger.error(f'FCM notification error: {e}') + return False + + +def _send_web_push_notification(subscription, payload): + """Send notification using standard Web Push Protocol""" + try: + # This requires pywebpush library + from pywebpush import webpush + + webpush( + subscription_info={ + 'endpoint': subscription['Endpoint'], + 'keys': subscription.get('Keys', {}) + }, + data=json.dumps(payload), + vapid_private_key=VAPID_PRIVATE_KEY, + vapid_claims={'sub': VAPID_SUBJECT}, + timeout=10 + ) + + return True + + except ImportError: + logger.warning('pywebpush not installed, install with: pip install pywebpush') + return False + except Exception as e: + logger.error(f'Web push error: {e}') + return False + + +def _mark_subscription_inactive(subscription_id): + """Mark a subscription as inactive (e.g., after failed send)""" + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + subs_col.update_one( + {'_id': ObjectId(subscription_id)}, + {'$set': {'IsActive': False}} + ) + + client.close() + except Exception as e: + logger.error(f'Error marking subscription inactive: {e}') + + +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: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + users_col = db['users'] + + # Get all admin users + admin_users = list(users_col.find( + {'IsAdmin': True}, + {'Username': 1} + )) + + client.close() + + total_sent = 0 + for admin_doc in admin_users: + sent = send_push_notification( + admin_doc['Username'], + title, + body, + icon, + url, + reference, + tag='admin-notification' + ) + total_sent += sent + + logger.info(f'Sent push to all admins: {total_sent} notifications') + return total_sent + + except Exception as e: + logger.error(f'Error sending push to admins: {e}') + return 0 + + +def cleanup_inactive_subscriptions(): + """ + Remove inactive subscriptions older than 30 days + Run this periodically as a maintenance task + """ + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30) + + result = subs_col.delete_many({ + 'IsActive': False, + 'LastUsed': {'$lt': cutoff_date} + }) + + client.close() + logger.info(f'Cleaned up {result.deleted_count} inactive subscriptions') + return result.deleted_count + + except Exception as e: + logger.error(f'Error cleaning up subscriptions: {e}') + return 0 + + +# Database collection schema +def ensure_push_subscriptions_collection(): + """Ensure the push_subscriptions collection exists with proper indexes""" + try: + client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) + db = client[cfg.MONGODB_DB] + subs_col = get_push_subscriptions_collection(db) + + # Create indexes + subs_col.create_index('Username') + subs_col.create_index([('Username', 1), ('IsActive', 1)]) + subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage + subs_col.create_index('SubscriptionHash', unique=True) + + logger.info('Push subscriptions collection indexes created') + client.close() + + except Exception as e: + logger.error(f'Error ensuring push subscriptions collection: {e}') diff --git a/Web/static/js/push-notifications.js b/Web/static/js/push-notifications.js new file mode 100644 index 0000000..6dbfe62 --- /dev/null +++ b/Web/static/js/push-notifications.js @@ -0,0 +1,470 @@ +/** + * Push Notification Management + * Handles subscription, unsubscription, and UI updates for web push notifications + */ + +class PushNotificationManager { + constructor() { + this.serviceWorkerRegistration = null; + this.vapidKey = null; + this.isSupported = this.checkSupport(); + } + + /** + * Check if push notifications are supported + */ + checkSupport() { + return ( + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); + } + + /** + * Initialize push notification system + * Must be called after page load + */ + async init() { + if (!this.isSupported) { + console.log('Push notifications not supported in this browser'); + return false; + } + + try { + // Get service worker registration + const registrations = await navigator.serviceWorker.getRegistrations(); + this.serviceWorkerRegistration = registrations.find( + reg => reg.scope === window.location.origin + '/' + ); + + if (!this.serviceWorkerRegistration) { + console.warn('Service Worker not found, retrying...'); + // Try registering if not already done + try { + this.serviceWorkerRegistration = await navigator.serviceWorker.register('/static/service-worker.js'); + } catch (err) { + console.error('Failed to register Service Worker:', err); + return false; + } + } + + // Fetch VAPID public key from server + const keyResponse = await fetch('/api/push/vapid-key'); + if (keyResponse.ok) { + const keyData = await keyResponse.json(); + this.vapidKey = keyData.vapid_key; + } else { + console.warn('Failed to fetch VAPID key'); + return false; + } + + return true; + } catch (error) { + console.error('Failed to initialize push notifications:', error); + return false; + } + } + + /** + * Request notification permission from user + */ + async requestPermission() { + if (!this.isSupported) { + console.warn('Push notifications not supported'); + return false; + } + + if (Notification.permission === 'granted') { + console.log('Push notifications already permitted'); + return true; + } + + if (Notification.permission === 'denied') { + console.warn('Push notifications have been denied by user'); + return false; + } + + try { + const permission = await Notification.requestPermission(); + return permission === 'granted'; + } catch (error) { + console.error('Error requesting notification permission:', error); + return false; + } + } + + /** + * Subscribe to push notifications + */ + async subscribe() { + if (!this.isSupported) { + console.error('Push notifications not supported'); + return false; + } + + if (!this.serviceWorkerRegistration) { + console.error('Service Worker not initialized'); + return false; + } + + if (!this.vapidKey) { + console.error('VAPID key not available'); + return false; + } + + try { + // Check if already subscribed + const existingSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + if (existingSubscription) { + console.log('Already subscribed to push notifications'); + return await this.saveSubscriptionToServer(existingSubscription); + } + + // Request permission if needed + const hasPermission = await this.requestPermission(); + if (!hasPermission) { + console.warn('User denied notification permission'); + return false; + } + + // Subscribe to push service + const subscription = await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.urlBase64ToUint8Array(this.vapidKey) + }); + + // Save subscription to server + return await this.saveSubscriptionToServer(subscription); + } catch (error) { + console.error('Failed to subscribe to push notifications:', error); + return false; + } + } + + /** + * Unsubscribe from push notifications + */ + async unsubscribe() { + if (!this.serviceWorkerRegistration) { + console.error('Service Worker not initialized'); + return false; + } + + try { + const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + if (!subscription) { + console.warn('No active push subscription'); + return false; + } + + // Remove subscription on server + const success = await this.removeSubscriptionFromServer(subscription); + + // Unsubscribe from push service + if (success) { + await subscription.unsubscribe(); + return true; + } + + return false; + } catch (error) { + console.error('Failed to unsubscribe from push notifications:', error); + return false; + } + } + + /** + * Check if user is subscribed to push notifications + */ + async isSubscribed() { + if (!this.serviceWorkerRegistration) { + return false; + } + + try { + const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + return subscription !== null; + } catch (error) { + console.error('Failed to check subscription status:', error); + return false; + } + } + + /** + * Save subscription to server + */ + async saveSubscriptionToServer(subscription) { + try { + const response = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscription: subscription.toJSON() + }) + }); + + if (response.ok) { + const data = await response.json(); + console.log('Subscription saved to server:', data.message); + return true; + } else { + const error = await response.json(); + console.error('Failed to save subscription:', error.error); + return false; + } + } catch (error) { + console.error('Error communicating with server:', error); + return false; + } + } + + /** + * Remove subscription from server + */ + async removeSubscriptionFromServer(subscription) { + try { + const response = await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: subscription.endpoint + }) + }); + + if (response.ok) { + console.log('Subscription removed from server'); + return true; + } else { + const error = await response.json(); + console.error('Failed to remove subscription:', error.error); + return false; + } + } catch (error) { + console.error('Error communicating with server:', error); + return false; + } + } + + /** + * Get all subscriptions for current user + */ + async getSubscriptions() { + try { + const response = await fetch('/api/push/subscriptions'); + if (response.ok) { + const data = await response.json(); + return data.subscriptions || []; + } + return []; + } catch (error) { + console.error('Error fetching subscriptions:', error); + return []; + } + } + + /** + * Send test notification (admin only) + */ + async sendTestNotification(targetUser = null) { + try { + const response = await fetch('/api/push/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + target_user: targetUser + }) + }); + + if (response.ok) { + const data = await response.json(); + console.log('Test notification sent:', data.message); + return true; + } else { + const error = await response.json(); + console.error('Failed to send test notification:', error.error); + return false; + } + } catch (error) { + console.error('Error sending test notification:', error); + return false; + } + } + + /** + * Convert VAPID key from base64 string to Uint8Array + * Required for subscribing to push service + */ + urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; + } +} + +// Create global instance +const pushNotificationManager = new PushNotificationManager(); + +/** + * Show notification subscription UI (typically in settings) + */ +function showPushNotificationSettings() { + const container = document.getElementById('push-notification-settings'); + if (!container) return; + + if (!pushNotificationManager.isSupported) { + container.innerHTML = 'Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.
'; + return; + } + + const html = ` +Erhalten Sie Benachrichtigungen für wichtige Ereignisse direkt auf Ihrem Gerät.
+ + + + + + +