CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1002 lines
31 KiB
Go
1002 lines
31 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
"github.com/dariuszjaniak/warp/server/internal/tracing"
|
|
)
|
|
|
|
// Simplified Sliding Sync (MSC4186): /sync for clients with a thousand
|
|
// rooms that want to show the top twenty.
|
|
//
|
|
// A classic initial sync sends every room a user is in, with its state and
|
|
// a timeline, before the client can draw anything; a client with a large
|
|
// account waits minutes. Sliding sync turns that round. The client asks for
|
|
// lists — "my rooms, most recently active first, positions 0 to 19" — and
|
|
// for the few pieces of state it needs to draw each one, and the server
|
|
// sends those rooms and a count of the rest. As the user scrolls, the
|
|
// client widens the window; as rooms become active, they move into it and
|
|
// are sent.
|
|
//
|
|
// What the server keeps for this is per connection (slidingconn.go): which
|
|
// rooms it has sent and as of which position, so that a room is sent once
|
|
// in full and then only as it changes, and a room that leaves the window
|
|
// and comes back is not sent again unless something happened to it. The
|
|
// streams that are not about one room — to-device messages, device lists,
|
|
// account data, receipts, typing — are extensions a client turns on, and
|
|
// are read with the same helpers classic /sync uses.
|
|
|
|
// SlidingRequest is one sliding sync request.
|
|
type SlidingRequest struct {
|
|
User string
|
|
Localpart string
|
|
Device string
|
|
|
|
// Pos is the ?pos= of the previous response, empty for a new
|
|
// connection.
|
|
Pos string
|
|
|
|
// Timeout is how long to wait for something to happen.
|
|
Timeout time.Duration
|
|
|
|
// SetPresence is ?set_presence=, as for /sync.
|
|
SetPresence string
|
|
|
|
Body SlidingBody
|
|
}
|
|
|
|
// SlidingBody is the request body.
|
|
type SlidingBody struct {
|
|
// ConnID tells apart the connections one device keeps: a client with a
|
|
// room list and a notification process runs two.
|
|
ConnID string `json:"conn_id,omitempty"`
|
|
|
|
Lists map[string]SlidingList `json:"lists,omitempty"`
|
|
RoomSubscriptions map[string]RoomSubscription `json:"room_subscriptions,omitempty"`
|
|
Extensions SlidingExtensionsRequest `json:"extensions,omitempty"`
|
|
}
|
|
|
|
// RoomSubscription is what a client wants of each room it is sent.
|
|
type RoomSubscription struct {
|
|
// RequiredState lists [type, state_key] pairs. "*" is a wildcard in
|
|
// either place, "$ME" is the user, and ["m.room.member", "$LAZY"] is the
|
|
// members who sent something in the timeline.
|
|
RequiredState [][]string `json:"required_state,omitempty"`
|
|
|
|
TimelineLimit int `json:"timeline_limit,omitempty"`
|
|
}
|
|
|
|
// SlidingList is one room list.
|
|
type SlidingList struct {
|
|
RoomSubscription
|
|
|
|
// Ranges are inclusive windows into the sorted list.
|
|
Ranges [][2]int `json:"ranges,omitempty"`
|
|
|
|
Filters *SlidingFilters `json:"filters,omitempty"`
|
|
}
|
|
|
|
// SlidingFilters narrows which rooms a list holds.
|
|
type SlidingFilters struct {
|
|
IsDM *bool `json:"is_dm,omitempty"`
|
|
Spaces []string `json:"spaces,omitempty"`
|
|
IsEncrypted *bool `json:"is_encrypted,omitempty"`
|
|
IsInvite *bool `json:"is_invite,omitempty"`
|
|
|
|
// RoomTypes and NotRoomTypes match m.room.create's type; a null entry
|
|
// is a room with no type, which is to say an ordinary room.
|
|
RoomTypes []*string `json:"room_types,omitempty"`
|
|
NotRoomTypes []*string `json:"not_room_types,omitempty"`
|
|
|
|
Tags []string `json:"tags,omitempty"`
|
|
NotTags []string `json:"not_tags,omitempty"`
|
|
}
|
|
|
|
// extensionToggle is the shape every extension's request shares.
|
|
type extensionToggle struct {
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
|
|
// Lists and Rooms narrow which rooms a per-room extension reports on;
|
|
// absent means every room in the response's lists and subscriptions.
|
|
Lists []string `json:"lists,omitempty"`
|
|
Rooms []string `json:"rooms,omitempty"`
|
|
}
|
|
|
|
func (t *extensionToggle) on() bool { return t != nil && t.Enabled != nil && *t.Enabled }
|
|
|
|
// SlidingExtensionsRequest is which extensions the client wants.
|
|
type SlidingExtensionsRequest struct {
|
|
ToDevice *struct {
|
|
extensionToggle
|
|
Since string `json:"since,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
} `json:"to_device,omitempty"`
|
|
E2EE *extensionToggle `json:"e2ee,omitempty"`
|
|
AccountData *extensionToggle `json:"account_data,omitempty"`
|
|
Receipts *extensionToggle `json:"receipts,omitempty"`
|
|
Typing *extensionToggle `json:"typing,omitempty"`
|
|
}
|
|
|
|
// SlidingResponse is one response.
|
|
type SlidingResponse struct {
|
|
Pos string `json:"pos"`
|
|
Lists map[string]SlidingListResult `json:"lists"`
|
|
Rooms map[string]*SlidingRoom `json:"rooms"`
|
|
Extensions SlidingExtensions `json:"extensions"`
|
|
}
|
|
|
|
// SlidingListResult is how many rooms a list holds.
|
|
type SlidingListResult struct {
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// SlidingHero is a member a room without a name is named after.
|
|
type SlidingHero struct {
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"displayname,omitempty"`
|
|
AvatarURL string `json:"avatar_url,omitempty"`
|
|
}
|
|
|
|
// SlidingRoom is one room of a response.
|
|
type SlidingRoom struct {
|
|
Name string `json:"name,omitempty"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
Heroes []SlidingHero `json:"heroes,omitempty"`
|
|
IsDM bool `json:"is_dm,omitempty"`
|
|
|
|
// Initial says this is the room in full: the client replaces what it
|
|
// had rather than adding to it.
|
|
Initial bool `json:"initial,omitempty"`
|
|
|
|
RequiredState []roomserver.ClientEvent `json:"required_state,omitempty"`
|
|
Timeline []roomserver.ClientEvent `json:"timeline,omitempty"`
|
|
PrevBatch string `json:"prev_batch,omitempty"`
|
|
Limited bool `json:"limited,omitempty"`
|
|
|
|
// NumLive is how many of the timeline's events are new since the
|
|
// previous response, rather than history sent to fill the timeline.
|
|
NumLive *int `json:"num_live,omitempty"`
|
|
|
|
// BumpStamp orders rooms by activity: the position of the last event
|
|
// that moved the room up a list.
|
|
BumpStamp int64 `json:"bump_stamp,omitempty"`
|
|
|
|
JoinedCount *int `json:"joined_count,omitempty"`
|
|
InvitedCount *int `json:"invited_count,omitempty"`
|
|
|
|
NotificationCount int `json:"notification_count"`
|
|
HighlightCount int `json:"highlight_count"`
|
|
|
|
InviteState []roomserver.StrippedEvent `json:"invite_state,omitempty"`
|
|
KnockState []roomserver.StrippedEvent `json:"knock_state,omitempty"`
|
|
}
|
|
|
|
// SlidingExtensions is the extensions' half of a response.
|
|
type SlidingExtensions struct {
|
|
ToDevice *ToDeviceExtension `json:"to_device,omitempty"`
|
|
E2EE *E2EEExtension `json:"e2ee,omitempty"`
|
|
AccountData *AccountDataExtension `json:"account_data,omitempty"`
|
|
Receipts *EphemeralExtension `json:"receipts,omitempty"`
|
|
Typing *EphemeralExtension `json:"typing,omitempty"`
|
|
}
|
|
|
|
// ToDeviceExtension is the to-device messages for the syncing device.
|
|
type ToDeviceExtension struct {
|
|
NextBatch string `json:"next_batch"`
|
|
Events []toDeviceEvent `json:"events"`
|
|
}
|
|
|
|
// E2EEExtension is the end-to-end encryption bookkeeping a classic sync
|
|
// carries at its top level.
|
|
type E2EEExtension struct {
|
|
DeviceLists DeviceLists `json:"device_lists"`
|
|
DeviceOneTimeKeysCount map[string]int `json:"device_one_time_keys_count"`
|
|
DeviceUnusedFallbackKeyTypes []string `json:"device_unused_fallback_key_types"`
|
|
}
|
|
|
|
// AccountDataExtension is global and per-room account data.
|
|
type AccountDataExtension struct {
|
|
Global []AccountDataEvent `json:"global"`
|
|
Rooms map[string][]AccountDataEvent `json:"rooms"`
|
|
}
|
|
|
|
// EphemeralExtension is receipts or typing, by room.
|
|
type EphemeralExtension struct {
|
|
Rooms map[string]EphemeralEvent `json:"rooms"`
|
|
}
|
|
|
|
// defaultToDeviceLimit is the to_device extension's batch size when the
|
|
// client names none. A device that has been away drains its queue over
|
|
// several requests rather than in one response of unbounded size.
|
|
const defaultToDeviceLimit = 100
|
|
|
|
// directType is the account data a client records its direct chats in.
|
|
const directType = "m.direct"
|
|
|
|
// tagType is the room account data a client records a room's tags in.
|
|
const tagType = "m.tag"
|
|
|
|
// SlidingSync answers one sliding sync request, blocking for up to
|
|
// req.Timeout when there is nothing to report.
|
|
func (s *API) SlidingSync(ctx context.Context, req SlidingRequest) (SlidingResponse, error) {
|
|
key := connKey{user: req.User, device: req.Device, connID: req.Body.ConnID}
|
|
snap, err := s.conns.load(key, req.Pos)
|
|
if err != nil {
|
|
return SlidingResponse{}, err
|
|
}
|
|
if err := s.applySetPresence(ctx, SyncRequest{User: req.User, SetPresence: req.SetPresence}); err != nil {
|
|
return SlidingResponse{}, err
|
|
}
|
|
|
|
timeout := min(req.Timeout, s.longPollTimeout)
|
|
deadline := s.now().Add(timeout)
|
|
for {
|
|
rooms, err := s.rooms.UserMemberships(ctx, req.User, nil)
|
|
if err != nil {
|
|
return SlidingResponse{}, err
|
|
}
|
|
listener := s.notifier.Listen(req.User, roomIDsOf(rooms))
|
|
|
|
resp, next, interesting, err := s.slidingAttempt(ctx, req, snap, rooms)
|
|
if err != nil {
|
|
listener.Close()
|
|
return SlidingResponse{}, err
|
|
}
|
|
wait := time.Until(deadline)
|
|
if interesting || timeout <= 0 || wait <= 0 {
|
|
listener.Close()
|
|
resp.Pos = s.conns.save(key, next)
|
|
return resp, nil
|
|
}
|
|
woken, err := waitFor(ctx, listener, wait)
|
|
listener.Close()
|
|
if err != nil {
|
|
return SlidingResponse{}, err
|
|
}
|
|
if !woken {
|
|
resp.Pos = s.conns.save(key, next)
|
|
return resp, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// slidingRoomConfig is what a room is to be sent with, merged over every
|
|
// list and subscription that selects it.
|
|
type slidingRoomConfig struct {
|
|
timelineLimit int
|
|
requiredState [][]string
|
|
}
|
|
|
|
func (c slidingRoomConfig) merge(sub RoomSubscription) slidingRoomConfig {
|
|
c.timelineLimit = max(c.timelineLimit, min(sub.TimelineLimit, MaxTimelineLimit))
|
|
for _, pair := range sub.RequiredState {
|
|
if len(pair) == 2 && !slices.ContainsFunc(c.requiredState, func(p []string) bool { return slices.Equal(p, pair) }) {
|
|
c.requiredState = append(c.requiredState, pair)
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// key renders a config for comparison with the one a room was last sent
|
|
// with.
|
|
func (c slidingRoomConfig) key() string {
|
|
pairs := make([]string, 0, len(c.requiredState))
|
|
for _, p := range c.requiredState {
|
|
pairs = append(pairs, p[0]+"\x00"+p[1])
|
|
}
|
|
slices.Sort(pairs)
|
|
return strconv.Itoa(c.timelineLimit) + "\x01" + strings.Join(pairs, "\x01")
|
|
}
|
|
|
|
// slidingAttempt builds one candidate response and the connection state it
|
|
// leaves behind.
|
|
func (s *API) slidingAttempt(ctx context.Context, req SlidingRequest, prev *connSnapshot, memberships []roomserver.MembershipAt) (_ SlidingResponse, _ *connSnapshot, _ bool, err error) {
|
|
ctx, span := tracing.Start(ctx, "syncapi.sliding_sync", attribute.Bool("matrix.initial", prev == nil))
|
|
defer func() { tracing.End(span, err) }()
|
|
|
|
positions, err := s.store.StreamPositions(ctx)
|
|
if err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
to := tokenAt(positions)
|
|
|
|
var since Token
|
|
next := &connSnapshot{token: to, startPDU: to.PDU, rooms: map[string]sentRoom{}, lists: map[string]int{}}
|
|
if prev != nil {
|
|
since = prev.token
|
|
next = prev.clone()
|
|
next.token = to
|
|
}
|
|
|
|
resp := SlidingResponse{
|
|
Lists: map[string]SlidingListResult{},
|
|
Rooms: map[string]*SlidingRoom{},
|
|
}
|
|
byRoom := make(map[string]roomserver.MembershipAt, len(memberships))
|
|
for _, m := range memberships {
|
|
byRoom[m.RoomID] = m
|
|
}
|
|
|
|
// The rooms a list may hold: those the user is in, invited to or
|
|
// knocking on, and those they left while this connection was open —
|
|
// which is how a client hears that a room it is showing is gone.
|
|
var candidates []roomserver.MembershipAt
|
|
for _, m := range memberships {
|
|
switch m.Membership {
|
|
case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock:
|
|
candidates = append(candidates, m)
|
|
case event.MembershipLeave, event.MembershipBan:
|
|
if prev != nil && m.Position > prev.startPDU {
|
|
candidates = append(candidates, m)
|
|
}
|
|
}
|
|
}
|
|
|
|
facts, err := s.roomFacts(ctx, req, candidates, to, req.Body.Lists)
|
|
if err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
|
|
selected := map[string]slidingRoomConfig{}
|
|
listsChanged := prev == nil
|
|
for name, list := range req.Body.Lists {
|
|
var rooms []roomserver.MembershipAt
|
|
for _, m := range candidates {
|
|
ok, err := s.listAdmits(ctx, req, list.Filters, m, facts)
|
|
if err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
if ok {
|
|
rooms = append(rooms, m)
|
|
}
|
|
}
|
|
slices.SortFunc(rooms, func(a, b roomserver.MembershipAt) int {
|
|
return cmp.Or(cmp.Compare(facts.bump[b.RoomID], facts.bump[a.RoomID]), strings.Compare(a.RoomID, b.RoomID))
|
|
})
|
|
resp.Lists[name] = SlidingListResult{Count: len(rooms)}
|
|
if count, ok := next.lists[name]; !ok || count != len(rooms) {
|
|
listsChanged = true
|
|
}
|
|
next.lists[name] = len(rooms)
|
|
|
|
for _, r := range list.Ranges {
|
|
lo, hi := max(r[0], 0), min(r[1], len(rooms)-1)
|
|
for i := lo; i <= hi; i++ {
|
|
selected[rooms[i].RoomID] = selected[rooms[i].RoomID].merge(list.RoomSubscription)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Subscriptions name a room directly: a room the client has open,
|
|
// whatever list it is or is not in. The user has to be able to read
|
|
// it, which for a room they are not in means a world-readable one.
|
|
for roomID, sub := range req.Body.RoomSubscriptions {
|
|
if _, ok := byRoom[roomID]; !ok {
|
|
visible, err := s.rooms.VisibleRange(ctx, roomID, req.User)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNoSuchRoom) {
|
|
continue
|
|
}
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
if !visible.Allowed {
|
|
continue
|
|
}
|
|
byRoom[roomID] = roomserver.MembershipAt{RoomID: roomID}
|
|
}
|
|
selected[roomID] = selected[roomID].merge(sub)
|
|
}
|
|
|
|
changed, err := s.slidingChangedRooms(ctx, req, prev, selected, byRoom, since, to)
|
|
if err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
|
|
selectedIDs := make([]string, 0, len(selected))
|
|
for roomID := range selected {
|
|
selectedIDs = append(selectedIDs, roomID)
|
|
}
|
|
slices.Sort(selectedIDs)
|
|
|
|
var counts map[string]storage.NotificationCounts
|
|
if s.push != nil && len(changed) > 0 {
|
|
if counts, err = s.push.NotificationCounts(ctx, req.User, selectedIDs); err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
}
|
|
|
|
initialRooms := map[string]bool{}
|
|
for _, roomID := range selectedIDs {
|
|
initial, ok := changed[roomID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
config := selected[roomID]
|
|
m := byRoom[roomID]
|
|
sent := next.rooms[roomID]
|
|
from := sent.pos
|
|
if initial {
|
|
from = 0
|
|
}
|
|
room, err := s.slidingRoom(ctx, req, m, config, from, to, facts)
|
|
if err != nil {
|
|
if errors.Is(err, ErrForbidden) {
|
|
// Lost access between the membership read and this one.
|
|
continue
|
|
}
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
room.Initial = initial
|
|
room.BumpStamp = facts.bump[roomID]
|
|
c := counts[roomID]
|
|
room.NotificationCount, room.HighlightCount = c.Notifications, c.Highlights
|
|
resp.Rooms[roomID] = room
|
|
if initial {
|
|
initialRooms[roomID] = true
|
|
}
|
|
next.rooms[roomID] = sentRoom{pos: to.PDU, config: config.key(), membership: m.Membership, typing: sent.typing}
|
|
}
|
|
|
|
extensionsInteresting, err := s.slidingExtensions(ctx, req, prev, next, since, to, memberships, selectedIDs, byRoom, initialRooms, &resp)
|
|
if err != nil {
|
|
return SlidingResponse{}, nil, false, err
|
|
}
|
|
|
|
interesting := prev == nil || listsChanged || len(resp.Rooms) > 0 || extensionsInteresting
|
|
return resp, next, interesting, nil
|
|
}
|
|
|
|
// slidingFacts are what a room list is filtered and sorted by.
|
|
type slidingFacts struct {
|
|
bump map[string]int64
|
|
dms map[string]bool
|
|
entry map[string]roomserver.RoomListEntry
|
|
}
|
|
|
|
// roomFacts reads what the lists need to know about the candidate rooms.
|
|
//
|
|
// The bump stamp of a joined room is its last activity; a room the user is
|
|
// invited to, knocking on or has left is bumped by their own membership,
|
|
// because they cannot see what happens in it.
|
|
func (s *API) roomFacts(ctx context.Context, req SlidingRequest, candidates []roomserver.MembershipAt, to Token, lists map[string]SlidingList) (slidingFacts, error) {
|
|
facts := slidingFacts{bump: map[string]int64{}, dms: map[string]bool{}, entry: map[string]roomserver.RoomListEntry{}}
|
|
|
|
var joined []string
|
|
for _, m := range candidates {
|
|
if m.Membership == event.MembershipJoin {
|
|
joined = append(joined, m.RoomID)
|
|
} else {
|
|
facts.bump[m.RoomID] = m.Position
|
|
}
|
|
}
|
|
bumps, err := s.rooms.BumpStamps(ctx, joined, to.PDU)
|
|
if err != nil {
|
|
return slidingFacts{}, err
|
|
}
|
|
for _, roomID := range joined {
|
|
facts.bump[roomID] = bumps[roomID]
|
|
}
|
|
|
|
raw, err := s.accounts.AccountData(ctx, req.Localpart, "", directType)
|
|
switch {
|
|
case errors.Is(err, storage.ErrNoSuchAccountData):
|
|
case err != nil:
|
|
return slidingFacts{}, err
|
|
default:
|
|
var direct map[string][]string
|
|
if json.Unmarshal(raw, &direct) == nil {
|
|
for _, rooms := range direct {
|
|
for _, roomID := range rooms {
|
|
facts.dms[roomID] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return facts, nil
|
|
}
|
|
|
|
// entryOf reads a room's list entry once per attempt.
|
|
func (s *API) entryOf(ctx context.Context, roomID string, facts slidingFacts) (roomserver.RoomListEntry, error) {
|
|
if e, ok := facts.entry[roomID]; ok {
|
|
return e, nil
|
|
}
|
|
e, err := s.rooms.RoomListEntry(ctx, roomID)
|
|
if err != nil {
|
|
return roomserver.RoomListEntry{}, err
|
|
}
|
|
facts.entry[roomID] = e
|
|
return e, nil
|
|
}
|
|
|
|
// listAdmits applies a list's filters to one room.
|
|
func (s *API) listAdmits(ctx context.Context, req SlidingRequest, f *SlidingFilters, m roomserver.MembershipAt, facts slidingFacts) (bool, error) {
|
|
if f == nil {
|
|
return true, nil
|
|
}
|
|
if f.IsDM != nil && facts.dms[m.RoomID] != *f.IsDM {
|
|
return false, nil
|
|
}
|
|
if f.IsInvite != nil && (m.Membership == event.MembershipInvite) != *f.IsInvite {
|
|
return false, nil
|
|
}
|
|
if f.IsEncrypted != nil || len(f.RoomTypes) > 0 || len(f.NotRoomTypes) > 0 {
|
|
entry, err := s.entryOf(ctx, m.RoomID, facts)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if f.IsEncrypted != nil && entry.Encrypted != *f.IsEncrypted {
|
|
return false, nil
|
|
}
|
|
typeMatches := func(types []*string) bool {
|
|
return slices.ContainsFunc(types, func(t *string) bool {
|
|
if t == nil {
|
|
return entry.RoomType == ""
|
|
}
|
|
return *t == entry.RoomType
|
|
})
|
|
}
|
|
if len(f.RoomTypes) > 0 && !typeMatches(f.RoomTypes) {
|
|
return false, nil
|
|
}
|
|
if typeMatches(f.NotRoomTypes) {
|
|
return false, nil
|
|
}
|
|
}
|
|
if len(f.Spaces) > 0 {
|
|
in := false
|
|
for _, space := range f.Spaces {
|
|
children, err := s.rooms.SpaceChildRooms(ctx, space)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if slices.Contains(children, m.RoomID) {
|
|
in = true
|
|
break
|
|
}
|
|
}
|
|
if !in {
|
|
return false, nil
|
|
}
|
|
}
|
|
if len(f.Tags) > 0 || len(f.NotTags) > 0 {
|
|
tags, err := s.roomTags(ctx, req.Localpart, m.RoomID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(f.Tags) > 0 && !slices.ContainsFunc(f.Tags, func(t string) bool { return tags[t] }) {
|
|
return false, nil
|
|
}
|
|
if slices.ContainsFunc(f.NotTags, func(t string) bool { return tags[t] }) {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// roomTags reads the tags a user has put on a room.
|
|
func (s *API) roomTags(ctx context.Context, localpart, roomID string) (map[string]bool, error) {
|
|
raw, err := s.accounts.AccountData(ctx, localpart, roomID, tagType)
|
|
if errors.Is(err, storage.ErrNoSuchAccountData) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var content struct {
|
|
Tags map[string]json.RawMessage `json:"tags"`
|
|
}
|
|
// Tags a client wrote in a shape that is not the spec's are no tags:
|
|
// a list filter is not the place to refuse somebody's account data.
|
|
if json.Unmarshal(raw, &content) != nil {
|
|
content.Tags = nil
|
|
}
|
|
out := make(map[string]bool, len(content.Tags))
|
|
for t := range content.Tags {
|
|
out[t] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// slidingChangedRooms decides which selected rooms go into the response,
|
|
// and whether each goes in full. The value is true for a room sent in full.
|
|
func (s *API) slidingChangedRooms(ctx context.Context, req SlidingRequest, prev *connSnapshot, selected map[string]slidingRoomConfig, byRoom map[string]roomserver.MembershipAt, since, to Token) (map[string]bool, error) {
|
|
out := map[string]bool{}
|
|
// The rooms already sent, grouped by the position they were sent up
|
|
// to, so that "has anything happened since" is one query per group.
|
|
byPos := map[int64][]string{}
|
|
for roomID, config := range selected {
|
|
var sent sentRoom
|
|
var ok bool
|
|
if prev != nil {
|
|
sent, ok = prev.rooms[roomID]
|
|
}
|
|
m := byRoom[roomID]
|
|
switch {
|
|
case !ok, sent.config != config.key():
|
|
out[roomID] = true
|
|
case m.Membership != sent.membership:
|
|
// A membership the room was last sent under no longer
|
|
// applies: an invite accepted is a room the client has only
|
|
// seen stripped state of.
|
|
out[roomID] = sent.membership == event.MembershipInvite ||
|
|
sent.membership == event.MembershipKnock || sent.membership == ""
|
|
case m.Position > sent.pos:
|
|
out[roomID] = false
|
|
case m.Membership == event.MembershipJoin || m.Membership == "":
|
|
byPos[sent.pos] = append(byPos[sent.pos], roomID)
|
|
}
|
|
}
|
|
for pos, roomIDs := range byPos {
|
|
slices.Sort(roomIDs)
|
|
withEvents, err := s.rooms.RoomsWithEvents(ctx, roomIDs, pos, to.PDU)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, roomID := range withEvents {
|
|
out[roomID] = false
|
|
}
|
|
}
|
|
|
|
// Rooms whose unread counts moved without an event: read on another
|
|
// device.
|
|
if s.push != nil && prev != nil && to.Notification > since.Notification {
|
|
moved, err := s.push.NotificationCountChanges(ctx, req.User, since.Notification, to.Notification)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, roomID := range moved {
|
|
if _, ok := selected[roomID]; ok {
|
|
if _, already := out[roomID]; !already {
|
|
out[roomID] = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// slidingRoom reads one room for a response. from is where the client's
|
|
// copy of the room ends, zero for a room sent in full.
|
|
func (s *API) slidingRoom(ctx context.Context, req SlidingRequest, m roomserver.MembershipAt, config slidingRoomConfig, from int64, to Token, facts slidingFacts) (*SlidingRoom, error) {
|
|
room := &SlidingRoom{IsDM: facts.dms[m.RoomID]}
|
|
entry, err := s.entryOf(ctx, m.RoomID, facts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
room.Name, room.Avatar = entry.Name, entry.AvatarURL
|
|
|
|
switch m.Membership {
|
|
case event.MembershipInvite, event.MembershipKnock:
|
|
state, err := s.rooms.StrippedState(ctx, m.RoomID, []string{req.User})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m.Membership == event.MembershipInvite {
|
|
room.InviteState = state
|
|
} else {
|
|
room.KnockState = state
|
|
}
|
|
return room, nil
|
|
}
|
|
|
|
if config.timelineLimit > 0 {
|
|
timeline, err := s.rooms.Timeline(ctx, roomserver.TimelineRequest{
|
|
RoomID: m.RoomID,
|
|
User: req.User,
|
|
Low: from,
|
|
High: to.PDU,
|
|
Direction: storage.DirectionBackward,
|
|
Limit: config.timelineLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
room.Timeline = withoutRoomID(timeline.Events)
|
|
room.Limited = timeline.Limited
|
|
if len(timeline.Events) > 0 {
|
|
room.PrevBatch = pduToken(timeline.Start).String()
|
|
}
|
|
live := 0
|
|
if from != 0 {
|
|
live = len(timeline.Events)
|
|
}
|
|
room.NumLive = &live
|
|
}
|
|
|
|
match := newStateMatcher(config.requiredState, req.User, room.Timeline)
|
|
if !match.empty() {
|
|
state, err := s.rooms.RequiredState(ctx, roomserver.RequiredStateRequest{
|
|
RoomID: m.RoomID,
|
|
User: req.User,
|
|
Since: from,
|
|
To: to.PDU,
|
|
Match: match.matches,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
room.RequiredState = withoutRoomID(state)
|
|
}
|
|
|
|
summary, err := s.rooms.RoomSummaryFor(ctx, m.RoomID, req.User)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
joined, invited := summary.JoinedMemberCount, summary.InvitedMemberCount
|
|
room.JoinedCount, room.InvitedCount = &joined, &invited
|
|
if room.Name == "" && len(summary.Heroes) > 0 {
|
|
members, err := s.rooms.MemberEvents(ctx, m.RoomID, summary.Heroes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles := map[string]SlidingHero{}
|
|
for _, e := range members {
|
|
if e.StateKey == nil {
|
|
continue
|
|
}
|
|
var content struct {
|
|
DisplayName string `json:"displayname"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
_ = json.Unmarshal(e.Content, &content)
|
|
profiles[*e.StateKey] = SlidingHero{UserID: *e.StateKey, DisplayName: content.DisplayName, AvatarURL: content.AvatarURL}
|
|
}
|
|
for _, userID := range summary.Heroes {
|
|
hero, ok := profiles[userID]
|
|
if !ok {
|
|
hero = SlidingHero{UserID: userID}
|
|
}
|
|
room.Heroes = append(room.Heroes, hero)
|
|
}
|
|
}
|
|
return room, nil
|
|
}
|
|
|
|
// stateMatcher is a room's required_state, compiled.
|
|
type stateMatcher struct {
|
|
all bool
|
|
wholeType map[string]bool
|
|
anyType map[string]bool
|
|
tuples map[[2]string]bool
|
|
}
|
|
|
|
func newStateMatcher(required [][]string, userID string, timeline []roomserver.ClientEvent) stateMatcher {
|
|
m := stateMatcher{wholeType: map[string]bool{}, anyType: map[string]bool{}, tuples: map[[2]string]bool{}}
|
|
for _, pair := range required {
|
|
eventType, stateKey := pair[0], pair[1]
|
|
switch stateKey {
|
|
case "$ME":
|
|
stateKey = userID
|
|
case "$LAZY":
|
|
if eventType == event.TypeMember {
|
|
// The members whose names the timeline needs, and the user
|
|
// themselves, whose own membership a client always shows.
|
|
m.tuples[[2]string{event.TypeMember, userID}] = true
|
|
for _, e := range timeline {
|
|
m.tuples[[2]string{event.TypeMember, e.Sender}] = true
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case eventType == "*" && stateKey == "*":
|
|
m.all = true
|
|
case stateKey == "*":
|
|
m.wholeType[eventType] = true
|
|
case eventType == "*":
|
|
m.anyType[stateKey] = true
|
|
default:
|
|
m.tuples[[2]string{eventType, stateKey}] = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m stateMatcher) empty() bool {
|
|
return !m.all && len(m.wholeType) == 0 && len(m.anyType) == 0 && len(m.tuples) == 0
|
|
}
|
|
|
|
func (m stateMatcher) matches(eventType, stateKey string) bool {
|
|
return m.all || m.wholeType[eventType] || m.anyType[stateKey] || m.tuples[[2]string{eventType, stateKey}]
|
|
}
|
|
|
|
// extensionRooms narrows the rooms a per-room extension reports on to the
|
|
// ones its request names, by list or by room.
|
|
//
|
|
// Naming any list the request has, or "*", is every selected room: the
|
|
// windows of the lists are merged before the extensions run, and a client
|
|
// naming its main list means the rooms it is showing, which is a superset
|
|
// at worst. Naming rooms narrows to those of them that are selected.
|
|
func extensionRooms(t *extensionToggle, body SlidingBody, selected []string) []string {
|
|
if len(t.Lists) == 0 && len(t.Rooms) == 0 {
|
|
return selected
|
|
}
|
|
for _, name := range t.Lists {
|
|
if _, ok := body.Lists[name]; ok || name == "*" {
|
|
return selected
|
|
}
|
|
}
|
|
if slices.Contains(t.Rooms, "*") {
|
|
return selected
|
|
}
|
|
var out []string
|
|
for _, roomID := range t.Rooms {
|
|
if slices.Contains(selected, roomID) {
|
|
out = append(out, roomID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// slidingExtensions fills in the extensions a request turned on, and
|
|
// reports whether any of them has something to say.
|
|
func (s *API) slidingExtensions(
|
|
ctx context.Context,
|
|
req SlidingRequest,
|
|
prev, next *connSnapshot,
|
|
since, to Token,
|
|
memberships []roomserver.MembershipAt,
|
|
selected []string,
|
|
byRoom map[string]roomserver.MembershipAt,
|
|
initialRooms map[string]bool,
|
|
resp *SlidingResponse,
|
|
) (bool, error) {
|
|
ext := req.Body.Extensions
|
|
interesting := false
|
|
|
|
if ext.ToDevice != nil && ext.ToDevice.on() {
|
|
from := next.toDevice
|
|
if ext.ToDevice.Since != "" {
|
|
parsed, err := strconv.ParseInt(ext.ToDevice.Since, 10, 64)
|
|
if err != nil || parsed < 0 {
|
|
return false, errInvalidTokenf("%q is not a to_device token", ext.ToDevice.Since)
|
|
}
|
|
from = parsed
|
|
// Coming back with a since acknowledges everything up to it.
|
|
s.ackToDevice(ctx, req.Localpart, req.Device, from)
|
|
}
|
|
limit := ext.ToDevice.Limit
|
|
if limit <= 0 {
|
|
limit = defaultToDeviceLimit
|
|
}
|
|
msgs, pos, err := s.toDeviceMessages(ctx, req.Localpart, req.Device, from, to.ToDevice, limit)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if msgs == nil {
|
|
msgs = []toDeviceEvent{}
|
|
}
|
|
next.toDevice = pos
|
|
resp.Extensions.ToDevice = &ToDeviceExtension{NextBatch: strconv.FormatInt(pos, 10), Events: msgs}
|
|
interesting = interesting || len(msgs) > 0
|
|
}
|
|
|
|
if ext.E2EE.on() {
|
|
e2ee := &E2EEExtension{DeviceLists: DeviceLists{Changed: []string{}, Left: []string{}}}
|
|
if prev != nil {
|
|
lists, err := s.deviceLists(ctx, req.User, since, to, memberships)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
e2ee.DeviceLists = lists
|
|
}
|
|
var keys Response
|
|
if err := s.deviceKeyCounts(ctx, SyncRequest{User: req.User, Device: req.Device}, &keys); err != nil {
|
|
return false, err
|
|
}
|
|
e2ee.DeviceOneTimeKeysCount = keys.DeviceOneTimeKeysCount
|
|
if e2ee.DeviceOneTimeKeysCount == nil {
|
|
e2ee.DeviceOneTimeKeysCount = map[string]int{}
|
|
}
|
|
e2ee.DeviceUnusedFallbackKeyTypes = []string{}
|
|
if keys.DeviceUnusedFallbackKeyTypes != nil {
|
|
e2ee.DeviceUnusedFallbackKeyTypes = *keys.DeviceUnusedFallbackKeyTypes
|
|
}
|
|
resp.Extensions.E2EE = e2ee
|
|
interesting = interesting || len(e2ee.DeviceLists.Changed) > 0 || len(e2ee.DeviceLists.Left) > 0
|
|
}
|
|
|
|
if ext.AccountData.on() {
|
|
from := since.AccountData
|
|
global, err := s.globalAccountData(ctx, SyncRequest{User: req.User, Localpart: req.Localpart}, from, to.AccountData, prev == nil, Filter{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
out := &AccountDataExtension{Global: global, Rooms: map[string][]AccountDataEvent{}}
|
|
for _, roomID := range extensionRooms(ext.AccountData, req.Body, selected) {
|
|
window := roomWindow{since: since, to: to, full: initialRooms[roomID] || prev == nil}
|
|
events, err := s.roomAccountData(ctx, req.Localpart, roomID, window, Filter{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(events) > 0 {
|
|
out.Rooms[roomID] = events
|
|
}
|
|
}
|
|
resp.Extensions.AccountData = out
|
|
interesting = interesting || len(global) > 0 || len(out.Rooms) > 0
|
|
}
|
|
|
|
joinedOnly := func(roomIDs []string) []string {
|
|
var out []string
|
|
for _, roomID := range roomIDs {
|
|
if byRoom[roomID].Membership == event.MembershipJoin {
|
|
out = append(out, roomID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
if ext.Receipts.on() {
|
|
out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}}
|
|
rooms := joinedOnly(extensionRooms(ext.Receipts, req.Body, selected))
|
|
var fresh, ongoing []string
|
|
for _, roomID := range rooms {
|
|
if initialRooms[roomID] || prev == nil {
|
|
fresh = append(fresh, roomID)
|
|
} else {
|
|
ongoing = append(ongoing, roomID)
|
|
}
|
|
}
|
|
for _, set := range []struct {
|
|
rooms []string
|
|
from int64
|
|
}{{fresh, 0}, {ongoing, since.Receipt}} {
|
|
if len(set.rooms) == 0 || (set.from != 0 && to.Receipt <= set.from) {
|
|
continue
|
|
}
|
|
byRoomReceipts, err := s.roomReceipts(ctx, req.User, set.rooms, set.from, to.Receipt)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for roomID, receipts := range byRoomReceipts {
|
|
if e := receiptsEvent(receipts); e != nil {
|
|
out.Rooms[roomID] = *e
|
|
}
|
|
}
|
|
}
|
|
resp.Extensions.Receipts = out
|
|
interesting = interesting || len(out.Rooms) > 0
|
|
}
|
|
|
|
if ext.Typing.on() {
|
|
out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}}
|
|
for _, roomID := range joinedOnly(extensionRooms(ext.Typing, req.Body, selected)) {
|
|
e := s.typingEvent(roomID)
|
|
current := ""
|
|
if e != nil {
|
|
current = string(e.Content)
|
|
}
|
|
sent := next.rooms[roomID]
|
|
if current == sent.typing {
|
|
continue
|
|
}
|
|
if e == nil {
|
|
// Somebody stopped: the client has to be told the list is
|
|
// now empty, or it would show them typing until it
|
|
// restarted.
|
|
e = &EphemeralEvent{Type: "m.typing", Content: json.RawMessage(`{"user_ids":[]}`)}
|
|
}
|
|
out.Rooms[roomID] = *e
|
|
sent.typing = current
|
|
next.rooms[roomID] = sent
|
|
}
|
|
resp.Extensions.Typing = out
|
|
interesting = interesting || len(out.Rooms) > 0
|
|
}
|
|
return interesting, nil
|
|
}
|