mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 20:26:35 +02:00
Lot 12 : notifications Web Push tenues par la façade (LOKI_PUSH=rust)
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
This commit is contained in:
20 files changed
+1320
-5
No files matched your search
@@ -143,7 +143,7 @@ var facadeChatRoutes = map[string]bool{
|
||||
// withFacadeChat remet la requête à la façade quand elle tient la
|
||||
// conversation — ou les routes des modèles (facade_models.go), la dictée
|
||||
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
|
||||
// de la discussion (facade_files.go).
|
||||
// de la discussion (facade_files.go), les notifications (facade_push.go).
|
||||
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
|
||||
// jeton interne qui en tient lieu là-bas.
|
||||
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -158,6 +158,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
held, unavailable = facadeSettings, "réglages indisponibles : "
|
||||
case facadeFilesRoutes[path]:
|
||||
held, unavailable = facadeFiles, "fichiers indisponibles : "
|
||||
case facadePushRoutes[path]:
|
||||
held, unavailable = facadePush, "notifications indisponibles : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -484,3 +484,30 @@ func TestFacadeFichiers(t *testing.T) {
|
||||
t.Fatalf(".part gardé sans la variable : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les
|
||||
// envois de ce process (fin de tour, fin de tâche) lui sont délégués.
|
||||
func TestFacadeNotifications(t *testing.T) {
|
||||
testHome(t)
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
t.Setenv(facadePushEnv, "rust")
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
for p := range facadePushRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != f.reply {
|
||||
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
sendPushToAll("Loki", "Réponse prête · 3s")
|
||||
got := f.recu()
|
||||
if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` {
|
||||
t.Fatalf("reçu par la façade : %v", got)
|
||||
}
|
||||
t.Setenv(facadePushEnv, "")
|
||||
sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué
|
||||
if n := len(f.recu()); n != len(facadePushRoutes)+1 {
|
||||
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_push.go — notifications Web Push tenues par la façade Rust
|
||||
// (LOKI_PUSH=rust, lot 12 de la migration ; expérimental, le Go reste le
|
||||
// défaut).
|
||||
//
|
||||
// La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
|
||||
// les notifications : les envois de ce process (fin de tour, fin de tâche) lui
|
||||
// sont délégués par /internal/facade/v1/push/send. Clés et abonnements restent
|
||||
// en base aux mêmes clés : hasPushSubs les lit toujours ici.
|
||||
|
||||
const facadePushEnv = "LOKI_PUSH"
|
||||
|
||||
// facadePush : la façade tient-elle les notifications ?
|
||||
func facadePush() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePushEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadePushRoutes : les routes que la façade sert alors (push.rs).
|
||||
var facadePushRoutes = map[string]bool{
|
||||
"/api/push/key": true,
|
||||
"/api/push/subscribe": true,
|
||||
"/api/push/unsubscribe": true,
|
||||
}
|
||||
|
||||
// facadePushSend délègue un envoi à la façade ; false quand elle ne tient pas
|
||||
// les notifications (l'appelant envoie alors lui-même).
|
||||
func facadePushSend(title, body string) bool {
|
||||
if !facadePush() {
|
||||
return false
|
||||
}
|
||||
if err := facadePost("/internal/facade/v1/push/send", map[string]any{"title": title, "body": body}); err != nil {
|
||||
fmt.Printf("[push] envoi délégué à la façade : %v\n", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parity_push_test.go — journal de référence des notifications Web Push (lot
|
||||
// 12 de la migration Rust) : clé VAPID (générée une fois, stable), abonnement,
|
||||
// désabonnement, et le texte du corps de notification. Rangé dans
|
||||
// rust/loki-rs/tests/parity_push/push.json ; tests/push.rs le rejoue par la
|
||||
// façade. Les clés tirées au hasard sont masquées (seule leur forme compte).
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications
|
||||
|
||||
const parityPushFile = "../../rust/loki-rs/tests/parity_push/push.json"
|
||||
|
||||
type parityPushCall struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Out any `json:"out"`
|
||||
// Après l'appel : les abonnements enregistrés (JSON brut, tel qu'en base).
|
||||
Subs string `json:"subs"`
|
||||
}
|
||||
|
||||
type parityPushFixture struct {
|
||||
Durations map[string]string `json:"durations"`
|
||||
Calls []parityPushCall `json:"calls"`
|
||||
}
|
||||
|
||||
func TestPariteNotifications(t *testing.T) {
|
||||
testHome(t)
|
||||
var fx parityPushFixture
|
||||
fx.Durations = map[string]string{}
|
||||
for _, ms := range []int64{0, 400, 600, 42_000, 185_000, 3_600_000, 4_330_000, -5_000} {
|
||||
fx.Durations[strings.TrimSpace(time.Duration(ms*int64(time.Millisecond)).String())] = fmtDurFR(time.Duration(ms) * time.Millisecond)
|
||||
}
|
||||
keyRe := regexp.MustCompile(`"key":"[A-Za-z0-9_-]{87}"`)
|
||||
call := func(method, path, body string) parityPushCall {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
switch path {
|
||||
case "/api/push/key":
|
||||
handlePushKey(w, r)
|
||||
case "/api/push/subscribe":
|
||||
handlePushSubscribe(w, r)
|
||||
case "/api/push/unsubscribe":
|
||||
handlePushUnsubscribe(w, r)
|
||||
default:
|
||||
t.Fatalf("route inattendue %s", path)
|
||||
}
|
||||
out := keyRe.ReplaceAllString(w.Body.String(), `"key":"{{CLE}}"`)
|
||||
c := parityPushCall{Method: method, Path: path, Body: body, Status: w.Code, Subs: string(getBytes(bkState, stPushSubs))}
|
||||
if err := json.Unmarshal([]byte(out), &c.Out); err != nil {
|
||||
t.Fatalf("%s : %v (%s)", path, err, w.Body.String())
|
||||
}
|
||||
return c
|
||||
}
|
||||
for _, c := range []struct{ m, p, b string }{
|
||||
{"GET", "/api/push/key", ""},
|
||||
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BAAA","auth":"x1"}}`},
|
||||
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/b","keys":{"p256dh":"BBBB","auth":"x2"},"expirationTime":null}`},
|
||||
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BCCC","auth":"x3"}}`},
|
||||
{"POST", "/api/push/subscribe", `{"keys":{"p256dh":"B","auth":"y"}}`},
|
||||
{"POST", "/api/push/subscribe", `pas du json`},
|
||||
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/inconnu"}`},
|
||||
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/a"}`},
|
||||
{"POST", "/api/push/unsubscribe", `pas du json`},
|
||||
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/b"}`},
|
||||
{"GET", "/api/push/key", ""},
|
||||
} {
|
||||
fx.Calls = append(fx.Calls, call(c.m, c.p, c.b))
|
||||
}
|
||||
// La paire VAPID est stable : la clé servie est la clé rangée.
|
||||
if pub := getStr(bkState, stPushVAPIDPub); len(pub) != 87 || len(getStr(bkState, stPushVAPIDPriv)) != 43 {
|
||||
t.Fatalf("paire VAPID inattendue : %q", pub)
|
||||
}
|
||||
|
||||
got, err := parityJSON(fx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityPushFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityPushFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityPushFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("les notifications ne produisent plus le journal rangé dans %s.\n"+
|
||||
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications,\n"+
|
||||
"puis cargo test --test push dit si le port Rust suit.\n--- obtenu ---\n%s", parityPushFile, got)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,10 @@ func hasPushSubs() bool { return len(loadSubs()) > 0 }
|
||||
// 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")
|
||||
|
||||
@@ -223,8 +223,9 @@ func newWebMux() *http.ServeMux {
|
||||
// conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand
|
||||
// elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée
|
||||
// (LOKI_DICTATE=rust, facade_dictate.go), des réglages partagés
|
||||
// (LOKI_SETTINGS=rust, facade_settings.go) et des fichiers de la
|
||||
// discussion (LOKI_FILES=rust, facade_files.go) ; sans effet sinon.
|
||||
// (LOKI_SETTINGS=rust, facade_settings.go), des fichiers de la discussion
|
||||
// (LOKI_FILES=rust, facade_files.go) et des notifications (LOKI_PUSH=rust,
|
||||
// facade_push.go) ; sans effet sinon.
|
||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) }
|
||||
api("/api/ping", handlePing)
|
||||
api("/api/status", handleStatus)
|
||||
|
||||
Reference in new issue
Block a user