Files
Warp/server/internal/roomserver/query.go
T
tajniak81andClaude Opus 5.5 487e0f410b
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
Phase 10: hardening and ops
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>
2026-10-17 20:21:31 +02:00

436 lines
14 KiB
Go

package roomserver
import (
"context"
"encoding/json"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// currentState reads a room's current state as the authorisation rules see
// it.
func (r *API) currentState(ctx context.Context, room storage.Room, rules roomversion.Rules) (auth.State, error) {
stored, err := r.store.CurrentStateEvents(ctx, room.ID)
if err != nil {
return nil, err
}
events, err := parseEvents(stored, rules)
if err != nil {
return nil, err
}
return auth.NewState(events), nil
}
// ClientEvent is an event in the shape the Client-Server API returns it.
//
// It is not the event as it was signed. A client is given the event ID
// explicitly — in every room version since 3 it is a hash, not a property —
// and is not given the hashes, signatures, auth_events or depth, which are
// a server's business. What it does get that the signed event never carried
// is `unsigned`, which is per-server annotation: the age of the event, the
// state it replaced, and the redaction that stripped it.
type ClientEvent struct {
EventID string `json:"event_id"`
// RoomID is omitted when empty, because a /sync response carries its
// events without one: they are already nested under the room they
// belong to, and repeating it on every event of a client with a
// thousand rooms is a measurable part of every response. Every other
// endpoint sets it, and the spec has a separate name for the shape
// without it — ClientEventWithoutRoomID — rather than two event types.
RoomID string `json:"room_id,omitempty"`
Type string `json:"type"`
StateKey *string `json:"state_key,omitempty"`
Sender string `json:"sender"`
Content json.RawMessage `json:"content"`
OriginServerTS int64 `json:"origin_server_ts"`
Unsigned json.RawMessage `json:"unsigned,omitempty"`
// Redacts is only on an m.room.redaction event, and only in the room
// versions that keep it at the top level. Clients still read it there.
Redacts string `json:"redacts,omitempty"`
}
// clientEvent projects a stored event into the client shape.
func (r *API) clientEvent(stored storage.StoredEvent, e *event.Event, roomID string) ClientEvent {
out := ClientEvent{
EventID: e.EventID(),
RoomID: roomID,
Type: e.Type(),
Sender: e.Sender(),
Content: e.Content(),
OriginServerTS: e.OriginServerTS(),
Redacts: e.Redacts(),
}
if stateKey, ok := e.StateKey(); ok {
out.StateKey = &stateKey
}
out.Unsigned = r.unsigned(stored, e)
return out
}
// unsigned builds the `unsigned` block a client is given.
//
// `age` is computed on the way out rather than stored: it is how long ago
// the event happened as far as this server can tell, and a value written
// down at persist time would be wrong by however long the event sat in the
// database.
func (r *API) unsigned(stored storage.StoredEvent, e *event.Event) json.RawMessage {
block := map[string]json.RawMessage{}
if existing := e.Unsigned(); len(existing) > 0 {
if err := json.Unmarshal(existing, &block); err != nil {
// The unsigned block is this server's own annotation and is
// written as an object everywhere; anything else is a bug here
// rather than something a client sent, and dropping it is
// better than failing the read.
r.log.Warn("stored event has an unreadable unsigned block", "event_id", e.EventID())
block = map[string]json.RawMessage{}
}
}
if age := r.nowMS() - e.OriginServerTS(); age >= 0 {
block["age"] = mustMarshal(age)
}
if stored.RedactedBy != "" {
block["redacted_because"] = mustMarshal(map[string]any{"event_id": stored.RedactedBy})
}
if len(block) == 0 {
return nil
}
return mustMarshal(block)
}
// Event returns one event of a room, if the user may see it.
func (r *API) Event(ctx context.Context, roomID, userID, eventID string) (ClientEvent, error) {
_, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return ClientEvent{}, err
}
visible, err := r.requireReadable(ctx, roomID, userID)
if err != nil {
return ClientEvent{}, err
}
stored, err := r.store.Event(ctx, roomID, eventID)
if err != nil {
return ClientEvent{}, err
}
// An event outside the user's window is reported as unknown rather
// than as forbidden: whether a particular event ID exists in a room is
// itself something they are not entitled to know.
if !visible.Contains(stored.StreamOrdering) {
return ClientEvent{}, fmt.Errorf("event %q: %w", eventID, ErrNoSuchEvent)
}
e, err := parseEvent(stored, rules)
if err != nil {
return ClientEvent{}, err
}
out := []ClientEvent{r.clientEvent(stored, e, roomID)}
if err := r.bundleAggregations(ctx, roomID, rules, userID, visible, []storage.StoredEvent{stored}, out); err != nil {
return ClientEvent{}, err
}
return out[0], nil
}
// RoomState returns a room's state in the client shape: the current state
// for a member, and the state as it was when they left for a user who has.
func (r *API) RoomState(ctx context.Context, roomID, userID string) ([]ClientEvent, error) {
_, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
visible, err := r.requireReadable(ctx, roomID, userID)
if err != nil {
return nil, err
}
return r.stateAt(ctx, roomID, rules, visible.To)
}
// StateEventContent returns the content of one piece of a room's current
// state, which is what GET /rooms/{roomID}/state/{type}/{key} answers.
//
// The endpoint returns the content alone, not the event: a quirk of the
// Client-Server API that predates the rest of it, and one clients depend on.
func (r *API) StateEventContent(ctx context.Context, roomID, userID, eventType, stateKey string) (json.RawMessage, error) {
e, err := r.StateEvent(ctx, roomID, userID, eventType, stateKey)
if err != nil {
return nil, err
}
return e.Content, nil
}
// StateEvent returns one piece of a room's current state as a whole event.
//
// It is what ?format=event asks for on the state endpoint, and the only way
// a client can see who set a piece of state and when.
func (r *API) StateEvent(ctx context.Context, roomID, userID, eventType, stateKey string) (ClientEvent, error) {
_, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return ClientEvent{}, err
}
visible, err := r.requireReadable(ctx, roomID, userID)
if err != nil {
return ClientEvent{}, err
}
// A member reads the one row they asked for. A user who has left has to
// be served from the state as of their departure, which means reading
// the snapshot and picking the tuple out of it — the room has moved on
// since, and the current row is not theirs to see.
if visible.To != 0 {
state, err := r.stateAt(ctx, roomID, rules, visible.To)
if err != nil {
return ClientEvent{}, err
}
for _, e := range state {
if e.Type == eventType && e.StateKey != nil && *e.StateKey == stateKey {
return e, nil
}
}
return ClientEvent{}, fmt.Errorf("state %s/%s of %q: %w",
eventType, stateKey, roomID, ErrNoSuchEvent)
}
stored, err := r.store.CurrentStateEvent(ctx, roomID, eventType, stateKey)
if err != nil {
return ClientEvent{}, err
}
e, err := parseEvent(stored, rules)
if err != nil {
return ClientEvent{}, err
}
return r.clientEvent(stored, e, roomID), nil
}
// MembersRequest filters GET /rooms/{roomID}/members.
type MembersRequest struct {
RoomID string
// User is the full user ID asking.
User string
// Membership and NotMembership filter the result; either may be empty.
Membership string
NotMembership string
// At is the stream position to report the membership as of, or 0 for
// the current one.
//
// It is what ?at= asks for, and what a client that is rendering an
// older part of a room's timeline needs: the names and avatars to show
// against those messages are the ones their senders had then, not now.
At int64
}
// Members returns a room's m.room.member events.
func (r *API) Members(ctx context.Context, req MembersRequest) ([]ClientEvent, error) {
_, rules, err := r.roomAndRules(ctx, req.RoomID)
if err != nil {
return nil, err
}
visible, err := r.requireReadable(ctx, req.RoomID, req.User)
if err != nil {
return nil, err
}
// The position to report as of: the one asked for, held inside what
// the user may see. A user who has left is pinned to their departure
// whatever they asked for — the room has moved on since, and the
// current membership is not theirs to read.
at := req.At
if visible.To != 0 && (at == 0 || at > visible.To) {
at = visible.To
}
if at != 0 && at <= visible.From {
return nil, fmt.Errorf("%w: %s may not see %s as it was at %d",
ErrForbidden, req.User, req.RoomID, req.At)
}
var members []ClientEvent
if at != 0 {
state, err := r.stateAt(ctx, req.RoomID, rules, at)
if err != nil {
return nil, err
}
members = filterClientEvents(state, storage.TimelineFilter{Types: []string{event.TypeMember}})
} else {
var filter []string
if req.Membership != "" {
filter = append(filter, req.Membership)
}
stored, err := r.store.Members(ctx, req.RoomID, filter)
if err != nil {
return nil, err
}
if members, err = r.clientEvents(stored, rules, req.RoomID); err != nil {
return nil, err
}
}
out := make([]ClientEvent, 0, len(members))
for _, e := range members {
membership := membershipOf(e)
if req.Membership != "" && membership != req.Membership {
continue
}
if req.NotMembership != "" && membership == req.NotMembership {
continue
}
out = append(out, e)
}
return out, nil
}
// membershipOf reads content.membership out of a projected member event.
//
// A member event that reached a room always has one — the authorisation
// rules refuse one that does not — so an empty answer means the event is
// not a member event at all.
func membershipOf(e ClientEvent) string {
var content struct {
Membership string `json:"membership"`
}
_ = json.Unmarshal(e.Content, &content)
return content.Membership
}
// RoomMember is one joined member, as /joined_members reports them.
type RoomMember struct {
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// JoinedMembers returns the joined members of a room with their profiles.
//
// Unlike /members this one is for members only: the spec says so, and the
// reason is that it exists to let a client that is in a room render it, not
// to let anyone enumerate who is in it.
func (r *API) JoinedMembers(ctx context.Context, roomID, userID string) (map[string]RoomMember, error) {
if err := r.requireJoined(ctx, roomID, userID); err != nil {
return nil, err
}
_, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
stored, err := r.store.Members(ctx, roomID, []string{event.MembershipJoin})
if err != nil {
return nil, err
}
out := make(map[string]RoomMember, len(stored))
for _, s := range stored {
e, err := parseEvent(s, rules)
if err != nil {
return nil, err
}
stateKey, ok := e.StateKey()
if !ok {
continue
}
var content struct {
DisplayName string `json:"displayname"`
AvatarURL string `json:"avatar_url"`
}
// A member event with a malformed profile still counts as a member;
// the profile is simply not shown.
_ = json.Unmarshal(e.Content(), &content)
out[stateKey] = RoomMember{DisplayName: content.DisplayName, AvatarURL: content.AvatarURL}
}
return out, nil
}
// JoinedRooms lists the rooms a user is joined to.
func (r *API) JoinedRooms(ctx context.Context, userID string) ([]string, error) {
rooms, err := r.store.RoomsForUser(ctx, userID, []string{event.MembershipJoin})
if err != nil {
return nil, err
}
if rooms == nil {
rooms = []string{}
}
return rooms, nil
}
// Membership returns a user's membership in a room, which is empty for a
// user who was never in it.
func (r *API) Membership(ctx context.Context, roomID, userID string) (string, error) {
entry, err := r.store.Membership(ctx, roomID, userID)
if err != nil {
return "", err
}
return entry.Membership, nil
}
// roomAndRules reads a room and the rules its events are under.
func (r *API) roomAndRules(ctx context.Context, roomID string) (storage.Room, roomversion.Rules, error) {
room, err := r.store.Room(ctx, roomID)
if err != nil {
return storage.Room{}, roomversion.Rules{}, err
}
rules, err := r.rules(room)
if err != nil {
return storage.Room{}, roomversion.Rules{}, err
}
return room, rules, nil
}
// requireJoined refuses a caller who is not currently in the room.
func (r *API) requireJoined(ctx context.Context, roomID, userID string) error {
entry, err := r.store.Membership(ctx, roomID, userID)
if err != nil {
return err
}
if entry.Membership != event.MembershipJoin {
return fmt.Errorf("%w: %s is not joined to %s", ErrForbidden, userID, roomID)
}
return nil
}
// CanSendStateEvent reports whether a user may send a state event of a type
// into a room.
//
// It exists for the two endpoints that are *about* a room rather than in it
// — publishing the room in the directory, and deleting one of its aliases —
// where the spec's permission is "could set the canonical alias". Nothing
// that sends an event uses it: an event's permission is decided by the
// authorisation rules when it is sent, and asking twice would be two
// answers to maintain.
func (r *API) CanSendStateEvent(ctx context.Context, roomID, userID, eventType string) (bool, error) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return false, err
}
st, err := r.currentState(ctx, room, rules)
if err != nil {
return false, err
}
if st.Membership(userID) != event.MembershipJoin {
return false, nil
}
create := st.Create()
if create == nil {
return false, fmt.Errorf("%w: %s has no create event in its state", ErrRejected, roomID)
}
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), create, rules)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrRejected, err)
}
return powerLevels.UserLevel(userID) >= powerLevels.SendLevel(eventType, true), nil
}
// RoomVersion returns a room's version, for the handful of callers that
// need to report it.
func (r *API) RoomVersion(ctx context.Context, roomID string) (string, error) {
room, err := r.store.Room(ctx, roomID)
if err != nil {
return "", err
}
return room.Version, nil
}