Files
Warp/server/internal/pushserver/pushers.go
T
tajniak81andClaude Opus 5.5 b507753172 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>
2026-10-17 11:43:36 +02:00

180 lines
6.5 KiB
Go

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