package roomserver import ( "context" "strings" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/storage" ) // RoomSyncRequest asks what one user sees of one room between two // positions. // // It is the read model's largest operation and the one /sync is built on. // It is a single call rather than a timeline read plus two state reads // because the three answers have to agree with each other: which state the // response reports depends on where the timeline turned out to start, and // working that out in the caller would mean the caller deciding what the // timeline was — which is the question it asked. type RoomSyncRequest struct { RoomID string // User is the full user ID syncing. User string // Since is the position the user has already seen, exclusive. Zero is // an initial sync. Since int64 // To is the top of this sync, inclusive. It is fixed by the caller for // every room in one response, so that a token names one moment rather // than a different one per room. To int64 // TimelineLimit caps the events in the timeline. TimelineLimit int // TimelineFilter and StateFilter narrow the two halves of the // response. The state filter is applied in this package rather than in // SQL: a state read is a set of tuples, not a window of a stream. TimelineFilter storage.TimelineFilter StateFilter storage.TimelineFilter // FullState asks for the room's whole state even on an incremental // sync, which is what ?full_state=true means. FullState bool // LazyLoadMembers leaves the member events out of the state except for // the senders who appear in the timeline. LazyLoadMembers bool } // RoomSync is what one user sees of one room between two positions. type RoomSync struct { // Membership is the user's current membership in the room. Membership string Timeline Timeline // State is the room's state as of the start of the timeline on an // initial sync, and what changed between Since and the start of the // timeline on an incremental one. For an incremental sync with no gap // it is empty: every state change in the window is in the timeline // itself. State []ClientEvent // Summary is the room's member counts and heroes. Summary RoomSyncSummary // Visible is the span of the stream the user may read, which the // caller needs in order to bound the ephemeral streams the same way. Visible VisibleRange } // RoomSync reads one room for one user. func (r *API) RoomSync(ctx context.Context, req RoomSyncRequest) (RoomSync, error) { room, rules, err := r.roomAndRules(ctx, req.RoomID) if err != nil { return RoomSync{}, err } summary, err := r.store.RoomSummary(ctx, room.ID) if err != nil { return RoomSync{}, err } visible, err := r.visibleRange(ctx, room.ID, rules, req.User, summary.HistoryVisibility) if err != nil { return RoomSync{}, err } if err := visible.refusal(room.ID, req.User); err != nil { return RoomSync{}, err } out := RoomSync{Membership: visible.Membership, Visible: visible} // The timeline is read backwards even though it is reported forwards. // A client wants the *newest* events of the window it has missed — the // bottom of the room, which is what it is looking at — and a forward // read with a limit would hand it the oldest instead and then call the // rest a gap. out.Timeline, err = r.timeline(ctx, rules, visible, TimelineRequest{ RoomID: req.RoomID, User: req.User, Low: req.Since, High: req.To, Direction: storage.DirectionBackward, Limit: req.TimelineLimit, Filter: req.TimelineFilter, }) if err != nil { return RoomSync{}, err } if out.State, err = r.syncState(ctx, rules, visible, req, out.Timeline); err != nil { return RoomSync{}, err } if out.Summary, err = r.RoomSummaryFor(ctx, req.RoomID, req.User); err != nil { return RoomSync{}, err } return out, nil } // syncState decides which state a room's half of a sync response reports. // // The rule the spec sets is that a client which applies `state` and then // `timeline` ends up with the room's state at the end of the timeline. That // gives three cases: // // - An initial sync, or ?full_state=true: the whole state as of the start // of the timeline. // - An incremental sync whose timeline reaches back to `since`: nothing. // Every state change in between is a timeline event the client is // already being given. // - An incremental sync whose timeline was cut short — a gap: what // changed between `since` and where the timeline starts. Those changes // are not in the timeline and the client would otherwise never see // them. func (r *API) syncState(ctx context.Context, rules roomversion.Rules, visible VisibleRange, req RoomSyncRequest, timeline Timeline) ([]ClientEvent, error) { statePos, haveState := statePosition(timeline, req.To, visible) switch { case !haveState: // The timeline reaches back to the room's create event, so there // is nothing before it to report. return nil, nil case req.FullState || req.Since == 0: state, err := r.stateAt(ctx, req.RoomID, rules, statePos) if err != nil { return nil, err } return r.shapeState(ctx, rules, req, timeline, state) case !timeline.Limited: return nil, nil default: before, err := r.stateAt(ctx, req.RoomID, rules, req.Since) if err != nil { return nil, err } after, err := r.stateAt(ctx, req.RoomID, rules, statePos) if err != nil { return nil, err } return r.shapeState(ctx, rules, req, timeline, stateDelta(before, after)) } } // statePosition is the position a room's state is reported as of, and // whether there is any state to report at all. // // The state belongs at the start of the timeline, so that applying it and // then the timeline lands on the room's current state. A room with no // timeline events in this window is reported as of the top of the sync // instead: the state may still have been read for the first time. func statePosition(timeline Timeline, to int64, visible VisibleRange) (int64, bool) { if len(timeline.Events) > 0 { return timeline.Start, timeline.Start > 0 } if visible.To != 0 && (to == 0 || to > visible.To) { to = visible.To } return to, to > 0 } // shapeState applies the state filter and lazy loading to a state set. func (r *API) shapeState(ctx context.Context, rules roomversion.Rules, req RoomSyncRequest, timeline Timeline, state []ClientEvent) ([]ClientEvent, error) { state = filterClientEvents(state, req.StateFilter) if !req.LazyLoadMembers { return state, nil } // Lazy loading: drop the member events and put back only the ones the // client needs to render the timeline it is being given. // // The spec also lets a server leave out members it has already sent // this device, which would need per-device bookkeeping of what each // one has seen. Warp sends them every time instead: it is the // behaviour `include_redundant_members: true` asks for, so no client // can be surprised by it, and it costs one state read rather than a // table. kept := make([]ClientEvent, 0, len(state)) for _, e := range state { if e.Type != event.TypeMember { kept = append(kept, e) } } senders := timelineSenders(timeline) members, err := r.memberEvents(ctx, req.RoomID, rules, senders) if err != nil { return nil, err } return append(kept, filterClientEvents(members, req.StateFilter)...), nil } // timelineSenders lists the distinct senders of a timeline, in the order // they first appear. func timelineSenders(timeline Timeline) []string { seen := make(map[string]struct{}, len(timeline.Events)) out := make([]string, 0, len(timeline.Events)) for _, e := range timeline.Events { if _, dup := seen[e.Sender]; dup { continue } seen[e.Sender] = struct{}{} out = append(out, e.Sender) } return out } // stateKey identifies one piece of state. type stateKeyTuple struct { eventType string stateKey string } // tupleOf is the state tuple of a client event, and whether it has one. func tupleOf(e ClientEvent) (stateKeyTuple, bool) { if e.StateKey == nil { return stateKeyTuple{}, false } return stateKeyTuple{eventType: e.Type, stateKey: *e.StateKey}, true } // stateDelta is what changed between two state sets: every tuple of `after` // whose event is not the one `before` had. // // A tuple that `before` had and `after` does not is left out. State is only // ever replaced, never removed — there is no way to unset a piece of room // state — so such a tuple can only come from state resolution dropping an // event, and there is nothing a client could be sent that would express it. func stateDelta(before, after []ClientEvent) []ClientEvent { had := make(map[stateKeyTuple]string, len(before)) for _, e := range before { if tuple, ok := tupleOf(e); ok { had[tuple] = e.EventID } } out := make([]ClientEvent, 0, len(after)) for _, e := range after { tuple, ok := tupleOf(e) if !ok { continue } if had[tuple] == e.EventID { continue } out = append(out, e) } return out } // filterClientEvents applies the part of an event filter that is a // comparison on the event itself. // // It is the same filter the store applies to a timeline read, applied in Go // because a state read is a set of tuples rather than a window of a stream: // there is no index to push it down to. func filterClientEvents(events []ClientEvent, filter storage.TimelineFilter) []ClientEvent { if filter.IsEmpty() { return events } out := make([]ClientEvent, 0, len(events)) for _, e := range events { if matchesFilter(e, filter) { out = append(out, e) } } return out } // matchesFilter reports whether one event passes a filter. Exclusions win // over inclusions, which is what the spec requires. func matchesFilter(e ClientEvent, filter storage.TimelineFilter) bool { switch { case matchesAny(e.Type, filter.NotTypes): return false case matchesAny(e.Sender, filter.NotSenders): return false case len(filter.Types) > 0 && !matchesAny(e.Type, filter.Types): return false case len(filter.Senders) > 0 && !matchesAny(e.Sender, filter.Senders): return false } return true } // matchesAny reports whether value matches one of the filter patterns, each // of which may end in '*' as the spec's wildcard. func matchesAny(value string, patterns []string) bool { for _, pattern := range patterns { if prefix, isWildcard := strings.CutSuffix(pattern, "*"); isWildcard { if strings.HasPrefix(value, prefix) { return true } continue } if value == pattern { return true } } return false }