package roomserver import ( "context" "encoding/json" "fmt" "slices" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/storage" ) // The read model: the event stream as /sync, /messages and /context read // it. // // It is here rather than in the syncapi because reading an event means // parsing it under its room's version, and that is the roomserver's // business — the syncapi would otherwise need its own copy of the room // version registry, the event parser and the client projection, and a // second copy of those would be a second place for them to disagree. What // the syncapi gets instead is windows of ClientEvents with their positions, // and it assembles the response shape around them. // MembershipAt is a user's current membership in a room, with the position // of the event that set it. type MembershipAt struct { RoomID string Membership string EventID string // Position is the stream position of the membership event. Position int64 } // TimelineRequest is one window of a room's event stream. // // The bounds are the store's: Low exclusive, High inclusive, both // directions, and zero meaning unbounded at that end. Direction decides // only which end the limit trims and the order the store reads in — the // events always come back oldest first. type TimelineRequest struct { RoomID string // User is the full user ID the window is read on behalf of. The result // is clamped to what their membership and the room's history // visibility allow. User string Low int64 High int64 Direction storage.TimelineDirection // Limit caps how many events come back. Limit int // Filter narrows which events are read. Applying it here rather than // after the limit is what keeps a filtered page from coming back short // and looking like the end of the room. Filter storage.TimelineFilter } // Timeline is a window of a room's event stream. type Timeline struct { // Events are in stream order, oldest first, whichever direction was // asked for: a client renders a timeline forwards even when it paged // backwards to get it. Events []ClientEvent // Start and End are the positions bounding the window: Start below the // first event and End at the last. They are the two tokens a client // needs to ask for the next page in either direction, and either can // be handed back as a bound without re-reading the event it names. Start int64 End int64 // Limited reports that the limit cut the window short, so there is // more of it beyond the end the limit trimmed. Limited bool } // StreamTop returns the top of this server's event stream. func (r *API) StreamTop(ctx context.Context) (int64, error) { return r.store.MaxStreamOrdering(ctx) } // UserMemberships returns the rooms a user has one of the given memberships // in, with the position of each membership event. func (r *API) UserMemberships(ctx context.Context, userID string, memberships []string) ([]MembershipAt, error) { stored, err := r.store.MembershipsForUser(ctx, userID, memberships) if err != nil { return nil, err } out := make([]MembershipAt, 0, len(stored)) for _, m := range stored { out = append(out, MembershipAt{ RoomID: m.RoomID, Membership: m.Membership, EventID: m.EventID, Position: m.StreamOrdering, }) } return out, nil } // RoomsWithEvents returns the subset of rooms that have an event after from // and at or before to. func (r *API) RoomsWithEvents(ctx context.Context, roomIDs []string, from, to int64) ([]string, error) { return r.store.RoomsWithEvents(ctx, roomIDs, from, to) } // Timeline reads one window of a room's event stream, as far as the user is // allowed to see it. func (r *API) Timeline(ctx context.Context, req TimelineRequest) (Timeline, error) { room, rules, err := r.roomAndRules(ctx, req.RoomID) if err != nil { return Timeline{}, err } visible, err := r.requireReadable(ctx, room.ID, req.User) if err != nil { return Timeline{}, err } return r.timeline(ctx, rules, visible, req) } // timeline is Timeline with the room's rules and the user's visible range // already in hand, for the sync loop that reads many rooms at once. func (r *API) timeline(ctx context.Context, rules roomversion.Rules, visible VisibleRange, req TimelineRequest) (Timeline, error) { // An empty window is reported at the bound the caller came in on, so // that the tokens it hands back are still usable. empty := Timeline{Start: req.Low, End: req.Low} if req.Direction == storage.DirectionBackward && req.High > 0 { empty = Timeline{Start: req.High, End: req.High} } if req.Limit <= 0 { return empty, nil } low, high, any := visible.Clamp(req.Low, req.High) if !any { return empty, nil } // One more than asked for, so that "there is more" can be told from // "that was all" without a second query. stored, err := r.store.Timeline(ctx, storage.TimelineRequest{ RoomID: req.RoomID, Low: low, High: high, Direction: req.Direction, Limit: req.Limit + 1, Filter: req.Filter, }) if err != nil { return Timeline{}, err } out := empty if len(stored) > req.Limit { out.Limited = true stored = stored[:req.Limit] } if len(stored) == 0 { return out, nil } // The store returned them in the direction asked for; a client always // renders a timeline oldest first. if req.Direction == storage.DirectionBackward { slices.Reverse(stored) } out.Events = make([]ClientEvent, 0, len(stored)) for _, s := range stored { e, err := parseEvent(s, rules) if err != nil { return Timeline{}, err } out.Events = append(out.Events, r.clientEvent(s, e, req.RoomID)) } // Start sits below the oldest event, so that handing it back as a High // bound returns the page before this one; End is at the newest, so that // handing it back as a Low bound returns the page after. out.Start = stored[0].StreamOrdering - 1 out.End = stored[len(stored)-1].StreamOrdering return out, nil } // EventPosition returns an event's position in the stream, for a user who // may see it. // // /context needs it: the timeline reads are bounded by position rather than // by event ID, so the event a context is centred on has to be located // before either side of it can be read. func (r *API) EventPosition(ctx context.Context, roomID, userID, eventID string) (int64, error) { visible, err := r.requireReadable(ctx, roomID, userID) if err != nil { return 0, err } stored, err := r.store.Event(ctx, roomID, eventID) if err != nil { return 0, err } if !visible.Contains(stored.StreamOrdering) { return 0, fmt.Errorf("event %q: %w", eventID, ErrNoSuchEvent) } return stored.StreamOrdering, nil } // StateAt returns a room's state as of a stream position, in the client // shape. // // It is what a sync with a gap in it and a leaver's state read both need: // the state current state cannot give, because the room has moved on since. func (r *API) StateAt(ctx context.Context, roomID, userID string, pos int64) ([]ClientEvent, error) { room, rules, err := r.roomAndRules(ctx, roomID) if err != nil { return nil, err } visible, err := r.requireReadable(ctx, room.ID, userID) if err != nil { return nil, err } if visible.To != 0 && (pos == 0 || pos > visible.To) { pos = visible.To } return r.stateAt(ctx, roomID, rules, pos) } // stateAt is StateAt with the checks already done. // // A position of zero is the room's current state, which is both cheaper to // read and what every caller that is up to date wants. func (r *API) stateAt(ctx context.Context, roomID string, rules roomversion.Rules, pos int64) ([]ClientEvent, error) { var ( entries []storage.StateEntry err error ) if pos == 0 { stored, err := r.store.CurrentStateEvents(ctx, roomID) if err != nil { return nil, err } return r.clientEvents(stored, rules, roomID) } if entries, err = r.store.StateAtStream(ctx, roomID, pos); err != nil { return nil, err } return r.clientEventsByID(ctx, roomID, rules, stateEntryIDs(entries)) } // StateBeforeEvent returns the state of a room as of an event's // prev_events: the state /context reports alongside it. func (r *API) StateBeforeEvent(ctx context.Context, roomID, userID, eventID string) ([]ClientEvent, error) { room, rules, err := r.roomAndRules(ctx, roomID) if err != nil { return nil, err } visible, err := r.requireReadable(ctx, room.ID, userID) if err != nil { return nil, err } stored, err := r.store.Event(ctx, roomID, eventID) if err != nil { return nil, err } if !visible.Contains(stored.StreamOrdering) { return nil, fmt.Errorf("%w: %s may not see %s", ErrForbidden, userID, eventID) } entries, err := r.store.StateBefore(ctx, roomID, eventID) if err != nil { return nil, err } state, err := r.clientEventsByID(ctx, roomID, rules, stateEntryIDs(entries)) if err != nil { return nil, err } // The event itself, when it is a state event, is part of the state // after it but not before it. /context wants the state the event was // sent against, so it is left out — except that a client rendering the // event needs the sender's member event, which is in the snapshot // already. return state, nil } // MemberEvents returns the m.room.member events of specific users in a // room, in the client shape. // // Lazy-loading is what this is for: a client that asked for it is sent the // members who appear in the timeline it was given, rather than all of them. func (r *API) MemberEvents(ctx context.Context, roomID string, userIDs []string) ([]ClientEvent, error) { if len(userIDs) == 0 { return nil, nil } _, rules, err := r.roomAndRules(ctx, roomID) if err != nil { return nil, err } return r.memberEvents(ctx, roomID, rules, userIDs) } // memberEvents is MemberEvents with the rules in hand. func (r *API) memberEvents(ctx context.Context, roomID string, rules roomversion.Rules, userIDs []string) ([]ClientEvent, error) { out := make([]ClientEvent, 0, len(userIDs)) for _, userID := range userIDs { stored, err := r.store.CurrentStateEvent(ctx, roomID, event.TypeMember, userID) switch { case isNotFound(err): // A member who appears in the timeline but is not in the // room's current state: their membership was reset by state // resolution, or the event is old enough to predate it. Not an // error, and nothing to send. continue case err != nil: return nil, err } e, err := parseEvent(stored, rules) if err != nil { return nil, err } out = append(out, r.clientEvent(stored, e, roomID)) } return out, nil } // StrippedEvent is a piece of room state as it travels with an invite: // enough for a client to show the user what they are being asked to join, // and nothing that would let them read the room. // // It has no event ID and no timestamp on purpose — the spec's "stripped // state" is explicitly not an event, and a client must not put it in a // timeline. type StrippedEvent struct { Type string `json:"type"` StateKey string `json:"state_key"` Sender string `json:"sender"` Content json.RawMessage `json:"content"` } // strippedStateTypes is what the spec says an invite carries: the state a // client needs to render the room in an invite list. var strippedStateTypes = []string{ event.TypeCreate, event.TypeJoinRules, TypeCanonicalAlias, TypeName, TypeAvatar, TypeTopic, TypeEncryption, event.TypeHistoryVisibility, } // StrippedState returns the stripped state of a room, plus the member // events of the users named. // // The member events are what makes an invite legible: a client showing // "Alice invited you" needs Alice's display name, and the invitee's own // member event carries the invite's reason. func (r *API) StrippedState(ctx context.Context, roomID string, members []string) ([]StrippedEvent, error) { _, rules, err := r.roomAndRules(ctx, roomID) if err != nil { return nil, err } return r.strippedState(ctx, roomID, rules, members) } // strippedState is StrippedState with the rules in hand. func (r *API) strippedState(ctx context.Context, roomID string, rules roomversion.Rules, members []string) ([]StrippedEvent, error) { var out []StrippedEvent appendState := func(eventType, stateKey string) error { stored, err := r.store.CurrentStateEvent(ctx, roomID, eventType, stateKey) switch { case isNotFound(err): return nil case err != nil: return err } e, err := parseEvent(stored, rules) if err != nil { return err } key, _ := e.StateKey() out = append(out, StrippedEvent{ Type: e.Type(), StateKey: key, Sender: e.Sender(), Content: e.Content(), }) return nil } for _, eventType := range strippedStateTypes { if err := appendState(eventType, ""); err != nil { return nil, err } } for _, member := range members { if err := appendState(event.TypeMember, member); err != nil { return nil, err } } return out, nil } // RoomSyncSummary is the `summary` block of a room in a sync response. // // It exists for rooms with no name: a client showing a direct message has // to call it something, and the heroes are the members it names it after. type RoomSyncSummary struct { // Heroes are up to five members of the room other than the user // syncing, which is what a client falls back to for a room with no // name and no canonical alias. Heroes []string `json:"m.heroes,omitempty"` JoinedMemberCount int `json:"m.joined_member_count"` InvitedMemberCount int `json:"m.invited_member_count"` } // maxHeroes is how many members a summary names. The spec says five. const maxHeroes = 5 // RoomSummaryFor builds the summary block of a room for one user. func (r *API) RoomSummaryFor(ctx context.Context, roomID, userID string) (RoomSyncSummary, error) { entries, err := r.store.MembershipEntries(ctx, roomID, []string{event.MembershipJoin, event.MembershipInvite}) if err != nil { return RoomSyncSummary{}, err } var summary RoomSyncSummary for _, entry := range entries { switch entry.Membership { case event.MembershipJoin: summary.JoinedMemberCount++ case event.MembershipInvite: summary.InvitedMemberCount++ } if entry.UserID != userID && len(summary.Heroes) < maxHeroes { summary.Heroes = append(summary.Heroes, entry.UserID) } } return summary, nil } // clientEvents projects stored events into the client shape. func (r *API) clientEvents(stored []storage.StoredEvent, rules roomversion.Rules, roomID string) ([]ClientEvent, error) { out := make([]ClientEvent, 0, len(stored)) for _, s := range stored { e, err := parseEvent(s, rules) if err != nil { return nil, err } out = append(out, r.clientEvent(s, e, roomID)) } return out, nil } // clientEventsByID reads events by ID and projects them. func (r *API) clientEventsByID(ctx context.Context, roomID string, rules roomversion.Rules, eventIDs []string) ([]ClientEvent, error) { if len(eventIDs) == 0 { return nil, nil } stored, err := r.store.EventsByID(ctx, roomID, eventIDs) if err != nil { return nil, err } return r.clientEvents(stored, rules, roomID) } // stateEntryIDs projects state entries onto the event IDs behind them. func stateEntryIDs(entries []storage.StateEntry) []string { out := make([]string, 0, len(entries)) for _, entry := range entries { out = append(out, entry.EventID) } return out }