mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs abonnés, directement via leur service de push — donc app fermée et téléphone verrouillé, là où une notification côté page ne peut rien (un onglet caché relâche son flux SSE). Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki — la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte le plus : une tâche tourne justement quand personne ne regarde. Clés VAPID générées à la première demande et rangées dans la base ; abonnements persistés et purgés quand le service de push répond 404/410. Corps de notification générique, sans extrait de réponse : elle transite par Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine (un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir les push, sans cache — mettre l'UI en cache servirait une interface périmée après une mise à jour de l'image. Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil. Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
203 lines
7.1 KiB
Go
203 lines
7.1 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) {
|
|
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) }
|