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:
1 parent
74f7bc8805
commit
b507753172
47 files changed
+5136
-50
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user