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>
452 lines
15 KiB
Go
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
|
|
}
|