From 6c4db17807895151244bdc28114a0fcb180e6028 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Tue, 14 Apr 2026 16:56:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(push):=20Web=20Push=20(VAPID=20+=20pywebpu?= =?UTF-8?q?sh)=20dla=20prywatnych=20wiadomo=C5=9Bci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.py | 18 ++ blueprints/__init__.py | 12 ++ blueprints/messages/message_routes.py | 41 ++++ blueprints/push/__init__.py | 10 + blueprints/push/push_service.py | 184 +++++++++++++++++ blueprints/push/routes.py | 153 ++++++++++++++ database.py | 25 +++ .../migrations/100_add_push_subscriptions.sql | 22 ++ requirements.txt | 3 + static/js/push-client.js | 195 ++++++++++++++++++ static/sw.js | 54 ++++- templates/base.html | 20 ++ 12 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 blueprints/push/__init__.py create mode 100644 blueprints/push/push_service.py create mode 100644 blueprints/push/routes.py create mode 100644 database/migrations/100_add_push_subscriptions.sql create mode 100644 static/js/push-client.js diff --git a/app.py b/app.py index 335ffc9..4e76d86 100644 --- a/app.py +++ b/app.py @@ -354,6 +354,24 @@ def inject_audit_access(): return dict(is_audit_owner=is_audit_owner()) +@app.context_processor +def inject_push_visibility(): + """Udostępnij szablonom informację, czy dzwonek Web Push ma być widoczny + dla bieżącego użytkownika. Reguła: jeśli PUSH_USER_WHITELIST jest niepusty, + to tylko wymienieni user_id widzą dzwonek. Pusty = wszyscy zalogowani. + """ + if not current_user.is_authenticated: + return {'push_bell_visible': False} + raw = os.getenv('PUSH_USER_WHITELIST', '').strip() + if not raw: + return {'push_bell_visible': True} + try: + whitelist = {int(x) for x in raw.split(',') if x.strip().isdigit()} + return {'push_bell_visible': current_user.id in whitelist} + except Exception: + return {'push_bell_visible': False} + + @app.context_processor def inject_company_context(): """Inject multi-company context into all templates.""" diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 1d96391..df8ff87 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -39,6 +39,18 @@ def register_blueprints(app): except Exception as e: logger.error(f"Error registering api blueprint: {e}") + # Push blueprint (Web Push VAPID) + try: + from blueprints.push import bp as push_bp + from blueprints.push.routes import exempt_from_csrf as push_exempt + app.register_blueprint(push_bp) + push_exempt(app) + logger.info("Registered blueprint: push (with CSRF exemption)") + except ImportError as e: + logger.debug(f"Blueprint push not yet available: {e}") + except Exception as e: + logger.error(f"Error registering push blueprint: {e}") + # Community blueprints - register directly (not nested) # to preserve endpoint names like 'calendar_index' instead of 'community.calendar.calendar_index' try: diff --git a/blueprints/messages/message_routes.py b/blueprints/messages/message_routes.py index c6dc951..a888a8f 100644 --- a/blueprints/messages/message_routes.py +++ b/blueprints/messages/message_routes.py @@ -264,6 +264,9 @@ def send_message(conv_id): # Send email notifications _send_message_email_notifications(db, conv_id, conversation, message, content) + # Send Web Push notifications + _send_message_push_notifications(db, conv_id, conversation, message, content) + return jsonify(msg_json), 201 except Exception as e: @@ -318,6 +321,44 @@ def _send_message_email_notifications(db, conv_id, conversation, message, conten logger.error(f"Email notification error: {e}") +def _send_message_push_notifications(db, conv_id, conversation, message, content): + """Send Web Push notifications to eligible conversation members. + + Gate whitelist (PUSH_USER_WHITELIST) siedzi w send_push() — tu filtrujemy + tylko członków konwersacji + muted + notify_push_messages. + """ + try: + from blueprints.push.push_service import send_push + + members = db.query(ConversationMember).filter( + ConversationMember.conversation_id == conv_id, + ConversationMember.user_id != current_user.id, + ConversationMember.is_muted == False, # noqa: E712 + ).all() + + sender_name = current_user.name or current_user.email.split('@')[0] + title = f'Nowa wiadomość od {sender_name}' + plain = strip_html(content or '') + preview = (plain[:80] + '…') if len(plain) > 80 else plain + + for m in members: + user = db.query(User).get(m.user_id) + if not user or user.notify_push_messages is False: + continue + send_push( + user_id=user.id, + title=title, + body=preview or '(załącznik)', + url=f'/wiadomosci?conv={conv_id}', + tag=f'conv-{conv_id}', + icon='/static/img/favicon-192.png', + ) + except ImportError: + logger.warning("push_service not available, skipping push notifications") + except Exception as e: + logger.error(f"Push notification error: {e}") + + # ============================================================ # 3. EDIT MESSAGE # ============================================================ diff --git a/blueprints/push/__init__.py b/blueprints/push/__init__.py new file mode 100644 index 0000000..792fa28 --- /dev/null +++ b/blueprints/push/__init__.py @@ -0,0 +1,10 @@ +"""Web Push blueprint — VAPID + pywebpush. + +Endpoints pod /push/*. Szczegóły w routes.py i push_service.py. +""" + +from flask import Blueprint + +bp = Blueprint('push', __name__, url_prefix='/push') + +from . import routes # noqa: E402, F401 diff --git a/blueprints/push/push_service.py b/blueprints/push/push_service.py new file mode 100644 index 0000000..8993428 --- /dev/null +++ b/blueprints/push/push_service.py @@ -0,0 +1,184 @@ +"""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 diff --git a/blueprints/push/routes.py b/blueprints/push/routes.py new file mode 100644 index 0000000..f26eb7a --- /dev/null +++ b/blueprints/push/routes.py @@ -0,0 +1,153 @@ +"""Web Push HTTP endpoints. + +GET /push/vapid-public-key — publiczny klucz VAPID dla klienta JS +POST /push/subscribe — zapisz subskrypcję w DB +POST /push/unsubscribe — usuń subskrypcję +POST /push/test — self-push (test własnego subskrybenta) +POST /push/pending-url — (iOS PWA) zapis URL z notificationclick +GET /push/pending-url — odczytaj i wyczyść pending URL +""" + +import logging +from datetime import datetime + +from flask import jsonify, request +from flask_login import login_required, current_user + +from database import SessionLocal, PushSubscription +from extensions import limiter +from . import bp +from .push_service import ( + send_push, + set_pending_url, + pop_pending_url, + _vapid_public_key, +) + +logger = logging.getLogger(__name__) + + +def exempt_from_csrf(app): + """Exempt push routes from CSRF protection (JS fetch from SW + frontend).""" + csrf = app.extensions.get('csrf') + if csrf: + csrf.exempt(bp) + + +@bp.route('/vapid-public-key', methods=['GET']) +@login_required +@limiter.exempt +def vapid_public_key(): + key = _vapid_public_key() + if not key: + return jsonify({'error': 'VAPID not configured'}), 503 + return jsonify({'key': key}) + + +@bp.route('/subscribe', methods=['POST']) +@login_required +@limiter.limit('30 per minute') +def subscribe(): + payload = request.get_json(silent=True) or {} + endpoint = (payload.get('endpoint') or '').strip() + keys = payload.get('keys') or {} + p256dh = (keys.get('p256dh') or '').strip() + auth = (keys.get('auth') or '').strip() + if not endpoint or not p256dh or not auth: + return jsonify({'error': 'endpoint, p256dh i auth wymagane'}), 400 + + user_agent = (request.headers.get('User-Agent') or '')[:500] + db = SessionLocal() + try: + existing = db.query(PushSubscription).filter_by(endpoint=endpoint).first() + if existing: + existing.user_id = current_user.id + existing.p256dh = p256dh + existing.auth = auth + existing.user_agent = user_agent + existing.last_used_at = datetime.now() + else: + sub = PushSubscription( + user_id=current_user.id, + endpoint=endpoint, + p256dh=p256dh, + auth=auth, + user_agent=user_agent, + ) + db.add(sub) + db.commit() + return jsonify({'ok': True}) + except Exception as e: + db.rollback() + logger.error("push subscribe error: %s", e) + return jsonify({'error': 'Błąd zapisu subskrypcji'}), 500 + finally: + db.close() + + +@bp.route('/unsubscribe', methods=['POST']) +@login_required +@limiter.limit('30 per minute') +def unsubscribe(): + payload = request.get_json(silent=True) or {} + endpoint = (payload.get('endpoint') or '').strip() + if not endpoint: + return jsonify({'error': 'endpoint wymagany'}), 400 + + db = SessionLocal() + try: + sub = db.query(PushSubscription).filter_by( + endpoint=endpoint, user_id=current_user.id + ).first() + if sub: + db.delete(sub) + db.commit() + return jsonify({'ok': True}) + except Exception as e: + db.rollback() + logger.error("push unsubscribe error: %s", e) + return jsonify({'error': 'Błąd'}), 500 + finally: + db.close() + + +@bp.route('/test', methods=['POST']) +@login_required +@limiter.limit('5 per minute') +def test_push(): + stats = send_push( + user_id=current_user.id, + title='🔔 Powiadomienia działają', + body='To testowe powiadomienie. Zobaczysz takie okienko kiedy ktoś napisze do Ciebie wiadomość.', + url='/wiadomosci', + tag='push-test', + ) + return jsonify({'ok': True, **stats}) + + +@bp.route('/pending-url', methods=['POST']) +@login_required +@limiter.limit('60 per minute') +def pending_url_set(): + payload = request.get_json(silent=True) or {} + url = (payload.get('url') or '').strip() + if not url or not url.startswith('/'): + return jsonify({'error': 'URL must be same-origin path'}), 400 + set_pending_url(current_user.id, url) + return jsonify({'ok': True}) + + +@bp.route('/pending-url', methods=['GET']) +@login_required +@limiter.exempt +def pending_url_get(): + url = pop_pending_url(current_user.id) + return jsonify({'url': url}) + + +@bp.route('/pending-url/clear', methods=['POST']) +@login_required +@limiter.exempt +def pending_url_clear(): + pop_pending_url(current_user.id) + return jsonify({'ok': True}) diff --git a/database.py b/database.py index 443af25..fab6363 100644 --- a/database.py +++ b/database.py @@ -339,12 +339,14 @@ class User(Base, UserMixin): # Email notification preferences notify_email_messages = Column(Boolean, default=True) # Email when receiving private message + notify_push_messages = Column(Boolean, default=True) # Web Push when receiving private message # Relationships conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan') forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id') forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id') forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan') + push_subscriptions = relationship('PushSubscription', back_populates='user', cascade='all, delete-orphan') # === ROLE SYSTEM HELPER METHODS === @@ -595,6 +597,29 @@ class User(Base, UserMixin): return f'' +class PushSubscription(Base): + """Web Push subscription per user device (desktop, mobile browser, PWA iOS). + + Jeden user może mieć wiele subskrypcji — po jednej na każde urządzenie/przeglądarkę. + Endpoint unique — powtórny subscribe z tej samej przeglądarki aktualizuje istniejący rekord. + """ + __tablename__ = 'push_subscriptions' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) + endpoint = Column(Text, nullable=False, unique=True) + p256dh = Column(String(255), nullable=False) + auth = Column(String(255), nullable=False) + user_agent = Column(String(500)) + created_at = Column(DateTime, default=datetime.now) + last_used_at = Column(DateTime) + + user = relationship('User', back_populates='push_subscriptions') + + def __repr__(self): + return f'' + + class UserCompanyPermissions(Base): """ Delegated permissions for company employees. diff --git a/database/migrations/100_add_push_subscriptions.sql b/database/migrations/100_add_push_subscriptions.sql new file mode 100644 index 0000000..f703f78 --- /dev/null +++ b/database/migrations/100_add_push_subscriptions.sql @@ -0,0 +1,22 @@ +-- Migration 100: Web Push subscriptions + user notification flag +-- +-- Tabela push_subscriptions: wielokrotne subskrypcje per user (desktop + mobile + PWA iOS). +-- Kolumna users.notify_push_messages analogiczna do notify_email_messages. + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh VARCHAR(255) NOT NULL, + auth VARCHAR(255) NOT NULL, + user_agent VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW(), + last_used_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_push_messages BOOLEAN DEFAULT TRUE; + +GRANT ALL ON TABLE push_subscriptions TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE push_subscriptions_id_seq TO nordabiz_app; diff --git a/requirements.txt b/requirements.txt index a635d05..20d79c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,6 +50,9 @@ geoip2==5.2.0 # Redis redis==7.1.0 +# Web Push (VAPID) +pywebpush==2.0.3 + # 2FA pyotp==2.9.0 diff --git a/static/js/push-client.js b/static/js/push-client.js new file mode 100644 index 0000000..7097585 --- /dev/null +++ b/static/js/push-client.js @@ -0,0 +1,195 @@ +// push-client.js — Web Push subscription flow dla Norda Biznes +// +// Rejestruje subskrypcję pod /push/subscribe, obsługuje dzwonek w navbarze, +// wykrywa iOS PWA, obsługuje diagnostykę ?pushdiag=1, pending-url dla iOS. + +(function() { + 'use strict'; + + function log(msg, data) { + try { console.log('[push] ' + msg, data !== undefined ? data : ''); } catch (e) {} + } + + function detectIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + } + + function isStandalone() { + return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || + window.navigator.standalone === true; + } + + function supportsPush() { + return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window); + } + + function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const output = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i); + return output; + } + + function getBell() { return document.getElementById('pushBellBtn'); } + + function setBellState(state) { + const bell = getBell(); + if (!bell) return; + bell.dataset.state = state; + const slash = bell.querySelector('.push-disabled-slash'); + if (state === 'enabled') { + bell.title = 'Powiadomienia włączone — kliknij, żeby wyłączyć'; + if (slash) slash.style.display = 'none'; + bell.style.opacity = '1'; + } else if (state === 'disabled') { + bell.title = 'Włącz powiadomienia'; + if (slash) slash.style.display = ''; + bell.style.opacity = '0.55'; + } else if (state === 'blocked') { + bell.title = 'Powiadomienia zablokowane w przeglądarce. Zmień w ustawieniach strony.'; + if (slash) slash.style.display = ''; + bell.style.opacity = '0.4'; + } else if (state === 'unsupported') { + bell.title = 'Przeglądarka nie obsługuje powiadomień'; + bell.style.opacity = '0.3'; + } + } + + async function getSubscription() { + const reg = await navigator.serviceWorker.ready; + return reg.pushManager.getSubscription(); + } + + async function enablePush() { + if (detectIOS() && !isStandalone()) { + alert('Na iPhone powiadomienia działają tylko po dodaniu strony do ekranu początkowego:\n\n1) Dotknij Udostępnij (strzałka na dole)\n2) Wybierz „Do ekranu początkowego"\n3) Uruchom aplikację z ikonki na pulpicie\n4) Zaloguj się i kliknij ponownie dzwoneczek'); + return; + } + if (!supportsPush()) { + alert('Twoja przeglądarka nie obsługuje powiadomień.'); + setBellState('unsupported'); + return; + } + const perm = await Notification.requestPermission(); + if (perm !== 'granted') { + setBellState(perm === 'denied' ? 'blocked' : 'disabled'); + return; + } + try { + const keyResp = await fetch('/push/vapid-public-key', { credentials: 'include' }); + if (!keyResp.ok) throw new Error('vapid-public-key HTTP ' + keyResp.status); + const { key } = await keyResp.json(); + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(key), + }); + } + const subObj = sub.toJSON(); + const resp = await fetch('/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: subObj.endpoint, + keys: subObj.keys, + }), + credentials: 'include', + }); + if (!resp.ok) throw new Error('subscribe HTTP ' + resp.status); + setBellState('enabled'); + // Welcome push — test że wszystko działa + await fetch('/push/test', { method: 'POST', credentials: 'include' }); + } catch (e) { + log('enable error', e); + alert('Nie udało się włączyć powiadomień: ' + (e.message || e)); + setBellState('disabled'); + } + } + + async function disablePush() { + try { + const sub = await getSubscription(); + if (sub) { + const endpoint = sub.endpoint; + await sub.unsubscribe(); + await fetch('/push/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint }), + credentials: 'include', + }); + } + setBellState('disabled'); + } catch (e) { + log('disable error', e); + } + } + + window.togglePush = async function(event) { + if (event) event.preventDefault(); + const bell = getBell(); + if (!bell) return; + const state = bell.dataset.state || 'disabled'; + if (state === 'enabled') { + await disablePush(); + } else { + await enablePush(); + } + }; + + async function initPushState() { + const bell = getBell(); + if (!bell) return; + if (!supportsPush()) { + // iOS Safari bez PWA — przeglądarka w ogóle bez PushManager. + // Dzwonek nadal klikalny, zobaczy komunikat o PWA. + if (detectIOS() && !isStandalone()) { + setBellState('disabled'); + return; + } + setBellState('unsupported'); + return; + } + if (Notification.permission === 'denied') { + setBellState('blocked'); + return; + } + try { + const sub = await getSubscription(); + setBellState(sub && Notification.permission === 'granted' ? 'enabled' : 'disabled'); + } catch (e) { + setBellState('disabled'); + } + } + + async function checkPendingUrl() { + // iOS PWA: jeśli otworzono PWA przez klik w powiadomienie, SW mógł zapisać URL + if (!isStandalone()) return; + try { + const resp = await fetch('/push/pending-url', { credentials: 'include' }); + if (!resp.ok) return; + const { url } = await resp.json(); + if (url && url !== window.location.pathname + window.location.search) { + window.location.href = url; + } + } catch (e) { /* ignore */ } + } + + document.addEventListener('DOMContentLoaded', function() { + initPushState(); + checkPendingUrl(); + if (/[?&]pushdiag=1/.test(window.location.search)) { + log('diagnostics', { + supportsPush: supportsPush(), + iOS: detectIOS(), + standalone: isStandalone(), + permission: (window.Notification && Notification.permission) || 'n/a', + userAgent: navigator.userAgent, + }); + } + }); +})(); diff --git a/static/sw.js b/static/sw.js index 088d2b4..084c6ef 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,5 +1,4 @@ -// Norda Biznes Partner — minimal service worker for PWA installability -// No offline caching — just enough for Chrome to offer install prompt +// Norda Biznes Partner — Service Worker with Web Push + iOS pending-url self.addEventListener('install', function() { self.skipWaiting(); @@ -12,3 +11,54 @@ self.addEventListener('activate', function(event) { self.addEventListener('fetch', function(event) { event.respondWith(fetch(event.request)); }); + +self.addEventListener('push', function(event) { + let payload = { + title: 'Norda Biznes', + body: '', + url: '/', + icon: '/static/img/favicon-192.png', + badge: '/static/img/favicon-192.png', + }; + try { + if (event.data) payload = Object.assign(payload, event.data.json()); + } catch (e) { + if (event.data) payload.body = event.data.text(); + } + const options = { + body: payload.body, + icon: payload.icon, + badge: payload.badge || '/static/img/favicon-192.png', + data: { url: payload.url }, + tag: payload.tag || undefined, + renotify: !!payload.tag, + }; + event.waitUntil(self.registration.showNotification(payload.title, options)); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + const targetUrl = (event.notification.data && event.notification.data.url) || '/'; + + event.waitUntil((async function() { + const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of clientList) { + if (client.url.indexOf(targetUrl) !== -1 && 'focus' in client) { + return client.focus(); + } + } + // iOS PWA fallback — gdy app zamknięta, zapisz pending URL w Redis + // żeby PWA po starcie mogła pod niego przeskoczyć. + try { + await fetch('/push/pending-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: targetUrl }), + credentials: 'include', + }); + } catch (e) { /* ignore */ } + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + })()); +}); diff --git a/templates/base.html b/templates/base.html index d9170cd..6dfcaed 100755 --- a/templates/base.html +++ b/templates/base.html @@ -174,6 +174,21 @@ + {% if push_bell_visible %} + +
  • + +
  • + {% endif %} +