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
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. 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, false)
|
|
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
|
|
}
|