package syncapi import ( "context" "encoding/json" "errors" "fmt" "maps" "slices" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Presence: who is online, and the status message they set. // // It is off by default (PLAN.md open question 3). Presence is the single // noisiest thing in Matrix — every user's state change is sent to everybody // who shares a room with them, which over federation is quadratic in the // worst case, and Synapse deployments routinely turn it off for exactly // that reason. A private homeserver with a dozen users has no such problem, // so it is a switch rather than a decision. // // With it off, the endpoints answer M_FORBIDDEN and the presence section of // a sync response is empty. They are not unregistered: a client that gets // a 404 from /presence concludes the server is too old to have it and // stops asking, whereas a refusal says this server has it switched off. // The presence states the spec defines. const ( PresenceOnline = "online" PresenceOffline = "offline" PresenceUnavailable = "unavailable" ) // maxStatusMsgLength caps a status message. The spec sets no limit; this is // long enough for a sentence and short enough that it cannot be used as // storage. const maxStatusMsgLength = 512 // PresenceRequest is PUT /presence/{userID}/status. type PresenceRequest struct { // User is the full user ID whose presence is being set, which the // handler has already checked is the caller. User string Presence string // StatusMsg is the free-text status. An explicitly empty string // clears it, which is why this is a pointer: a request that does not // mention it leaves it alone. StatusMsg *string } // SetPresence records a user's presence. func (s *API) SetPresence(ctx context.Context, req PresenceRequest) error { if !s.presenceEnabled { return ErrPresenceDisabled } switch req.Presence { case PresenceOnline, PresenceOffline, PresenceUnavailable: default: return errInvalidParamf("%q is not a presence state", req.Presence) } if req.StatusMsg != nil && len(*req.StatusMsg) > maxStatusMsgLength { return errInvalidParamf("a status message may be at most %d bytes", maxStatusMsgLength) } state := storage.PresenceState{ UserID: req.User, Presence: req.Presence, LastActiveMS: s.nowMS(), } // Keep the status message a request did not mention. if req.StatusMsg != nil { state.StatusMsg = *req.StatusMsg } else { switch existing, err := s.store.Presence(ctx, req.User); { case err == nil: state.StatusMsg = existing.StatusMsg case !errors.Is(err, storage.ErrNoSuchUser): return err } } _, moved, err := s.store.SetPresence(ctx, state) if err != nil { return err } // Nothing a peer would notice changed, so there is nobody to wake. if !moved { return nil } return s.notifyPresence(ctx, req.User) } // Presence reads a user's presence, which GET /presence/{userID}/status // answers. // // A user nothing has ever been recorded for is offline rather than unknown. // That is what the spec says to report and it is also the truth: a user who // has never told the server they were online is not online. func (s *API) Presence(ctx context.Context, userID string) (storage.PresenceState, error) { if !s.presenceEnabled { return storage.PresenceState{}, ErrPresenceDisabled } if !s.isLocal(userID) { // Remote presence is not fetched over federation. Reporting offline // would be a guess; saying so is not. return storage.PresenceState{}, errForbiddenf( "%s belongs to another server and federation is required to ask it", userID) } switch state, err := s.store.Presence(ctx, userID); { case err == nil: return state, nil case errors.Is(err, storage.ErrNoSuchUser): return storage.PresenceState{UserID: userID, Presence: PresenceOffline}, nil default: return storage.PresenceState{}, err } } // notifyPresence wakes the users who share a room with this one. // // Presence is published to everybody a user shares a room with, which is // the set this walks: their rooms, and the members of each. It is the one // genuinely fan-out notification in the component, and the reason presence // is a switch. func (s *API) notifyPresence(ctx context.Context, userID string) error { rooms, err := s.rooms.JoinedRooms(ctx, userID) if err != nil { return err } peers := map[string]struct{}{} for _, roomID := range rooms { members, err := s.rooms.JoinedMembers(ctx, roomID, userID) if err != nil { return err } for member := range members { peers[member] = struct{}{} } } delete(peers, userID) s.notifier.NotifyUsers(slices.Sorted(maps.Keys(peers))) // Their own other devices want it too. s.notifier.NotifyUser(userID) return nil } // presenceEvents reads the presence of everybody the user shares a room // with, between two positions. func (s *API) presenceEvents(ctx context.Context, userID string, rooms []string, from, to int64, filter storage.TimelineFilter) ([]presenceEvent, error) { if !s.presenceEnabled || len(rooms) == 0 { return nil, nil } peers := map[string]struct{}{userID: {}} for _, roomID := range rooms { entries, err := s.rooms.JoinedMembers(ctx, roomID, userID) if err != nil { // A room the user is no longer joined to is not an error here: // their presence section simply does not include its members. if errors.Is(err, ErrForbidden) { continue } return nil, err } for member := range entries { peers[member] = struct{}{} } } states, err := s.store.PresenceForUsers(ctx, slices.Sorted(maps.Keys(peers)), from, to) if err != nil { return nil, err } out := make([]presenceEvent, 0, len(states)) for _, state := range states { e, err := newPresenceEvent(state, s.nowMS()) if err != nil { return nil, err } if !matchesEventFilter(e.Type, e.Sender, filter) { continue } out = append(out, e) } return out, nil } // presenceEvent is the m.presence EDU of one user. type presenceEvent struct { Type string `json:"type"` Sender string `json:"sender"` Content json.RawMessage `json:"content"` } // newPresenceEvent builds the EDU for one user's state. // // `last_active_ago` is computed on the way out rather than stored, for the // same reason `age` is on an event: a value written down at write time // would be wrong by however long the row sat in the database. func newPresenceEvent(state storage.PresenceState, nowMS int64) (presenceEvent, error) { content := map[string]any{ "presence": state.Presence, "currently_active": state.Presence == PresenceOnline, } if state.StatusMsg != "" { content["status_msg"] = state.StatusMsg } if state.LastActiveMS > 0 { content["last_active_ago"] = max(nowMS-state.LastActiveMS, 0) } body, err := json.Marshal(content) if err != nil { return presenceEvent{}, fmt.Errorf("marshalling the presence of %s: %w", state.UserID, err) } return presenceEvent{Type: "m.presence", Sender: state.UserID, Content: body}, nil } // matchesEventFilter is the filter check for the streams whose events are // not room events: presence and global account data. Their filters have the // same types and senders fields, and exclusions win the same way. func matchesEventFilter(eventType, sender string, filter storage.TimelineFilter) bool { switch { case anyMatch(eventType, filter.NotTypes): return false case sender != "" && anyMatch(sender, filter.NotSenders): return false case len(filter.Types) > 0 && !anyMatch(eventType, filter.Types): return false case len(filter.Senders) > 0 && sender != "" && !anyMatch(sender, filter.Senders): return false } return true } // anyMatch applies a list of filter patterns. func anyMatch(value string, patterns []string) bool { for _, pattern := range patterns { if matches(value, pattern) { return true } } return false }