Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.
internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.
Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.
Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.
Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.
Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
595 lines
19 KiB
Go
595 lines
19 KiB
Go
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. Either can be
|
|
// negative, inside history backfilled from other servers. 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
|
|
|
|
// Backfill allows a backwards read that runs out of history at the
|
|
// bottom of what this server holds to ask the servers in the room for
|
|
// more before it answers (see backfill.go). /messages sets it; /sync
|
|
// and /context do not, because neither is a client asking to scroll
|
|
// back, and neither should wait on another server.
|
|
Backfill bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
page, err := r.timeline(ctx, rules, visible, req)
|
|
if err != nil {
|
|
return Timeline{}, err
|
|
}
|
|
|
|
// A page that came back short at the bottom of the room is either the
|
|
// beginning of the room or the beginning of what this server holds of
|
|
// it. Telling a client it has reached the end — no `end` token — when
|
|
// it is the second would stop it scrolling for good, so the servers in
|
|
// the room are asked first.
|
|
for range backfillRounds {
|
|
if !r.wantsBackfill(req, visible, page) {
|
|
break
|
|
}
|
|
added, err := r.backfill(ctx, room, rules, req.Limit)
|
|
if err != nil {
|
|
// The page this server has is still an answer. The client
|
|
// will try again the next time it scrolls up.
|
|
r.log.Info("could not backfill a room", "room_id", room.ID, "error", err)
|
|
break
|
|
}
|
|
if added == 0 {
|
|
break
|
|
}
|
|
if page, err = r.timeline(ctx, rules, visible, req); err != nil {
|
|
return Timeline{}, err
|
|
}
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// 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. Below the
|
|
// event at 1 is -1, not 0: zero would read as "unbounded" and hand the
|
|
// client the whole room again rather than the history beneath it.
|
|
out.Start = storage.PositionBefore(stored[0].StreamOrdering)
|
|
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.
|
|
//
|
|
// For a room this server is not in — an invite that came over federation —
|
|
// the room's state is not here, and what is returned is the stripped state
|
|
// the inviting server sent along, plus the invite itself.
|
|
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
|
|
}
|
|
if _, err := r.store.CurrentStateEvent(ctx, roomID, event.TypeCreate, ""); isNotFound(err) {
|
|
return r.outOfBandStrippedState(ctx, roomID, rules, members)
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.strippedState(ctx, roomID, rules, members)
|
|
}
|
|
|
|
// outOfBandStrippedState is StrippedState for a room known only from an
|
|
// invite.
|
|
func (r *API) outOfBandStrippedState(ctx context.Context, roomID string, rules roomversion.Rules, members []string) ([]StrippedEvent, error) {
|
|
var out []StrippedEvent
|
|
for _, member := range members {
|
|
raw, err := r.store.InviteState(ctx, roomID, member)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) > 0 {
|
|
var stripped []StrippedEvent
|
|
if err := json.Unmarshal(raw, &stripped); err != nil {
|
|
// What another server sent is not ours to fail a sync over.
|
|
r.log.Warn("stored invite state is unreadable", "room_id", roomID, "user_id", member, "error", err)
|
|
}
|
|
for _, s := range stripped {
|
|
if s.Type == event.TypeMember && s.StateKey == member {
|
|
continue // the invite itself is added below
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
}
|
|
for _, member := range members {
|
|
stored, err := r.store.CurrentStateEvent(ctx, roomID, event.TypeMember, member)
|
|
if isNotFound(err) {
|
|
continue
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
e, err := parseEvent(stored, rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, StrippedEvent{Type: e.Type(), StateKey: member, Sender: e.Sender(), Content: e.Content()})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// UsersSharingRooms lists the users joined to a room the given user is
|
|
// joined to, that user included. It is who hears about the user's device
|
|
// list changes.
|
|
func (r *API) UsersSharingRooms(ctx context.Context, userID string) ([]string, error) {
|
|
return r.store.UsersSharingRooms(ctx, userID)
|
|
}
|
|
|
|
// MemberChanges lists the membership events of the given rooms in a window
|
|
// of the event stream, each with the user's membership now.
|
|
func (r *API) MemberChanges(ctx context.Context, roomIDs []string, from, to int64) ([]storage.MemberChange, error) {
|
|
return r.store.MemberChanges(ctx, roomIDs, from, to)
|
|
}
|
|
|
|
// JoinedUserIDs lists the users joined to a room, as this server last
|
|
// knew it.
|
|
func (r *API) JoinedUserIDs(ctx context.Context, roomID string) ([]string, error) {
|
|
entries, err := r.store.MembershipEntries(ctx, roomID, []string{event.MembershipJoin})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, e.UserID)
|
|
}
|
|
return out, nil
|
|
}
|