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

+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
}