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