nordabiz/blueprints/push/push_service.py
Maciej Pienczyn 6c4db17807
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(push): Web Push (VAPID + pywebpush) dla prywatnych wiadomości
Pierwsza iteracja — trigger to nowa wiadomość prywatna. Rollout
fazowany przez PUSH_USER_WHITELIST w .env: pusta = wszyscy, lista
user_id = tylko wymienieni. Ta sama flaga kontroluje widoczność
dzwonka w navbarze (context_processor inject_push_visibility).

Co jest:
- database/migrations/100 — push_subscriptions + notify_push_messages
- database.py — PushSubscription model + relacja na User
- blueprints/push/ — vapid-public-key, subscribe, unsubscribe, test,
  pending-url (iOS PWA), CSRF exempt, auto-prune martwych (410/404/403)
- static/sw.js — push + notificationclick (z iOS fallback przez
  /push/pending-url w Redis, TTL 5 min)
- static/js/push-client.js — togglePush, iOS detection, ?pushdiag=1
- base.html — dzwonek + wpięcie skryptu gated przez push_bell_visible
- message_routes.py — _send_message_push_notifications po emailach
- requirements.txt — pywebpush==2.0.3

Kill switch: PUSH_KILL_SWITCH=1 zatrzymuje wszystkie wysyłki.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:49 +02:00

185 lines
5.7 KiB
Python

"""Web Push service — send_push() oraz obsługa whitelist / kill switch.
send_push() nigdy nie rzuca w górę — wywołujące (trigger wiadomości) nie może
awaritu z powodu błędu pusha. Wszystkie błędy logowane, martwe subskrypcje
automatycznie kasowane (410/404/403).
"""
import base64
import json
import logging
import os
from datetime import datetime
from database import PushSubscription, SessionLocal
logger = logging.getLogger(__name__)
def _vapid_claims_email():
return 'mailto:' + os.getenv('VAPID_CONTACT_EMAIL', 'noreply@nordabiznes.pl')
def _vapid_private_key():
"""Return VAPID private key jako PEM (preferowane przez pywebpush) albo base64url raw scalar."""
raw = os.getenv('VAPID_PRIVATE_KEY', '').strip()
return raw or None
def _vapid_public_key():
return os.getenv('VAPID_PUBLIC_KEY', '').strip() or None
def _kill_switch_enabled():
return os.getenv('PUSH_KILL_SWITCH', '').strip() in ('1', 'true', 'True', 'yes')
def _parse_whitelist():
raw = os.getenv('PUSH_USER_WHITELIST', '').strip()
if not raw:
return set()
return {int(x) for x in raw.split(',') if x.strip().isdigit()}
def _user_allowed(user_id: int) -> bool:
whitelist = _parse_whitelist()
if not whitelist:
return True
return user_id in whitelist
def send_push(user_id: int, title: str, body: str,
url: str = '/',
icon: str = '/static/img/favicon-192.png',
tag: str | None = None,
data: dict | None = None) -> dict:
"""Wyślij Web Push do wszystkich subskrypcji usera.
Zwraca dict {'sent': int, 'failed': int, 'pruned': int}.
Nigdy nie rzuca wyjątku.
"""
stats = {'sent': 0, 'failed': 0, 'pruned': 0}
if _kill_switch_enabled():
logger.info("push kill switch ON, skipping user=%s", user_id)
return stats
if not _user_allowed(user_id):
return stats # cichy opt-out — faza B: whitelist blokuje resztę userów
private_key = _vapid_private_key()
if not private_key:
logger.warning("VAPID_PRIVATE_KEY not configured, skipping push")
return stats
try:
from pywebpush import webpush, WebPushException
except ImportError:
logger.warning("pywebpush not installed, skipping push")
return stats
payload = {
'title': title,
'body': body,
'url': url,
'icon': icon,
}
if tag:
payload['tag'] = tag
if data:
payload['data'] = data
db = SessionLocal()
try:
subs = db.query(PushSubscription).filter_by(user_id=user_id).all()
if not subs:
return stats
to_prune = []
for sub in subs:
try:
webpush(
subscription_info={
'endpoint': sub.endpoint,
'keys': {'p256dh': sub.p256dh, 'auth': sub.auth},
},
data=json.dumps(payload, ensure_ascii=False),
vapid_private_key=private_key,
vapid_claims={'sub': _vapid_claims_email()},
timeout=10,
)
sub.last_used_at = datetime.now()
stats['sent'] += 1
except WebPushException as e:
status = getattr(e.response, 'status_code', None) if getattr(e, 'response', None) else None
msg = str(e)
if status in (404, 410) or 'InvalidRegistration' in msg or 'expired' in msg.lower():
to_prune.append(sub)
stats['pruned'] += 1
elif status == 403:
logger.warning("push VAPID rejected (403) sub=%s, pruning", sub.id)
to_prune.append(sub)
stats['pruned'] += 1
elif status == 413:
logger.warning("push payload too large sub=%s", sub.id)
stats['failed'] += 1
else:
logger.warning("push transient error sub=%s status=%s err=%s", sub.id, status, msg[:200])
stats['failed'] += 1
except Exception as e:
logger.warning("push unexpected error sub=%s err=%s", sub.id, str(e)[:200])
stats['failed'] += 1
for sub in to_prune:
try:
db.delete(sub)
except Exception:
pass
db.commit()
except Exception as e:
logger.error("push service db error: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
if stats['sent'] or stats['failed'] or stats['pruned']:
logger.info("push sent user=%s sent=%d failed=%d pruned=%d",
user_id, stats['sent'], stats['failed'], stats['pruned'])
return stats
# === iOS pending URL helper (Redis-backed, TTL 5 min) ===
def set_pending_url(user_id: int, url: str) -> bool:
try:
from redis_service import get_redis, is_available
if not is_available():
return False
r = get_redis()
r.setex(f'push:pending_url:{user_id}', 300, url)
return True
except Exception as e:
logger.debug("pending_url set failed: %s", e)
return False
def pop_pending_url(user_id: int) -> str | None:
try:
from redis_service import get_redis, is_available
if not is_available():
return None
r = get_redis()
key = f'push:pending_url:{user_id}'
val = r.get(key)
if val:
r.delete(key)
return val.decode() if isinstance(val, (bytes, bytearray)) else val
return None
except Exception as e:
logger.debug("pending_url pop failed: %s", e)
return None