feat: Implement Web Push notifications with VAPID

- Split notification sending into separate WebSocket and Web Push channels
- Add Web Push support for offline users
- Configure VAPID keys for push authentication
- Add 'aud' field to VAPID claims for WNS compatibility
- Optimize subscription sync - only POST when endpoint changes
- Remove debug logs and minimize server logging
- Update Service Worker with improved error handling
- Add Service-Worker-Allowed header for proper scope

Web Push now works independently from WebSocket, enabling
notifications when browser is closed (requires HTTPS in production)
This commit is contained in:
Игорь Надеин 2026-01-06 07:48:04 +03:00
parent e5503a1490
commit 44488cb40e
7 changed files with 202 additions and 64 deletions

View File

@ -120,7 +120,7 @@ def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
)
if settings.send_web:
NotificationService.send_web_notification(notification)
NotificationService.send_web_socket(notification)
notification.sent_web = True
notification.save(update_fields=['sent_web'])
except Exception as e:
@ -256,7 +256,7 @@ def notify_department_employees(sender, instance, action, pk_set, **kwargs):
)
if settings_obj.send_web:
NotificationService.send_web_notification(notification)
NotificationService.send_web_socket(notification)
notification.sent_web = True
notification.save(update_fields=['sent_web'])
except Exception as e:
@ -397,7 +397,7 @@ def notify_all_employees(document):
# Только веб
if settings.send_web:
NotificationService.send_web_notification(notification)
NotificationService.send_web_socket(notification)
notification.sent_web = True
notification.save(update_fields=['sent_web'])
except Exception as e:

View File

@ -450,13 +450,12 @@ TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot")
# Web Push Notifications (VAPID)
# Ключи для авторизации push-уведомлений
# Для генерации новых ключей: python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); v.save_key('private_key.pem'); v.save_public_key('public_key.pem')"
VAPID_PUBLIC_KEY = os.getenv(
"VAPID_PUBLIC_KEY",
"BG3WGJZH3bCPtZ1yRjXywfGsHoRT4MZ7npk25twmBireG_Di5-1FBn1ChblWCDicienEr07y4k7R7_A5IjbfjRA="
"BMTitZy9r4ygYJBgGdaZuCkb7rwR7iHLJv0DkNpaJOLESotONETWtZAQxnJVU_Yo9v-iYo-7dWEeF0VEjMcGMkQ"
)
VAPID_PRIVATE_KEY = os.getenv(
"VAPID_PRIVATE_KEY",
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIYJXs5gLYLtLpWO0cr6prv4jHJT28_hvG1BFyjcIMZKhRANCAARt1hiWR92wj7WdckY18sHxrB6EU-DGe56ZNubcJgYq3hvw4uftRQZ9QoW5Vgg4nInpxK9O8uJO0e_wOSI2340Q"
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCNnpupg3xbtFUiOSUZ6L7s6puxuEjzR73kTL7v8bMvKhRANCAATE4rWcva-MoGCQYBnWmbgpG-68Ee4hyyb9A5DaWiTixEqLTjRE1rWQEMZyVVP2KPb_omKPu3VhHhdFRIzHBjJE"
)
VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "admin@example.com")
VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "robotail-info@yandex.ru")

View File

