feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

+7
View File
@@ -143,6 +143,9 @@ func (s *Server) startAlarmTicker() {
if set.Events.Due.Webhook {
channels = append(channels, "webhook")
}
if set.Events.Due.Push {
channels = append(channels, "push")
}
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
@@ -166,6 +169,10 @@ func (s *Server) startAlarmTicker() {
sentAny = true
}
}
case "push":
if err := s.sendPush(a.UserID, pushMessage{Title: "⏰ Aufgabe fällig", Body: a.Title + " – " + due, URL: "/", Tag: "task-" + a.TaskID}); err == nil {
sentAny = true
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
+30 -3
View File
@@ -190,7 +190,7 @@ func (s *Server) flushNotifications(now time.Time) {
// channelItems collects what goes out on each channel.
type channelItems struct {
email, discord, webhook []notifyItem
email, discord, webhook, push []notifyItem
}
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
@@ -203,6 +203,9 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
if ch.Webhook {
c.webhook = append(c.webhook, items...)
}
if ch.Push {
c.push = append(c.push, items...)
}
}
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
@@ -236,6 +239,11 @@ func (s *Server) deliver(userID, heading string, out channelItems) error {
firstErr = err
}
}
if len(out.push) > 0 {
if err := s.sendPush(userID, pushDigest(heading, out.push, s.notifyLink())); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
@@ -254,6 +262,25 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
return s.sendDiscordDigest(webhookURL, heading, items)
}
// pushDigest: one notification per bundle; a single event is its own title
func pushDigest(heading string, items []notifyItem, link string) pushMessage {
if link == "" {
link = "/"
}
if len(items) == 1 {
return pushMessage{Title: "MindSift", Body: items[0].text, URL: link, Tag: "digest"}
}
lines := make([]string, 0, 5)
for i, it := range items {
if i == 4 {
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-4))
break
}
lines = append(lines, "• "+it.text)
}
return pushMessage{Title: "MindSift: " + heading, Body: strings.Join(lines, "\n"), URL: link, Tag: "digest"}
}
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
if s.notifyWebhookHook != nil {
return s.notifyWebhookHook(webhookURL, heading, items)
@@ -377,7 +404,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook && !ds.Push) {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
@@ -389,7 +416,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
continue
}
var out channelItems
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, items...)
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
}
+1 -1
View File
@@ -134,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
// Settings round trip over the profile API
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false,"push":false}`) {
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
}
}
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/sandrodach/mindsift/internal/auth"
)
// Web push: browsers and installed apps (Android, iOS from 16.4 as home screen app, desktop) subscribe
// with the server's VAPID key; notifications reach them even when MindSift is closed. Push is one more
// channel next to e-mail, Discord and the own webhook (notification settings). The VAPID key pair comes
// from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or is generated once and kept in the database.
type pushKeys struct {
once sync.Once
public, priv string
err error
}
func (s *Server) vapidKeys() (string, string, error) {
s.push.once.Do(func() {
if s.cfg.VAPIDPublicKey != "" && s.cfg.VAPIDPrivateKey != "" {
s.push.public, s.push.priv = s.cfg.VAPIDPublicKey, s.cfg.VAPIDPrivateKey
return
}
s.push.public, s.push.priv, s.push.err = s.store.PushKeys(func() (string, string, error) {
priv, pub, err := webpush.GenerateVAPIDKeys()
return pub, priv, err
})
})
return s.push.public, s.push.priv, s.push.err
}
// vapidSubject: contact for the push services (VAPID_SUBJECT, else the https base URL)
func (s *Server) vapidSubject() string {
if s.cfg.VAPIDSubject != "" {
return s.cfg.VAPIDSubject
}
if s.cfg.BaseURLSet && strings.HasPrefix(s.cfg.BaseURL, "https://") {
return strings.TrimRight(s.cfg.BaseURL, "/")
}
return "mailto:push@mindsift.invalid"
}
type pushMessage struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Tag string `json:"tag,omitempty"`
}
// sendPush delivers a message to every device of the user; devices the push service no longer knows
// are forgotten. Error when no device could be reached.
func (s *Server) sendPush(userID string, msg pushMessage) error {
if s.notifyPushHook != nil {
return s.notifyPushHook(userID, msg)
}
if s.cfg.DemoMode {
return errDemoOutgoing
}
subs, err := s.store.PushSubscriptions(userID)
if err != nil || len(subs) == 0 {
return err
}
pub, priv, err := s.vapidKeys()
if err != nil {
return err
}
payload, _ := json.Marshal(msg)
client := s.pushHTTPClient
if client == nil {
client = guardedClient(s.allowPrivateWebhooks())
}
var lastErr error
sent := 0
for _, sub := range subs {
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
}, &webpush.Options{
HTTPClient: client,
Subscriber: s.vapidSubject(),
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
TTL: 24 * 3600,
Urgency: webpush.UrgencyHigh,
})
if err != nil {
lastErr = err
continue
}
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
s.store.ForgetPushEndpoint(sub.Endpoint)
case resp.StatusCode >= 400:
lastErr = fmt.Errorf("Push-Dienst antwortet mit HTTP %d", resp.StatusCode)
default:
sent++
}
}
if sent == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
if s.cfg.DemoMode {
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false})
return
}
pub, _, err := s.vapidKeys()
if err != nil {
slog.Error("VAPID-Schlüssel nicht verfügbar", "error", err)
writeError(w, http.StatusInternalServerError, "Push ist auf diesem Server nicht verfügbar")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": true, "publicKey": pub})
}
func (s *Server) handlePushDevices(w http.ResponseWriter, r *http.Request) {
subs, err := s.store.PushSubscriptions(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": subs})
}
func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
if s.cfg.DemoMode {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
return
}
var req struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
Device string `json:"device"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := validPushEndpoint(req.Endpoint); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeError(w, http.StatusBadRequest, "Schlüssel des Geräts fehlen")
return
}
device := strings.TrimSpace(req.Device)
if len(device) > 120 {
device = device[:120]
}
if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
var req struct {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := s.store.DeletePushSubscription(auth.GetUserID(r.Context()), req.Endpoint); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht entfernt werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
subs, _ := s.store.PushSubscriptions(userID)
if len(subs) == 0 {
writeError(w, http.StatusBadRequest, "Auf keinem Gerät sind Push-Benachrichtigungen eingeschaltet")
return
}
err := s.sendPush(userID, pushMessage{Title: "MindSift", Body: "Test: Push-Benachrichtigungen kommen an.", URL: "/", Tag: "test"})
if err != nil {
if errors.Is(err, errDemoOutgoing) {
writeError(w, http.StatusForbidden, err.Error())
return
}
slog.Warn("Test-Push fehlgeschlagen", "user_id", userID, "error", err)
writeError(w, http.StatusBadGateway, "Push konnte nicht zugestellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]int{"devices": len(subs)})
}
// validPushEndpoint: the push services of the browsers are https URLs on the public internet.
func validPushEndpoint(endpoint string) error {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || len(endpoint) > 2048 {
return errors.New("Ungültige Push-Adresse")
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/hkdf"
)
// A push sent by the server can be decrypted like a browser does (RFC 8291, aes128gcm) and carries
// a VAPID authorization; a device the push service no longer knows (410) is forgotten.
func TestPushPayloadDecryptsLikeABrowser(t *testing.T) {
srv, store, _ := setupTestServer(t)
me, _ := newTestUser(t, store, "geraet@example.com")
// The "browser": its key pair and auth secret
recv, _ := ecdh.P256().GenerateKey(rand.Reader)
authSecret := make([]byte, 16)
_, _ = rand.Read(authSecret)
b64 := base64.RawURLEncoding
var body []byte
var authz string
status := http.StatusCreated
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
authz = r.Header.Get("Authorization")
w.WriteHeader(status)
}))
defer push.Close()
srv.pushHTTPClient = push.Client()
endpoint := push.URL + "/send/geraet-1"
if err := store.SavePushSubscription(me.ID, endpoint, b64.EncodeToString(recv.PublicKey().Bytes()), b64.EncodeToString(authSecret), "Test"); err != nil {
t.Fatal(err)
}
if err := srv.sendPush(me.ID, pushMessage{Title: "MindSift", Body: "Steuer fällig", URL: "/"}); err != nil {
t.Fatalf("send: %v", err)
}
if !strings.HasPrefix(authz, "vapid t=") || !strings.Contains(authz, ", k=") {
t.Fatalf("missing VAPID authorization: %q", authz)
}
// aes128gcm header: salt(16) | record size(4) | key id length(1) | sender public key
salt, idLen := body[:16], int(body[20])
senderPub, cipherText := body[21:21+idLen], body[21+idLen:]
sender, err := ecdh.P256().NewPublicKey(senderPub)
if err != nil {
t.Fatalf("sender key: %v", err)
}
shared, _ := recv.ECDH(sender)
info := append(append([]byte("WebPush: info\x00"), recv.PublicKey().Bytes()...), senderPub...)
ikm := make([]byte, 32)
_, _ = io.ReadFull(hkdf.New(sha256.New, shared, authSecret, info), ikm)
cek := make([]byte, 16)
nonce := make([]byte, 12)
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: aes128gcm\x00")), cek)
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: nonce\x00")), nonce)
block, _ := aes.NewCipher(cek)
gcm, _ := cipher.NewGCM(block)
plain, err := gcm.Open(nil, nonce, cipherText, nil)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
plain = plain[:strings.LastIndexByte(string(plain), 2)] // padding delimiter
var got pushMessage
if err := json.Unmarshal(plain, &got); err != nil || got.Body != "Steuer fällig" {
t.Fatalf("payload %q: %v", plain, err)
}
// The push service says the device is gone: it is removed
status = http.StatusGone
_ = srv.sendPush(me.ID, pushMessage{Title: "x"})
if subs, _ := store.PushSubscriptions(me.ID); len(subs) != 0 {
t.Fatalf("gone device must be forgotten, still %d", len(subs))
}
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestPushSubscribeKeyAndChannel(t *testing.T) {
srv, store, _ := setupTestServer(t)
srv.cfg.NotifyDigestMinutes = 5
router := srv.Router()
me, token := newTestUser(t, store, "push@example.com")
// The key is generated once and stays the same
var k1, k2 struct {
Enabled bool
PublicKey string
}
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k1)
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k2)
if !k1.Enabled || len(k1.PublicKey) < 80 || k1.PublicKey != k2.PublicKey {
t.Fatalf("key: %+v / %+v", k1, k2)
}
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, `{"endpoint":"http://intern/x","keys":{"p256dh":"a","auth":"b"}}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("http endpoint must be refused, got %d", rec.Code)
}
body := `{"endpoint":"https://push.example.com/abc","keys":{"p256dh":"BAAA","auth":"CCCC"},"device":"Firefox auf Linux"}`
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, body, ""); rec.Code != http.StatusOK {
t.Fatalf("subscribe: %d %s", rec.Code, rec.Body.String())
}
rec := sessionCall(router, "GET", "/api/v1/push/devices", token, "", "")
if !strings.Contains(rec.Body.String(), "Firefox auf Linux") || strings.Contains(rec.Body.String(), "CCCC") {
t.Fatalf("devices must list the device without its keys: %s", rec.Body.String())
}
// Push as a channel for watched tasks
var pushed []pushMessage
srv.notifyPushHook = func(userID string, msg pushMessage) error {
if userID == me.ID {
pushed = append(pushed, msg)
}
return nil
}
task, _ := store.CreateThoughtWithDetails(me.ID, "Reifen wechseln", "normal", "manual", "", "", "todo", "")
_ = store.WatchTask(task.ID, me.ID, true)
set := store.NotificationSettingsOf(me.ID)
set.IncludeOwnWatched = true
set.Events.Watched = domain.ChannelChoice{Push: true}
_ = store.SetNotificationSettings(me.ID, set)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, token, `{"priority":"high"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(pushed) != 1 || !strings.Contains(pushed[0].Body, "Reifen wechseln") {
t.Fatalf("expected one push, got %+v", pushed)
}
if rec := sessionCall(router, "POST", "/api/v1/push/unsubscribe", token, `{"endpoint":"https://push.example.com/abc"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("unsubscribe: %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/push/test", token, "", ""); rec.Code != http.StatusBadRequest {
t.Fatalf("test without devices: want 400, got %d", rec.Code)
}
}
func TestPushDigestText(t *testing.T) {
one := pushDigest("Neuigkeiten", []notifyItem{{text: "Anna hat „Einkauf“ erledigt"}}, "")
if one.Body != "Anna hat „Einkauf“ erledigt" || one.URL != "/" {
t.Fatalf("single: %+v", one)
}
many := pushDigest("Neuigkeiten", []notifyItem{{text: "a"}, {text: "b"}, {text: "c"}, {text: "d"}, {text: "e"}, {text: "f"}}, "https://x/")
if many.Title != "MindSift: Neuigkeiten" || !strings.HasSuffix(many.Body, "… und 2 weitere") {
t.Fatalf("many: %+v", many)
}
}
+8
View File
@@ -39,6 +39,9 @@ type Server struct {
notifyEmailHook func(to, subject, text, html string) error
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
notifyPushHook func(userID string, msg pushMessage) error
pushHTTPClient *http.Client // tests: reach a local push service
push pushKeys
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -218,6 +221,11 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
r.Get("/api/v1/journal", s.handleGetJournal)
r.Get("/api/v1/push/key", s.handlePushKey)
r.Get("/api/v1/push/devices", s.handlePushDevices)
r.Post("/api/v1/push/subscribe", s.handlePushSubscribe)
r.Post("/api/v1/push/unsubscribe", s.handlePushUnsubscribe)
r.Post("/api/v1/push/test", s.handlePushTest)
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)