Phase 8: push and notifications

Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 11:43:36 +02:00
1 parent 74f7bc8805
commit b507753172
47 files changed
+5136 -50

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
package pushserver
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The push gateway client: the Push Gateway API's one endpoint,
// POST /_matrix/push/v1/notify, called for every pusher of a notified user.
//
// Pushes are sent from a small pool of senders fed by a bounded queue, so
// that a slow or dead gateway delays its own pushes and nothing else: the
// evaluation worker never waits on the network. A push that fails with a
// server error or a timeout is retried a few times with a growing pause;
// one the gateway refuses outright is not. When the queue is full a push
// is dropped and logged. Pushes are a best-effort wake-up — the
// notification itself is already counted, and the device sees it on its
// next sync whether or not the push arrived.
const (
// gatewaySenders is how many pushes are in flight at once.
gatewaySenders = 4
// gatewayQueueSize bounds the pushes waiting for a sender.
gatewayQueueSize = 1024
// gatewayAttempts is how many times one push is tried.
gatewayAttempts = 3
// maxGatewayResponse bounds what is read of a gateway's answer, which
// is a list of rejected push keys and should be tiny.
maxGatewayResponse = 64 << 10
)
// gatewayRetryDelay is the pause before the given retry (1-based).
var gatewayRetryDelay = func(attempt int) time.Duration {
return time.Duration(1<<(2*(attempt-1))) * time.Second
}
// notificationPush is the notification object of a push, without the
// devices, which are per pusher.
type notificationPush struct {
EventID string `json:"event_id,omitempty"`
RoomID string `json:"room_id,omitempty"`
Type string `json:"type,omitempty"`
Sender string `json:"sender,omitempty"`
SenderDisplayName string `json:"sender_display_name,omitempty"`
RoomName string `json:"room_name,omitempty"`
RoomAlias string `json:"room_alias,omitempty"`
UserIsTarget *bool `json:"user_is_target,omitempty"`
Membership string `json:"membership,omitempty"`
Prio string `json:"prio,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
Counts pushCounts `json:"counts"`
Devices []pushDevice `json:"devices"`
}
type pushCounts struct {
Unread int `json:"unread"`
}
type pushDevice struct {
AppID string `json:"app_id"`
PushKey string `json:"pushkey"`
PushKeyTS int64 `json:"pushkey_ts,omitempty"`
Data map[string]json.RawMessage `json:"data,omitempty"`
Tweaks map[string]json.RawMessage `json:"tweaks,omitempty"`
}
// pushJob is one push to one pusher.
type pushJob struct {
localpart string
pusher storage.Pusher
note notificationPush
tweaks map[string]json.RawMessage
}
type gatewayOptions struct {
client *http.Client
includeContent bool
// rejected is told about a push key the gateway refused.
rejected func(localpart, appID, pushKey string)
log *slog.Logger
}
type gateway struct {
opts gatewayOptions
queue chan pushJob
wg sync.WaitGroup
ctx context.Context
}
func newGateway(opts gatewayOptions) *gateway {
return &gateway{opts: opts, queue: make(chan pushJob, gatewayQueueSize)}
}
func (g *gateway) start(ctx context.Context) {
g.ctx = ctx
for range gatewaySenders {
g.wg.Add(1)
go g.send()
}
}
func (g *gateway) wait() { g.wg.Wait() }
func (g *gateway) enqueue(job pushJob) {
select {
case g.queue <- job:
default:
g.opts.log.Warn("push queue is full; dropping a push",
"localpart", job.localpart, "app_id", job.pusher.AppID)
}
}
func (g *gateway) send() {
defer g.wg.Done()
for {
select {
case <-g.ctx.Done():
return
case job := <-g.queue:
g.deliver(job)
}
}
}
// deliver sends one push, retrying what is worth retrying.
func (g *gateway) deliver(job pushJob) {
var data pusherData
_ = json.Unmarshal(job.pusher.Data, &data)
body, err := g.payload(job, data)
if err != nil {
g.opts.log.Error("building a push failed", "localpart", job.localpart, "error", err)
return
}
for attempt := 1; attempt <= gatewayAttempts; attempt++ {
rejected, retry, err := g.post(data.URL, body)
if err == nil {
for _, key := range rejected {
if key == job.pusher.PushKey {
g.opts.rejected(job.localpart, job.pusher.AppID, job.pusher.PushKey)
}
}
return
}
if !retry || attempt == gatewayAttempts {
g.opts.log.Warn("push gateway request failed",
"localpart", job.localpart, "app_id", job.pusher.AppID, "attempts", attempt, "error", err)
return
}
select {
case <-g.ctx.Done():
return
case <-time.After(gatewayRetryDelay(attempt)):
}
}
}
// payload builds the request body for one pusher.
//
// The pusher's data goes to the gateway as the device's data, less the
// URL, which is the homeserver's business. A pusher whose format is
// event_id_only gets only the IDs and the counts: the app fetches the
// event itself, so nothing of it passes through the gateway.
func (g *gateway) payload(job pushJob, data pusherData) ([]byte, error) {
deviceData := map[string]json.RawMessage{}
if err := json.Unmarshal(job.pusher.Data, &deviceData); err != nil {
return nil, fmt.Errorf("pusher data: %w", err)
}
delete(deviceData, "url")
device := pushDevice{
AppID: job.pusher.AppID,
PushKey: job.pusher.PushKey,
PushKeyTS: job.pusher.PushKeyTSMS / 1000,
Data: deviceData,
Tweaks: job.tweaks,
}
note := job.note
if data.Format == "event_id_only" {
note = notificationPush{
EventID: note.EventID,
RoomID: note.RoomID,
Prio: note.Prio,
Counts: note.Counts,
}
} else if !g.opts.includeContent {
note.Content = nil
}
note.Devices = []pushDevice{device}
return json.Marshal(struct {
Notification notificationPush `json:"notification"`
}{note})
}
// post sends a body to a gateway. It returns the push keys the gateway
// rejected, or whether a failure is worth another try.
func (g *gateway) post(url string, body []byte) (rejected []string, retry bool, err error) {
req, err := http.NewRequestWithContext(g.ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.opts.client.Do(req)
if err != nil {
return nil, g.ctx.Err() == nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxGatewayResponse))
if err != nil {
return nil, true, err
}
switch {
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
return nil, true, fmt.Errorf("gateway answered %d", resp.StatusCode)
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return nil, false, fmt.Errorf("gateway answered %d", resp.StatusCode)
}
var answer struct {
Rejected []string `json:"rejected"`
}
// A gateway that says 200 and sends something unreadable delivered
// the push all the same.
_ = json.Unmarshal(raw, &answer)
return answer.Rejected, false, nil
}
+109
View File
@@ -0,0 +1,109 @@
package pushserver
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
func TestValidateGatewayURL(t *testing.T) {
for raw, ok := range map[string]bool{
"https://push.example/_matrix/push/v1/notify": true,
"http://10.0.0.5:5000/_matrix/push/v1/notify": true,
"https://push.example/notify": false,
"ftp://push.example/_matrix/push/v1/notify": false,
"/_matrix/push/v1/notify": false,
"https://user:pw@push.example/_matrix/push/v1/notify": false,
} {
err := validateGatewayURL(raw)
if (err == nil) != ok {
t.Errorf("validateGatewayURL(%q) = %v", raw, err)
}
if err != nil && !errors.Is(err, ErrInvalidPusher) {
t.Errorf("validateGatewayURL(%q) = %v, not ErrInvalidPusher", raw, err)
}
}
}
func TestGatewayRetriesServerErrorsAndNotRefusals(t *testing.T) {
saved := gatewayRetryDelay
gatewayRetryDelay = func(int) time.Duration { return time.Millisecond }
t.Cleanup(func() { gatewayRetryDelay = saved })
var calls atomic.Int32
status := atomic.Int32{}
status.Store(http.StatusInternalServerError)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(int(status.Load()))
_, _ = w.Write([]byte(`{"rejected":[]}`))
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
g := newGateway(gatewayOptions{
client: ts.Client(),
rejected: func(string, string, string) {},
log: slog.New(slog.DiscardHandler),
})
g.ctx = ctx
job := pushJob{
localpart: "alice",
pusher: storage.Pusher{AppID: "a", PushKey: "k",
Data: json.RawMessage(`{"url":"` + ts.URL + `/_matrix/push/v1/notify"}`)},
note: notificationPush{EventID: "$e", RoomID: "!r:x"},
}
g.deliver(job)
if got := calls.Load(); got != gatewayAttempts {
t.Errorf("a failing gateway was tried %d times, want %d", got, gatewayAttempts)
}
calls.Store(0)
status.Store(http.StatusBadRequest)
g.deliver(job)
if got := calls.Load(); got != 1 {
t.Errorf("a refusing gateway was tried %d times, want 1", got)
}
}
func TestGatewayPayloadLeavesTheURLOut(t *testing.T) {
g := newGateway(gatewayOptions{includeContent: false, log: slog.New(slog.DiscardHandler)})
job := pushJob{
pusher: storage.Pusher{AppID: "a", PushKey: "k", PushKeyTSMS: 5000,
Data: json.RawMessage(`{"url":"https://p.example/_matrix/push/v1/notify","extra":1}`)},
note: notificationPush{EventID: "$e", Content: json.RawMessage(`{"body":"secret"}`)},
}
raw, err := g.payload(job, pusherData{})
if err != nil {
t.Fatal(err)
}
var body struct {
Notification struct {
Content json.RawMessage `json:"content"`
Devices []struct {
PushKeyTS int64 `json:"pushkey_ts"`
Data map[string]any `json:"data"`
} `json:"devices"`
} `json:"notification"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
d := body.Notification.Devices[0]
if body.Notification.Content != nil || d.Data["url"] != nil || d.Data["extra"] != float64(1) || d.PushKeyTS != 5 {
t.Errorf("payload = %s", raw)
}
}
+162
View File
@@ -0,0 +1,162 @@
package pushserver
import (
"context"
"encoding/json"
"errors"
"strconv"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Receipt types that move the counts. A private receipt is still the
// user saying they have read up to here; it is private from other users,
// not from the user's own badge.
const (
receiptRead = "m.read"
receiptReadPrivate = "m.read.private"
// threadMain is the thread ID of a receipt for the main timeline.
threadMain = "main"
)
// ReceiptSet is told about a read receipt a local user set, and clears
// their notifications in the room up to the event it names.
//
// Notifications are not tracked per thread (threads are Phase 10), so a
// receipt counts when it is for the room as a whole or for its main
// timeline, and a receipt inside a thread is left alone: clearing the
// room on it would mark the main timeline read when the user only looked
// at one thread. Thread notifications are cleared when the main receipt
// moves past them.
func (a *API) ReceiptSet(ctx context.Context, userID, roomID, receiptType, threadID, eventID string) {
if receiptType != receiptRead && receiptType != receiptReadPrivate {
return
}
if threadID != "" && threadID != threadMain {
return
}
pos, err := a.rooms.EventPosition(ctx, roomID, userID, eventID)
if err != nil {
// A receipt for an event this server does not have — or one the
// user cannot see — reads nothing.
if !errors.Is(err, roomserver.ErrNoSuchEvent) && !errors.Is(err, roomserver.ErrForbidden) {
a.log.Warn("locating a receipt's event failed",
"user_id", userID, "room_id", roomID, "event_id", eventID, "error", err)
}
return
}
if err := a.markRead(ctx, userID, roomID, pos); err != nil {
a.log.Warn("clearing notifications on a receipt failed",
"user_id", userID, "room_id", roomID, "error", err)
}
}
// markRead clears a user's notifications in a room up to a position, and
// wakes their syncs if the counts moved.
func (a *API) markRead(ctx context.Context, userID, roomID string, upTo int64) error {
_, moved, err := a.store.MarkNotificationsRead(ctx, userID, roomID, upTo)
if err != nil {
return err
}
if moved {
a.notifyUsers(userID)
}
return nil
}
// NotificationCounts reads a user's unread counts in the given rooms.
func (a *API) NotificationCounts(ctx context.Context, userID string, roomIDs []string) (map[string]storage.NotificationCounts, error) {
return a.store.NotificationCounts(ctx, userID, roomIDs)
}
// NotificationCountChanges lists the rooms whose counts for a user moved
// in a window of the notification stream.
func (a *API) NotificationCountChanges(ctx context.Context, userID string, from, to int64) ([]string, error) {
return a.store.NotificationCountChanges(ctx, userID, from, to)
}
// Notification is one entry of GET /notifications.
type Notification struct {
Actions []json.RawMessage `json:"actions"`
Event roomserver.ClientEvent `json:"event"`
ProfileTag string `json:"profile_tag,omitempty"`
Read bool `json:"read"`
RoomID string `json:"room_id"`
TS int64 `json:"ts"`
}
// NotificationsRequest is GET /notifications.
type NotificationsRequest struct {
UserID string
// From is the next_token of an earlier page, or empty for the newest.
From string
Limit int
OnlyHighlight bool
}
// Limits on a page of notifications.
const (
DefaultNotificationsLimit = 20
MaxNotificationsLimit = 100
)
// ErrInvalidFrom is a from token that is not one this server handed out.
var ErrInvalidFrom = errors.New("invalid pagination token")
// Notifications pages through a user's notifications, newest first. It
// returns the page and the token for the next one, which is empty at the
// end.
func (a *API) Notifications(ctx context.Context, req NotificationsRequest) ([]Notification, string, error) {
var before int64
if req.From != "" {
var err error
if before, err = strconv.ParseInt(req.From, 10, 64); err != nil || before <= 0 {
return nil, "", ErrInvalidFrom
}
}
limit := req.Limit
if limit <= 0 {
limit = DefaultNotificationsLimit
}
limit = min(limit, MaxNotificationsLimit)
stored, err := a.store.Notifications(ctx, req.UserID, before, limit, req.OnlyHighlight)
if err != nil {
return nil, "", err
}
out := make([]Notification, 0, len(stored))
for _, n := range stored {
ev, err := a.rooms.Event(ctx, n.RoomID, req.UserID, n.EventID)
switch {
case errors.Is(err, roomserver.ErrNoSuchEvent), errors.Is(err, roomserver.ErrNoSuchRoom),
errors.Is(err, roomserver.ErrForbidden):
// Purged, or no longer the user's to see: the notification
// has nothing left to show.
continue
case err != nil:
return nil, "", err
}
var actions []json.RawMessage
if err := json.Unmarshal(n.Actions, &actions); err != nil {
actions = []json.RawMessage{}
}
out = append(out, Notification{
Actions: actions,
Event: ev,
Read: n.Read,
RoomID: n.RoomID,
TS: n.TimestampMS,
})
}
var next string
if len(stored) == limit {
next = strconv.FormatInt(stored[len(stored)-1].ID, 10)
}
return out, next, nil
}
+179
View File
@@ -0,0 +1,179 @@
package pushserver
import (
"context"
"encoding/json"
"fmt"
"net/url"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// PusherKindHTTP is the one kind of pusher this server pushes to. The
// spec's other kind, email, needs a mail server and templates, which are
// not something a homeserver should ship by default; a client asking for
// it is told so rather than given a pusher that never fires.
const PusherKindHTTP = "http"
// gatewayPath is the path every push gateway is reached at.
const gatewayPath = "/_matrix/push/v1/notify"
// Limits the spec sets on a pusher's identity.
const (
maxAppIDLength = 64
maxPushKeyLength = 512
)
// Pusher is a pusher in the shape the Client-Server API uses for it.
type Pusher struct {
PushKey string `json:"pushkey"`
Kind string `json:"kind"`
AppID string `json:"app_id"`
AppDisplayName string `json:"app_display_name"`
DeviceDisplayName string `json:"device_display_name"`
ProfileTag string `json:"profile_tag,omitempty"`
Lang string `json:"lang"`
Data json.RawMessage `json:"data"`
}
// SetPusherRequest is POST /pushers/set.
type SetPusherRequest struct {
PushKey string `json:"pushkey"`
// Kind null removes the pusher.
Kind *string `json:"kind"`
AppID string `json:"app_id"`
AppDisplayName string `json:"app_display_name"`
DeviceDisplayName string `json:"device_display_name"`
ProfileTag string `json:"profile_tag"`
Lang string `json:"lang"`
Data json.RawMessage `json:"data"`
Append bool `json:"append"`
}
// pusherData is the part of a pusher's data the server reads.
type pusherData struct {
URL string `json:"url"`
Format string `json:"format"`
}
// Pushers lists a user's pushers.
func (a *API) Pushers(ctx context.Context, localpart string) ([]Pusher, error) {
stored, err := a.store.Pushers(ctx, localpart)
if err != nil {
return nil, err
}
out := make([]Pusher, 0, len(stored))
for _, p := range stored {
out = append(out, Pusher{
PushKey: p.PushKey, Kind: p.Kind, AppID: p.AppID,
AppDisplayName: p.AppDisplayName, DeviceDisplayName: p.DeviceDisplayName,
ProfileTag: p.ProfileTag, Lang: p.Lang, Data: p.Data,
})
}
return out, nil
}
// SetPusher creates, replaces or removes a pusher for the session
// deviceID.
func (a *API) SetPusher(ctx context.Context, localpart, deviceID string, req SetPusherRequest) error {
switch {
case req.PushKey == "":
return fmt.Errorf("%w: pushkey", ErrMissingPusherField)
case req.AppID == "":
return fmt.Errorf("%w: app_id", ErrMissingPusherField)
case len(req.AppID) > maxAppIDLength:
return fmt.Errorf("%w: app_id must be at most %d characters", ErrInvalidPusher, maxAppIDLength)
case len(req.PushKey) > maxPushKeyLength:
return fmt.Errorf("%w: pushkey must be at most %d bytes", ErrInvalidPusher, maxPushKeyLength)
}
if req.Kind == nil {
return a.store.DeletePusher(ctx, localpart, req.AppID, req.PushKey)
}
switch {
case *req.Kind != PusherKindHTTP:
return fmt.Errorf("%w: this server only supports pushers of kind %q", ErrInvalidPusher, PusherKindHTTP)
case req.AppDisplayName == "":
return fmt.Errorf("%w: app_display_name", ErrMissingPusherField)
case req.DeviceDisplayName == "":
return fmt.Errorf("%w: device_display_name", ErrMissingPusherField)
case req.Lang == "":
return fmt.Errorf("%w: lang", ErrMissingPusherField)
case len(req.Data) == 0:
return fmt.Errorf("%w: data", ErrMissingPusherField)
}
var data pusherData
if err := json.Unmarshal(req.Data, &data); err != nil {
return fmt.Errorf("%w: data must be an object", ErrInvalidPusher)
}
if data.URL == "" {
return fmt.Errorf("%w: data.url", ErrMissingPusherField)
}
if err := validateGatewayURL(data.URL); err != nil {
return err
}
return a.store.SetPusher(ctx, storage.Pusher{
Localpart: localpart,
DeviceID: deviceID,
Kind: *req.Kind,
AppID: req.AppID,
PushKey: req.PushKey,
AppDisplayName: req.AppDisplayName,
DeviceDisplayName: req.DeviceDisplayName,
ProfileTag: req.ProfileTag,
Lang: req.Lang,
Data: req.Data,
PushKeyTSMS: a.now().UnixMilli(),
}, req.Append)
}
// validateGatewayURL checks a pusher's gateway URL. The spec asks for
// HTTPS; plain HTTP is accepted too, because the usual deployment runs the
// gateway next to the homeserver on a private network, where requiring a
// certificate would only push operators to a worse workaround.
func validateGatewayURL(raw string) error {
u, err := url.Parse(raw)
switch {
case err != nil || u.Host == "":
return fmt.Errorf("%w: data.url must be an absolute URL", ErrInvalidPusher)
case u.Scheme != "https" && u.Scheme != "http":
return fmt.Errorf("%w: data.url must be an http or https URL", ErrInvalidPusher)
case u.Path != gatewayPath:
return fmt.Errorf("%w: data.url must have the path %s", ErrInvalidPusher, gatewayPath)
case u.User != nil:
return fmt.Errorf("%w: data.url must not carry credentials", ErrInvalidPusher)
}
return nil
}
// DevicesDeleted removes the pushers of deleted sessions: a phone that
// has logged out must stop being woken for the account. It implements
// userapi.DeviceObserver, and like every observer it logs a failure
// rather than returning it, because the device is already gone.
func (a *API) DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string) {
if err := a.store.DeletePushersForDevices(ctx, localpart, deviceIDs); err != nil {
a.log.Warn("removing the pushers of deleted devices failed",
"localpart", localpart, "devices", deviceIDs, "error", err)
}
}
// DeviceRenamed implements userapi.DeviceObserver. A pusher's device
// display name is the one the app registered it with, not the session's.
func (a *API) DeviceRenamed(context.Context, string, string) {}
// pushKeyRejected removes a pusher whose gateway said its push key is no
// longer valid — the app was uninstalled, or its registration expired —
// so that it is not pushed to again.
func (a *API) pushKeyRejected(localpart, appID, pushKey string) {
if err := a.store.DeletePusher(a.ctx, localpart, appID, pushKey); err != nil && a.ctx.Err() == nil {
a.log.Warn("removing a rejected pusher failed",
"localpart", localpart, "app_id", appID, "error", err)
return
}
a.log.Info("removed a pusher its gateway rejected", "localpart", localpart, "app_id", appID)
}
+251
View File
@@ -0,0 +1,251 @@
// Package pushserver turns events into notifications: it keeps each user's
// push rules and pushers, evaluates every event against the rules of every
// local member of its room, records what notified whom with per-room unread
// counts, and pushes to the push gateways of the users' devices.
//
// It is PLAN.md Phase 8. The rule language is internal/pushrules; this
// package is everything around it that needs storage, rooms or a network.
//
// It hangs off the roomserver's emit stage like the federation queue does,
// and like the queue it does no work there: the emit stage runs with the
// room's write lock held, and evaluating an event needs the room's members,
// their display names, the power levels and a ruleset per member. So an
// event is put on a list and a worker of this package's own takes it from
// there. What that costs is a moment in which a sync can show a message
// whose notification has not been counted yet; the counts are a stream of
// their own in the sync token (see storage.PushStore), so the sync that
// follows the worker's write carries the corrected count.
package pushserver
import (
"context"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/pushrules"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The rule-language sentinels are
// re-exported so that a handler needs only this package.
var (
ErrNoSuchRule = pushrules.ErrNoSuchRule
ErrNoSuchAnchor = pushrules.ErrNoSuchAnchor
ErrInvalidRule = pushrules.ErrInvalidRule
// ErrInvalidPusher is a pusher a client may not register.
ErrInvalidPusher = errors.New("invalid pusher")
// ErrMissingPusherField is a pusher missing a field the spec requires.
ErrMissingPusherField = errors.New("missing pusher field")
)
// Accounts is the part of the userapi this package needs: push rules are
// the m.push_rules account data. *userapi.API satisfies it.
type Accounts interface {
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
}
// Rooms is the part of the roomserver this package reads. *roomserver.API
// satisfies it.
type Rooms interface {
PushRoomFor(ctx context.Context, roomID string) (roomserver.PushRoom, error)
Event(ctx context.Context, roomID, userID, eventID string) (roomserver.ClientEvent, error)
EventPosition(ctx context.Context, roomID, userID, eventID string) (int64, error)
}
// Notifier wakes a user's blocked syncs. *syncapi.Notifier satisfies it.
type Notifier interface {
NotifyUser(userID string)
NotifyUsers(userIDs []string)
}
// API is the push server.
type API struct {
serverName string
store storage.PushStore
accounts Accounts
rooms Rooms
notifier Notifier
gateway *gateway
log *slog.Logger
now func() time.Time
// pushEnabled sends pushes to gateways. Notifications are counted
// either way: the counts are what a client shows, and are no less
// right on a server that does not push.
pushEnabled bool
// retention is how long a read notification is kept for GET
// /notifications before it is pruned.
retention time.Duration
// rules caches each user's merged ruleset. Every event is evaluated
// against the rules of every local member of its room, and reading
// and merging them from account data each time would put a query per
// member in front of every message. Every write goes through this
// package, which is what makes the cache safe in one process.
rulesMu sync.Mutex
rulesCache map[string]pushrules.Ruleset
writeLocks sync.Map // localpart -> *sync.Mutex
// The event list the emit stage appends to and the worker drains.
mu sync.Mutex
incoming []roomserver.Notification
busy bool
wake chan struct{}
idle *sync.Cond
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// Options configure the component.
type Options struct {
ServerName string
Store storage.PushStore
Accounts Accounts
Rooms Rooms
// Notifier wakes the syncs a count change concerns. Nil wakes
// nobody, which only a test wants.
Notifier Notifier
// PushEnabled sends pushes to the pushers' gateways.
PushEnabled bool
// IncludeContent puts the event's content into a push. Off, a push
// carries only who sent what kind of event where, for operators who
// would rather not have message text pass through a third-party push
// service.
IncludeContent bool
// GatewayTimeout bounds one request to a push gateway.
GatewayTimeout time.Duration
// HTTPClient sends pushes; nil builds one. Tests pass their own.
HTTPClient *http.Client
// NotificationRetention is how long a read notification is kept.
NotificationRetention time.Duration
Logger *slog.Logger
Now func() time.Time
}
// Defaults.
const (
DefaultGatewayTimeout = 10 * time.Second
DefaultNotificationRetention = 30 * 24 * time.Hour
// pruneInterval is how often read notifications past their retention
// are deleted.
pruneInterval = time.Hour
)
// New builds the component and starts its worker. Close stops it.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("pushserver: ServerName must not be empty")
case opts.Store == nil:
return nil, errors.New("pushserver: Store must not be nil")
case opts.Accounts == nil:
return nil, errors.New("pushserver: Accounts must not be nil")
case opts.Rooms == nil:
return nil, errors.New("pushserver: Rooms must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.GatewayTimeout <= 0 {
opts.GatewayTimeout = DefaultGatewayTimeout
}
if opts.NotificationRetention <= 0 {
opts.NotificationRetention = DefaultNotificationRetention
}
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: opts.GatewayTimeout}
}
ctx, cancel := context.WithCancel(context.Background())
a := &API{
serverName: opts.ServerName,
store: opts.Store,
accounts: opts.Accounts,
rooms: opts.Rooms,
notifier: opts.Notifier,
log: opts.Logger,
now: opts.Now,
pushEnabled: opts.PushEnabled,
retention: opts.NotificationRetention,
rulesCache: map[string]pushrules.Ruleset{},
wake: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
a.idle = sync.NewCond(&a.mu)
a.gateway = newGateway(gatewayOptions{
client: opts.HTTPClient,
includeContent: opts.IncludeContent,
rejected: a.pushKeyRejected,
log: opts.Logger,
})
a.wg.Add(2)
go a.work()
go a.prune()
a.gateway.start(ctx)
return a, nil
}
// Close stops the worker and the gateway senders. Events not yet
// evaluated are dropped: the emit stage is not durable, and a
// notification is not worth holding up a shutdown for.
func (a *API) Close() {
a.cancel()
a.mu.Lock()
a.idle.Broadcast()
a.mu.Unlock()
a.wg.Wait()
a.gateway.wait()
}
// notifyUsers wakes syncs, if there is anybody to wake them.
func (a *API) notifyUsers(userIDs ...string) {
if a.notifier == nil || len(userIDs) == 0 {
return
}
a.notifier.NotifyUsers(userIDs)
}
// prune deletes read notifications past their retention, hourly.
func (a *API) prune() {
defer a.wg.Done()
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
}
cutoff := a.now().Add(-a.retention).UnixMilli()
n, err := a.store.PruneNotifications(a.ctx, cutoff)
switch {
case err != nil && a.ctx.Err() == nil:
a.log.Warn("pruning read notifications failed", "error", err)
case n > 0:
a.log.Debug("pruned read notifications", "count", n)
}
}
}
+167
View File
@@ -0,0 +1,167 @@
package pushserver
import (
"context"
"encoding/json"
"errors"
"sync"
"github.com/dariuszjaniak/warp/server/internal/pushrules"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// AccountDataType is the account data push rules are kept in, and the
// type a client is refused when it tries to write them directly.
const AccountDataType = "m.push_rules"
// Push rules are the m.push_rules account data, and this is the only
// writer of it.
//
// That is where the spec has a client read them — every client takes its
// rules from the account data of its initial sync — so keeping them
// anywhere else would mean a second copy, rendered into account data on
// every change. What is stored is the whole ruleset as a client sees it.
// What is read back from it is only what the user owns: their own rules,
// and the enabled flag and actions of each default rule. The rest comes
// from pushrules.Defaults, so a server upgrade that adds a default rule
// reaches every user without rewriting anybody's account data.
// Ruleset returns a user's rules as they apply now: their own laid over
// the server defaults.
func (a *API) Ruleset(ctx context.Context, localpart, userID string) (pushrules.Ruleset, error) {
a.rulesMu.Lock()
cached, ok := a.rulesCache[localpart]
a.rulesMu.Unlock()
if ok {
return cached.Clone(), nil
}
rs, err := a.loadRuleset(ctx, localpart, userID)
if err != nil {
return pushrules.Ruleset{}, err
}
a.rulesMu.Lock()
a.rulesCache[localpart] = rs
a.rulesMu.Unlock()
return rs.Clone(), nil
}
func (a *API) loadRuleset(ctx context.Context, localpart, userID string) (pushrules.Ruleset, error) {
defaults := pushrules.Defaults(userID)
raw, err := a.accounts.AccountData(ctx, localpart, "", AccountDataType)
switch {
case errors.Is(err, storage.ErrNoSuchAccountData):
return defaults, nil
case err != nil:
return pushrules.Ruleset{}, err
}
stored, err := pushrules.ParseDocument(raw)
if err != nil {
// Only this package writes the rules, so an unreadable copy is a
// bug or a hand edit. The defaults are a better answer than
// refusing every push and every sync until somebody fixes it.
a.log.Error("stored push rules are unreadable; using the defaults",
"user_id", userID, "error", err)
return defaults, nil
}
return pushrules.Merge(stored, defaults), nil
}
// Document is a user's rules as m.push_rules content: what a sync and a
// read of the account data return.
func (a *API) Document(ctx context.Context, localpart, userID string) ([]byte, error) {
rs, err := a.Ruleset(ctx, localpart, userID)
if err != nil {
return nil, err
}
return pushrules.MarshalDocument(rs)
}
// Rule returns one rule.
func (a *API) Rule(ctx context.Context, localpart, userID string, kind pushrules.Kind, ruleID string) (pushrules.Rule, error) {
rs, err := a.Ruleset(ctx, localpart, userID)
if err != nil {
return pushrules.Rule{}, err
}
rule, ok := rs.Find(kind, ruleID)
if !ok {
return pushrules.Rule{}, ErrNoSuchRule
}
return rule, nil
}
// PutRule creates or replaces a user rule; see pushrules.Ruleset.Put for
// where it goes.
func (a *API) PutRule(ctx context.Context, localpart, userID string, kind pushrules.Kind, ruleID string, rule pushrules.NewRule, before, after string) error {
built, err := rule.Build(kind, ruleID)
if err != nil {
return err
}
return a.editRules(ctx, localpart, userID, func(rs *pushrules.Ruleset) error {
return rs.Put(kind, built, before, after)
})
}
// DeleteRule removes a user rule.
func (a *API) DeleteRule(ctx context.Context, localpart, userID string, kind pushrules.Kind, ruleID string) error {
return a.editRules(ctx, localpart, userID, func(rs *pushrules.Ruleset) error {
return rs.Delete(kind, ruleID)
})
}
// SetRuleEnabled switches a rule on or off.
func (a *API) SetRuleEnabled(ctx context.Context, localpart, userID string, kind pushrules.Kind, ruleID string, enabled bool) error {
return a.editRules(ctx, localpart, userID, func(rs *pushrules.Ruleset) error {
return rs.SetEnabled(kind, ruleID, enabled)
})
}
// SetRuleActions replaces a rule's actions.
func (a *API) SetRuleActions(ctx context.Context, localpart, userID string, kind pushrules.Kind, ruleID string, actions []json.RawMessage) error {
return a.editRules(ctx, localpart, userID, func(rs *pushrules.Ruleset) error {
return rs.SetActions(kind, ruleID, actions)
})
}
// editRules is the read-modify-write every change is, serialised per user
// so that two changes made at once cannot each start from the ruleset the
// other has not written yet.
func (a *API) editRules(ctx context.Context, localpart, userID string, edit func(*pushrules.Ruleset) error) error {
mu := a.writeLock(localpart)
mu.Lock()
defer mu.Unlock()
rs, err := a.Ruleset(ctx, localpart, userID)
if err != nil {
return err
}
if err := edit(&rs); err != nil {
return err
}
doc, err := pushrules.MarshalDocument(rs)
if err != nil {
return err
}
if err := a.accounts.SetAccountData(ctx, localpart, "", AccountDataType, doc); err != nil {
return err
}
a.rulesMu.Lock()
a.rulesCache[localpart] = rs
a.rulesMu.Unlock()
// The rules travel as account data, so a change is something every
// one of the user's syncs has to hear about now rather than at its
// timeout.
a.notifyUsers(userID)
return nil
}
func (a *API) writeLock(localpart string) *sync.Mutex {
actual, _ := a.writeLocks.LoadOrStore(localpart, &sync.Mutex{})
mu, ok := actual.(*sync.Mutex)
if !ok {
panic("pushserver: write lock map holds something that is not a mutex")
}
return mu
}
+278
View File
@@ -0,0 +1,278 @@
package pushserver
import (
"context"
"encoding/json"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/pushrules"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// EventPersisted implements roomserver.Notifier. It only queues the event:
// see the package comment for why nothing is evaluated here.
func (a *API) EventPersisted(_ context.Context, n roomserver.Notification) {
if n.Event == nil {
return
}
a.mu.Lock()
if a.ctx.Err() != nil {
a.mu.Unlock()
return
}
a.incoming = append(a.incoming, n)
a.mu.Unlock()
select {
case a.wake <- struct{}{}:
default:
}
}
// WaitIdle blocks until every event queued so far has been evaluated, or
// ctx ends. Tests use it to look at counts without sleeping, and it is the
// honest way for anything else to ask "has the push server caught up".
func (a *API) WaitIdle(ctx context.Context) error {
stop := context.AfterFunc(ctx, func() {
a.mu.Lock()
a.idle.Broadcast()
a.mu.Unlock()
})
defer stop()
a.mu.Lock()
defer a.mu.Unlock()
for len(a.incoming) > 0 || a.busy {
if err := ctx.Err(); err != nil {
return err
}
if a.ctx.Err() != nil {
return a.ctx.Err()
}
a.idle.Wait()
}
return nil
}
// work drains the event list, one event at a time and in the order they
// were persisted, so that a user's notifications are numbered in the
// order their events happened.
func (a *API) work() {
defer a.wg.Done()
for {
select {
case <-a.ctx.Done():
return
case <-a.wake:
}
for {
a.mu.Lock()
if len(a.incoming) == 0 {
a.busy = false
a.idle.Broadcast()
a.mu.Unlock()
break
}
batch := a.incoming
a.incoming = nil
a.busy = true
a.mu.Unlock()
for _, n := range batch {
if a.ctx.Err() != nil {
return
}
if err := a.process(a.ctx, n); err != nil && a.ctx.Err() == nil {
a.log.Error("evaluating push rules failed",
"room_id", n.RoomID, "event_id", n.Event.EventID(), "error", err)
}
}
}
}
}
// process evaluates one event for every local user it may concern.
func (a *API) process(ctx context.Context, n roomserver.Notification) error {
e := n.Event
sender := e.Sender()
// A local user leaving a room has nothing more to read in it, and a
// count left behind there would sit in their badge for good. The event
// is still evaluated for everybody else below.
if e.Type() == event.TypeMember {
if target, ok := e.StateKey(); ok && a.isLocal(target) {
switch membership, _ := e.Membership(); membership {
case event.MembershipLeave, event.MembershipBan:
if err := a.markRead(ctx, target, n.RoomID, n.StreamOrdering); err != nil {
return err
}
}
}
}
room, err := a.rooms.PushRoomFor(ctx, n.RoomID)
if err != nil {
return err
}
// Who the event is for: the room's local members, and the user an
// invite is addressed to, who is not a member yet. Never the sender —
// nobody is notified of what they did themselves.
var candidates []string
for userID := range room.Members {
if userID != sender && a.isLocal(userID) {
candidates = append(candidates, userID)
}
}
if e.Type() == event.TypeMember {
if target, ok := e.StateKey(); ok && target != sender && a.isLocal(target) {
if _, member := room.Members[target]; !member {
candidates = append(candidates, target)
}
}
}
if len(candidates) == 0 {
return nil
}
ev, err := pushrules.NewEvent(e.JSON())
if err != nil {
return err
}
var (
notifications []storage.NewNotification
outcomes = map[string]pushrules.Outcome{}
)
now := a.now().UnixMilli()
for _, userID := range candidates {
localpart, _, err := id.SplitUserID(userID)
if err != nil {
continue
}
rs, err := a.Ruleset(ctx, localpart, userID)
if err != nil {
return err
}
rule, _, ok := rs.Evaluate(ev, pushrules.Context{
UserID: userID,
DisplayName: room.Members[userID],
MemberCount: len(room.Members),
SenderMayNotify: func(key string) bool {
return room.SenderMayNotify(sender, key)
},
})
if !ok {
continue
}
outcome := pushrules.ParseActions(rule.Actions)
if !outcome.Notify {
continue
}
actions, err := json.Marshal(rule.Actions)
if err != nil {
return err
}
notifications = append(notifications, storage.NewNotification{
UserID: userID,
RoomID: n.RoomID,
EventID: e.EventID(),
EventPos: n.StreamOrdering,
TimestampMS: now,
Highlight: outcome.Highlight,
Actions: actions,
})
outcomes[userID] = outcome
}
if len(notifications) == 0 {
return nil
}
if _, err := a.store.AddNotifications(ctx, notifications); err != nil {
return err
}
notified := make([]string, 0, len(notifications))
for _, nn := range notifications {
notified = append(notified, nn.UserID)
}
a.notifyUsers(notified...)
if a.pushEnabled {
for _, userID := range notified {
a.push(ctx, userID, room, n, outcomes[userID])
}
}
return nil
}
// push hands one notification to the gateways of a user's pushers.
func (a *API) push(ctx context.Context, userID string, room roomserver.PushRoom, n roomserver.Notification, outcome pushrules.Outcome) {
localpart, _, err := id.SplitUserID(userID)
if err != nil {
return
}
pushers, err := a.store.Pushers(ctx, localpart)
if err != nil {
a.log.Warn("reading pushers failed", "user_id", userID, "error", err)
return
}
if len(pushers) == 0 {
return
}
unread, err := a.store.UnreadNotifications(ctx, userID)
if err != nil {
a.log.Warn("counting unread notifications failed", "user_id", userID, "error", err)
}
e := n.Event
note := notificationPush{
EventID: e.EventID(),
RoomID: n.RoomID,
Type: e.Type(),
Sender: e.Sender(),
SenderDisplayName: room.Members[e.Sender()],
RoomName: room.Name,
RoomAlias: room.CanonicalAlias,
Content: e.Content(),
Counts: pushCounts{Unread: unread},
Prio: priority(outcome),
}
if stateKey, ok := e.StateKey(); ok && e.Type() == event.TypeMember {
isTarget := stateKey == userID
note.UserIsTarget = &isTarget
if membership, err := e.Membership(); err == nil {
note.Membership = membership
}
}
for _, p := range pushers {
if p.Kind != PusherKindHTTP {
continue
}
a.gateway.enqueue(pushJob{
localpart: localpart,
pusher: p,
note: note,
tweaks: outcome.Tweaks,
})
}
}
// priority is a push's prio: high for anything that makes a sound or is a
// highlight, which is what a phone should wake its user for, and low for
// the rest, which a gateway may batch to save the battery.
func priority(o pushrules.Outcome) string {
if o.Highlight {
return "high"
}
if _, ok := o.Tweaks[pushrules.TweakSound]; ok {
return "high"
}
return "low"
}
// isLocal reports whether a user ID belongs to this server.
func (a *API) isLocal(userID string) bool {
server, err := id.ServerNameOf(userID)
return err == nil && server == a.serverName
}