mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ; le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls. Mêmes clés en base que le Go. Côté Go : facade_push.go remet les routes du tunnel à la façade, et sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi. Parité : parity_push_test.go tire du Go les durées et onze appels de route avec la liste des abonnements en base, rejoués par tests/push.rs ; le chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi de bout en bout est déchiffré côté navigateur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
207 lines
7.3 KiB
Go
207 lines
7.3 KiB
Go
// push.go — notifications Web Push : quand un tour utilisateur se termine
|
|
// (chat_conversation.go, turn_done), le serveur Loki pousse une notification
|
|
// vers les navigateurs abonnés (iPhone verrouillé, Android, desktop) via leur
|
|
// service de push (Apple/Google). C'est le SERVEUR qui pousse, directement vers
|
|
// le endpoint du navigateur — ça marche donc app fermée, contrairement à une
|
|
// notification purement client (l'onglet caché relâche son flux SSE).
|
|
//
|
|
// Ce que ça ne fait PAS transiter par le tunnel E2E : la charge utile est
|
|
// chiffrée de bout en bout avec les clés PROPRES de l'abonnement (RFC 8291),
|
|
// puis remise au service de push public. L'inscription, elle, passe par /api
|
|
// (donc jfetch/E2E) comme le reste.
|
|
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
)
|
|
|
|
// fmtDurFR : durée → « 42s », « 3 mn 05s », « 1 h 12 mn » pour le corps de la
|
|
// notification. Miroir serveur de fmtElapsed() côté UI (09-stream.js).
|
|
func fmtDurFR(d time.Duration) string {
|
|
secs := int(d.Round(time.Second).Seconds())
|
|
if secs < 0 {
|
|
secs = 0
|
|
}
|
|
h, m, s := secs/3600, (secs%3600)/60, secs%60
|
|
switch {
|
|
case h > 0:
|
|
return fmt.Sprintf("%d h %02d mn", h, m)
|
|
case m > 0:
|
|
return fmt.Sprintf("%d mn %02ds", m, s)
|
|
default:
|
|
return fmt.Sprintf("%ds", s)
|
|
}
|
|
}
|
|
|
|
// Stockées dans bkState (une seule base bbolt, voir store.go).
|
|
const (
|
|
stPushVAPIDPriv = "push_vapid_priv" // clé privée VAPID (base64url)
|
|
stPushVAPIDPub = "push_vapid_pub" // clé publique VAPID (base64url), servie à l'UI
|
|
stPushSubs = "push_subs" // liste JSON des abonnements
|
|
)
|
|
|
|
// pushSub : un abonnement PushSubscription du navigateur. Même forme que l'objet
|
|
// JS, on le reçoit tel quel depuis l'UI et on le rejoue tel quel à l'envoi.
|
|
type pushSub struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Keys webpush.Keys `json:"keys"`
|
|
}
|
|
|
|
// vapidMu sérialise la génération paresseuse des clés : deux requêtes /api/push/key
|
|
// concurrentes sur une base neuve ne doivent pas produire deux paires différentes
|
|
// (la seconde écraserait la première, invalidant les abonnements de la première).
|
|
var vapidMu sync.Mutex
|
|
|
|
// vapidKeys renvoie la paire VAPID, la générant et la persistant au premier appel.
|
|
// La paire est STABLE pour la vie de l'installation : la changer invaliderait tous
|
|
// les abonnements déjà pris (le navigateur signe l'abonnement avec la clé publique).
|
|
func vapidKeys() (priv, pub string, err error) {
|
|
vapidMu.Lock()
|
|
defer vapidMu.Unlock()
|
|
priv = getStr(bkState, stPushVAPIDPriv)
|
|
pub = getStr(bkState, stPushVAPIDPub)
|
|
if priv != "" && pub != "" {
|
|
return priv, pub, nil
|
|
}
|
|
priv, pub, err = webpush.GenerateVAPIDKeys()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err = putStr(bkState, stPushVAPIDPriv, priv); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err = putStr(bkState, stPushVAPIDPub, pub); err != nil {
|
|
return "", "", err
|
|
}
|
|
return priv, pub, nil
|
|
}
|
|
|
|
// subsMu sérialise les mutations de la liste d'abonnements (lecture-modif-écriture) :
|
|
// une inscription et une purge concurrentes ne doivent pas s'écraser l'une l'autre.
|
|
var subsMu sync.Mutex
|
|
|
|
func loadSubs() []pushSub {
|
|
var subs []pushSub
|
|
getJSON(bkState, stPushSubs, &subs)
|
|
return subs
|
|
}
|
|
|
|
func saveSubs(subs []pushSub) error { return putJSON(bkState, stPushSubs, subs) }
|
|
|
|
// addSub enregistre un abonnement (idempotent : ré-inscrire le même endpoint met
|
|
// simplement à jour ses clés au lieu de le dupliquer — un navigateur ré-abonné
|
|
// garde le même endpoint mais peut renouveler ses clés).
|
|
func addSub(s pushSub) error {
|
|
if s.Endpoint == "" {
|
|
return nil
|
|
}
|
|
subsMu.Lock()
|
|
defer subsMu.Unlock()
|
|
subs := loadSubs()
|
|
for i, x := range subs {
|
|
if x.Endpoint == s.Endpoint {
|
|
subs[i] = s
|
|
return saveSubs(subs)
|
|
}
|
|
}
|
|
subs = append(subs, s)
|
|
return saveSubs(subs)
|
|
}
|
|
|
|
// removeSub retire un abonnement par son endpoint (désinscription explicite, ou
|
|
// purge après un 404/410 « gone » renvoyé par le service de push).
|
|
func removeSub(endpoint string) {
|
|
if endpoint == "" {
|
|
return
|
|
}
|
|
subsMu.Lock()
|
|
defer subsMu.Unlock()
|
|
subs := loadSubs()
|
|
out := subs[:0]
|
|
for _, x := range subs {
|
|
if x.Endpoint != endpoint {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
_ = saveSubs(out)
|
|
}
|
|
|
|
// pushPayload : ce que le service worker reçoit dans l'événement `push`.
|
|
type pushPayload struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
// hasPushSubs : y a-t-il au moins un abonnement ? Évite de sérialiser une charge
|
|
// utile pour rien à chaque fin de tour quand personne n'a activé les notifs.
|
|
func hasPushSubs() bool { return len(loadSubs()) > 0 }
|
|
|
|
// sendPushToAll pousse une notification à tous les abonnés. Best-effort : les
|
|
// échecs réseau sont ignorés (le service de push réessaiera selon le TTL), mais un
|
|
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
|
|
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
|
|
func sendPushToAll(title, body string) {
|
|
// Notifications tenues par la façade (LOKI_PUSH=rust) : c'est elle qui pousse.
|
|
if facadePushSend(title, body) {
|
|
return
|
|
}
|
|
subs := loadSubs()
|
|
if len(subs) == 0 {
|
|
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
|
|
return
|
|
}
|
|
priv, pub, err := vapidKeys()
|
|
if err != nil {
|
|
fmt.Printf("[push] clés VAPID indisponibles : %v\n", err)
|
|
return
|
|
}
|
|
msg, _ := json.Marshal(pushPayload{Title: title, Body: body, Tag: "loki-turn"})
|
|
opts := &webpush.Options{
|
|
// ⚠️ SANS préfixe « mailto: » : webpush-go l'ajoute lui-même (sauf si la
|
|
// chaîne commence par « https: »). Passer « mailto:… » ici donnait
|
|
// « mailto:mailto:… », un sujet VAPID malformé → Apple répond 403
|
|
// BadJwtToken et rien n'arrive. On donne donc l'email nu.
|
|
Subscriber: "mail@nathaninline.com",
|
|
VAPIDPublicKey: pub,
|
|
VAPIDPrivateKey: priv,
|
|
TTL: 120, // périmé après 2 min : une notif « réponse prête » n'a pas de sens tardive
|
|
Urgency: webpush.UrgencyHigh,
|
|
}
|
|
fmt.Printf("[push] envoi à %d abonné(s)…\n", len(subs))
|
|
for _, s := range subs {
|
|
sub := &webpush.Subscription{Endpoint: s.Endpoint, Keys: s.Keys}
|
|
resp, err := webpush.SendNotification(msg, sub, opts)
|
|
if err != nil {
|
|
fmt.Printf("[push] échec envoi (%s) : %v\n", endpointHost(s.Endpoint), err)
|
|
continue
|
|
}
|
|
// 201 Created = accepté par le service de push. 4xx = problème (VAPID,
|
|
// chiffrement, abonnement mort) — on TRACE le corps de la réponse, qui porte
|
|
// le motif exact d'Apple/Google (ex. « BadJwtToken »).
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
fmt.Printf("[push] refus %d (%s) : %s\n", resp.StatusCode, endpointHost(s.Endpoint), strings.TrimSpace(string(body)))
|
|
} else {
|
|
fmt.Printf("[push] accepté %d (%s)\n", resp.StatusCode, endpointHost(s.Endpoint))
|
|
}
|
|
// 404/410 = abonnement expiré côté service de push : on le retire.
|
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
|
removeSub(s.Endpoint)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// endpointHost : hôte de l'endpoint pour les logs (miroir de pushEndpointHost,
|
|
// gardé dans ce fichier pour éviter un couplage de compilation entre les deux).
|
|
func endpointHost(endpoint string) string { return pushEndpointHost(endpoint) }
|