feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
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:
1 parent
1a49e6b304
commit
38b8c92899
27 files changed
+918
-27
No files matched your search
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user