@ -6,14 +6,21 @@ from django.http import FileResponse
import os
def serve_service_worker(request):
"""Обслуживает Service Worker с правильным content-type"""
"""Обслуживает Service Worker с правильным content-type и заголовками"""
sw_path = os.path.join(settings.BASE_DIR, 'sw.js')
try:
return FileResponse(
response = FileResponse(
open(sw_path, 'rb'),
content_type='application/javascript',
content_type='application/javascript; charset=utf-8',
status=200
)
# Отключаем кэширование для sw.js (всегда получаем свежую версию)
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
# Service-Worker-Allowed позволяет контролировать весь домен
response['Service-Worker-Allowed'] = '/'
return response
except Exception as e:
from django.http import HttpResponse
return HttpResponse(f"Service Worker not found: {e}", status=500)

View File

@ -480,10 +480,9 @@ def subscribe_push(request):
}
)
logger.info(
f"[WebPush] {'Created' if created else 'Updated'} subscription for user {request.user.id} "
f"(endpoint: {endpoint[:50]}...)"
)
# Логируем только создание новой подписки, не обновление
if created:
logger.info(f"[WebPush] New subscription for user {request.user.id}")
return Response({
'status': 'success',

View File

@ -161,13 +161,14 @@ class NotificationService:
f"{'✅' if settings.send_telegram else '❌'} Telegram"
)
# Отправить на веб (WebSocket)
# Отправить на веб (WebSocket) - для онлайн пользователей
if settings.send_web:
logger.info(
f"[NotificationService.send_notification] 🌐 Отправка через WebSocket..."
)
try:
NotificationService.send_web_notification(notification)
# Отправляем WebSocket только для онлайн уведомлений (не push)
NotificationService.send_web_socket(notification)
notification.sent_web = True
logger.info(
f"[NotificationService.send_notification] ✅ WebSocket: успешно отправлено"
@ -181,6 +182,29 @@ class NotificationService:
logger.info(
f"[NotificationService.send_notification] ⏭️ WebSocket: канал отключен"
)
# Отправить Web Push (для offline пользователей)
# ВАЖНО: отправляем независимо от send_web настройки,
# если есть активные push-подписки
if settings.send_web:
logger.info(
f"[NotificationService.send_notification] 📲 Отправка Web Push (offline)..."
)
try:
push_count = NotificationService.send_web_push_notification(notification)
if push_count > 0:
logger.info(
f"[NotificationService.send_notification] ✅ Web Push: отправлено на {push_count} устройств"
)
except Exception as e:
logger.error(
f"[NotificationService.send_notification] ❌ Web Push: ошибка - {e}",
exc_info=True
)
else:
logger.info(
f"[NotificationService.send_notification] ⏭️ Web Push: канал отключен"
)
# Отправить на email
if settings.send_email:
@ -312,10 +336,13 @@ class NotificationService:
)
@staticmethod
def send_web_notification(notification: Notification):
"""Отправить уведомление через WebSocket"""
def send_web_socket(notification: Notification):
"""
Отправить уведомление через WebSocket (только для онлайн пользователей).
Переименовано из send_web_notification для ясности.
"""
logger.info(
f"[NotificationService.send_web_notification] НАЧАЛО: "
f"[NotificationService.send_web_socket] НАЧАЛО: "
f"notification_id={notification.id}, "
f"recipient={notification.recipient.id}"
)
@ -337,7 +364,7 @@ class NotificationService:
group_name = f'notifications_{notification.recipient.id}'
logger.info(
f"[NotificationService.send_web_notification] Отправка в группу: {group_name}"
f"[NotificationService.send_web_socket] Отправка в группу: {group_name}"
)
async_to_sync(channel_layer.group_send)(
@ -349,7 +376,7 @@ class NotificationService:
)
logger.info(
f"[NotificationService.send_web_notification] ✅ Уведомление отправлено в WebSocket"
f"[NotificationService.send_web_socket] ✅ Уведомление отправлено в WebSocket"
)
# Обновить счетчик
@ -360,7 +387,7 @@ class NotificationService:
).count()
logger.info(
f"[NotificationService.send_web_notification] Обновление счетчика: {unread_count} непрочитанных"
f"[NotificationService.send_web_socket] Обновление счетчика: {unread_count} непрочитанных"
)
async_to_sync(channel_layer.group_send)(
@ -372,11 +399,8 @@ class NotificationService:
)
logger.info(
f"[NotificationService.send_web_notification] ✅ Счетчик обновлен"
f"[NotificationService.send_web_socket] ✅ Счетчик обновлен"
)
# Также отправляем Web Push для offline уведомлений
NotificationService.send_web_push_notification(notification)
@staticmethod
def send_web_push_notification(notification: Notification) -> int:
@ -450,10 +474,6 @@ class NotificationService:
)
return 0
vapid_claims = {
'sub': f'mailto:{vapid_email}'
}
success_count = 0
failed_subscriptions = []
@ -467,25 +487,29 @@ class NotificationService:
}
}
logger.debug(
f"[NotificationService.send_web_push_notification] "
f"Отправка на endpoint: {subscription.endpoint[:50]}..."
)
# Извлекаем origin из endpoint для поля 'aud' в VAPID claims
from urllib.parse import urlparse
parsed_url = urlparse(subscription.endpoint)
audience = f"{parsed_url.scheme}://{parsed_url.netloc}"
vapid_claims = {
'sub': f'mailto:{vapid_email}',
'aud': audience
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=vapid_private_key,
vapid_claims=vapid_claims
vapid_claims=vapid_claims,
ttl=86400 # 24 часа
)
success_count += 1
subscription.reset_errors()
except WebPushException as e:
logger.warning(
f"[NotificationService.send_web_push_notification] ⚠️ WebPush ошибка: {e}"
)
logger.warning(f"[WebPush] Error: {e}")
# Обрабатываем ошибки
subscription.error_count += 1
@ -494,26 +518,17 @@ class NotificationService:
# Если подписка устарела (410 Gone) или недействительна - деактивируем
if e.response and e.response.status_code in [404, 410]:
subscription.is_active = False
logger.info(
f"[NotificationService.send_web_push_notification] "
f"Подписка деактивирована: {subscription.id}"
)
logger.info(f"[WebPush] Subscription {subscription.id} deactivated (expired)")
# Если слишком много ошибок - деактивируем
if subscription.error_count >= 5:
subscription.is_active = False
logger.info(
f"[NotificationService.send_web_push_notification] "
f"Подписка деактивирована (много ошибок): {subscription.id}"
)
logger.info(f"[WebPush] Subscription {subscription.id} deactivated (too many errors)")
subscription.save(update_fields=['error_count', 'last_error', 'is_active', 'updated_at'])
except Exception as e:
logger.error(
f"[NotificationService.send_web_push_notification] ❌ Ошибка: {e}",
exc_info=True
)
logger.error(f"[WebPush] Unexpected error: {e}")
logger.info(
f"[NotificationService.send_web_push_notification] ✅ Завершено: "

View File

@ -74,17 +74,29 @@ class PushNotificationsManager {
* @private
*/
async _fetchVapidKey() {
console.log('[PushNotifications][DEBUG] 🔍 Запрос VAPID ключа с сервера...');
const response = await fetch('/api/v1/notifications/push/vapid-key/', {
credentials: 'include'
});
console.log('[PushNotifications][DEBUG] 📡 Ответ сервера:', response.status, response.statusText);
if (!response.ok) {
throw new Error('Не удалось получить VAPID ключ');
throw new Error(`Не удалось получить VAPID ключ: ${response.status}`);
}
const data = await response.json();
console.log('[PushNotifications][DEBUG] 📦 Данные от сервера:', data);
this.vapidPublicKey = data.vapid_public_key;
console.log('[PushNotifications] VAPID ключ получен');
if (!this.vapidPublicKey) {
throw new Error('VAPID ключ отсутствует в ответе сервера');
}
console.log('[PushNotifications][DEBUG] ✅ VAPID ключ получен, длина:', this.vapidPublicKey.length);
console.log('[PushNotifications][DEBUG] 🔑 VAPID ключ:', this.vapidPublicKey.substring(0, 20) + '...');
}
/**
@ -120,7 +132,39 @@ class PushNotificationsManager {
this.isSubscribed = subscription !== null;
if (this.isSubscribed) {
console.log('[PushNotifications] Активная подписка найдена');
// Проверяем, нужна ли синхронизация с сервером
const lastEndpoint = localStorage.getItem('push_endpoint');
const currentEndpoint = subscription.endpoint;
// Отправляем на сервер только если endpoint изменился или не сохранён
if (lastEndpoint !== currentEndpoint) {
try {
await this._sendSubscriptionToServer(subscription);
localStorage.setItem('push_endpoint', currentEndpoint);
} catch (error) {
console.warn('[PushNotifications] Ошибка синхронизации, переподписываемся:', error);
// Если ошибка - возможно VAPID ключи изменились
// Удаляем старую подписку и создаём новую
try {
await subscription.unsubscribe();
console.log('[PushNotifications] Старая подписка удалена');
// Создаём новую подписку с новыми VAPID ключами
const newSubscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey)
});
await this._sendSubscriptionToServer(newSubscription);
localStorage.setItem('push_endpoint', newSubscription.endpoint);
console.log('[PushNotifications] ✅ Автоматически переподписались');
this.isSubscribed = true;
} catch (resubscribeError) {
console.error('[PushNotifications] Ошибка переподписки:', resubscribeError);
this.isSubscribed = false;
}
}
}
}
@ -131,18 +175,33 @@ class PushNotificationsManager {
* @private
*/
_urlBase64ToUint8Array(base64String) {
console.log('[PushNotifications][DEBUG] 🔧 _urlBase64ToUint8Array вход:', {
length: base64String.length,
first20: base64String.substring(0, 20),
last20: base64String.substring(base64String.length - 20)
});
const padding = '='.repeat((4 - base64String.length % 4) % 4);
console.log('[PushNotifications][DEBUG] 🔧 Добавлено паддинга:', padding.length);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
console.log('[PushNotifications][DEBUG] 🔧 После замены символов, длина:', base64.length);
const rawData = window.atob(base64);
console.log('[PushNotifications][DEBUG] 🔧 После atob, длина:', rawData.length);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
console.log('[PushNotifications][DEBUG] 🔧 Uint8Array готов, длина:', outputArray.length);
console.log('[PushNotifications][DEBUG] 🔧 Первые 10 байт:', Array.from(outputArray.slice(0, 10)));
return outputArray;
}
@ -165,41 +224,94 @@ class PushNotificationsManager {
* @returns {Promise<boolean>} Успешность подписки
*/
async subscribe() {
console.log('[PushNotifications][DEBUG] 🚀 Начало процесса подписки');
if (!this.isSupported || !this.swRegistration || !this.vapidPublicKey) {
console.error('[PushNotifications] Менеджер не инициализирован');
console.error('[PushNotifications][DEBUG] ❌ Менеджер не инициализирован:', {
isSupported: this.isSupported,
swRegistration: !!this.swRegistration,
vapidPublicKey: !!this.vapidPublicKey
});
return false;
}
// Проверяем разрешение
console.log('[PushNotifications][DEBUG] 🔐 Проверка разрешений, текущее:', Notification.permission);
if (Notification.permission === 'denied') {
console.warn('[PushNotifications] Уведомления заблокированы пользователем');
console.warn('[PushNotifications][DEBUG] ❌ Уведомления заблокированы пользователем');
return false;
}
if (Notification.permission === 'default') {
console.log('[PushNotifications][DEBUG] ❓ Запрос разрешения у пользователя...');
const permission = await this.requestPermission();
if (permission !== 'granted') {
console.log('[PushNotifications][DEBUG] ❌ Разрешение не получено:', permission);
return false;
}
}
try {
// Удаляем старую подписку, если есть (на случай смены VAPID ключей)
const existingSubscription = await this.swRegistration.pushManager.getSubscription();
console.log('[PushNotifications][DEBUG] 🔍 Существующая подписка:', existingSubscription ? 'найдена' : 'отсутствует');
if (existingSubscription) {
console.log('[PushNotifications][DEBUG] 🗑️ Удаляем старую подписку:', existingSubscription.endpoint.substring(0, 50) + '...');
await existingSubscription.unsubscribe();
console.log('[PushNotifications][DEBUG] ✅ Старая подписка удалена');
}
// Конвертируем VAPID ключ
console.log('[PushNotifications][DEBUG] 🔄 Конвертация VAPID ключа в Uint8Array...');
const applicationServerKey = this._urlBase64ToUint8Array(this.vapidPublicKey);
console.log('[PushNotifications][DEBUG] ✅ Uint8Array создан, длина:', applicationServerKey.length);
console.log('[PushNotifications][DEBUG] 🔢 Первые байты:', Array.from(applicationServerKey.slice(0, 5)));
// Создаем push-подписку
const subscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey)
console.log('[PushNotifications][DEBUG] 📝 Создание новой подписки...');
console.log('[PushNotifications][DEBUG] 🌐 Push Manager:', this.swRegistration.pushManager);
// Проверяем поддерживаемые функции
const pushManager = this.swRegistration.pushManager;
console.log('[PushNotifications][DEBUG] 🔍 Push Manager возможности:', {
hasGetSubscription: typeof pushManager.getSubscription === 'function',
hasSubscribe: typeof pushManager.subscribe === 'function',
hasPermissionState: typeof pushManager.permissionState === 'function'
});
console.log('[PushNotifications] Push-подписка создана:', subscription.endpoint);
const subscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('[PushNotifications][DEBUG] ✅ Push-подписка создана успешно!');
console.log('[PushNotifications][DEBUG] 🌐 Endpoint:', subscription.endpoint.substring(0, 70) + '...');
console.log('[PushNotifications][DEBUG] 🔑 Keys:', Object.keys(subscription.toJSON().keys));
// Отправляем подписку на сервер
console.log('[PushNotifications][DEBUG] 📤 Отправка подписки на сервер...');
await this._sendSubscriptionToServer(subscription);
this.isSubscribed = true;
console.log('[PushNotifications][DEBUG] 🎉 Подписка завершена успешно!');
return true;
} catch (error) {
console.error('[PushNotifications] Ошибка подписки:', error);
console.error('[PushNotifications][DEBUG] ❌ ОШИБКА ПОДПИСКИ:', error);
console.error('[PushNotifications][DEBUG] 📋 Тип ошибки:', error.name);
console.error('[PushNotifications][DEBUG] 💬 Сообщение:', error.message);
console.error('[PushNotifications][DEBUG] 📚 Stack:', error.stack);
// Дополнительная диагностика
if (error.name === 'AbortError') {
console.error('[PushNotifications][DEBUG] 🔍 AbortError обычно означает:');
console.error('[PushNotifications][DEBUG] 1. Невалидный VAPID ключ');
console.error('[PushNotifications][DEBUG] 2. Проблема с push-сервисом (FCM/Mozilla)');
console.error('[PushNotifications][DEBUG] 3. Неправильный формат applicationServerKey');
}
return false;
}
}

View File

@ -12,7 +12,6 @@ const SW_VERSION = '1.0.0';
// Настройки по умолчанию для уведомлений
const DEFAULT_NOTIFICATION_OPTIONS = {
icon: '/static/img/logo-192.png',
badge: '/static/img/badge-72.png',
vibrate: [100, 50, 100],
requireInteraction: false,
renotify: true,
@ -55,7 +54,8 @@ self.addEventListener('activate', (event) => {
* }
*/
self.addEventListener('push', (event) => {
console.log('[SW] Push received:', event);
console.log('[SW] 🔔 Push received:', event);
console.log('[SW] 🔔 Has data:', !!event.data);
let notificationData = {
title: 'Новое уведомление',
@ -67,7 +67,7 @@ self.addEventListener('push', (event) => {
if (event.data) {
try {
const payload = event.data.json();
console.log('[SW] Push payload:', payload);
console.log('[SW] 🔔 Push payload:', payload);
notificationData = {
...notificationData,
@ -97,6 +97,8 @@ self.addEventListener('push', (event) => {
}
}
console.log('[SW] 🔔 Showing notification:', notificationData.title);
// Показываем уведомление
const promiseChain = self.registration.showNotification(
notificationData.title,
@ -112,7 +114,11 @@ self.addEventListener('push', (event) => {
image: notificationData.image,
actions: notificationData.actions,
}
);
).then(() => {
console.log('[SW] ✅ Notification shown successfully:', notificationData.title);
}).catch((error) => {
console.error('[SW] ❌ Failed to show notification:', error);
});
event.waitUntil(promiseChain);
});