Files
Warp/server/internal/roomserver/query.go
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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
Web client (warp-web) with E2EE, and two sync fixes
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>
2026-10-18 16:22:11 +02:00

452 lines
15 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
}
// MembershipBefore returns a user's membership in a room as of just before
// an event, empty when they had none. Sync asks it of the user's own latest
// membership event, to tell a join from a profile change.
func (r *API) MembershipBefore(ctx context.Context, roomID, userID, eventID string) (string, error) {
entries, err := r.store.StateBefore(ctx, roomID, eventID)
if err != nil {
return "", err
}
for _, e := range entries {
if e.Type == event.TypeMember && e.StateKey == userID {
return e.Membership, nil
}
}
return "", 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
}