feat(push): Web Push (VAPID + pywebpush) dla prywatnych wiadomości
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
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
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>
This commit is contained in:
parent
5583c624cd
commit
6c4db17807
18
app.py
18
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."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
# ============================================================
|
||||
|
||||
10
blueprints/push/__init__.py
Normal file
10
blueprints/push/__init__.py
Normal file
@ -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
|
||||
184
blueprints/push/push_service.py
Normal file
184
blueprints/push/push_service.py
Normal file
@ -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
|
||||
153
blueprints/push/routes.py
Normal file
153
blueprints/push/routes.py
Normal file
@ -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})
|
||||
25
database.py
25
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'<User {self.email} role={self.role}>'
|
||||
|
||||
|
||||
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'<PushSubscription user={self.user_id} id={self.id}>'
|
||||
|
||||
|
||||
class UserCompanyPermissions(Base):
|
||||
"""
|
||||
Delegated permissions for company employees.
|
||||
|
||||
22
database/migrations/100_add_push_subscriptions.sql
Normal file
22
database/migrations/100_add_push_subscriptions.sql
Normal file
@ -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;
|
||||
@ -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
|
||||
|
||||
|
||||
195
static/js/push-client.js
Normal file
195
static/js/push-client.js
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
54
static/sw.js
54
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);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
@ -174,6 +174,21 @@
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% if push_bell_visible %}
|
||||
<!-- Web Push toggle -->
|
||||
<li class="push-toggle">
|
||||
<button id="pushBellBtn" class="nav-link-with-badge" onclick="togglePush(event)"
|
||||
aria-label="Powiadomienia push" title="Włącz powiadomienia push" data-state="disabled"
|
||||
style="opacity:0.55">
|
||||
<svg class="notifications-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="overflow:visible">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 5a2 2 0 114 0v.341C16.67 6.165 18 8.388 18 11v3.159c0 .538.214 1.055.595 1.436L20 17H4l1.405-1.405A2.032 2.032 0 006 14.159V11c0-2.612 1.33-4.835 4-5.659zM9 17v1a3 3 0 006 0v-1"/>
|
||||
<line class="push-disabled-slash" x1="4" y1="20" x2="20" y2="4" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- User Menu -->
|
||||
<li class="user-dropdown">
|
||||
<button class="user-trigger" onclick="toggleUserMenu(event)">
|
||||
@ -1163,6 +1178,11 @@
|
||||
<!-- Scroll Animations (Sprint 4) -->
|
||||
<script src="{{ url_for('static', filename='js/scroll-animations.js') }}" defer></script>
|
||||
|
||||
{% if push_bell_visible %}
|
||||
<!-- Web Push client -->
|
||||
<script src="{{ url_for('static', filename='js/push-client.js') }}" defer></script>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
{% if is_staging %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user