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
Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.
Server:
- MSC4140 delayed events (send/state with a delay, plus the
delayed_events list/restart/cancel/send API), stored and scheduled, so
a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.
Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
549 lines
18 KiB
Go
549 lines
18 KiB
Go
package roomserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"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/id"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// The createRoom presets, which are shorthand for a join rule, a history
|
|
// visibility and a guest access policy together.
|
|
const (
|
|
PresetPrivateChat = "private_chat"
|
|
PresetTrustedPrivateChat = "trusted_private_chat"
|
|
PresetPublicChat = "public_chat"
|
|
)
|
|
|
|
// Directory visibility values for createRoom and
|
|
// /directory/list/room/{roomID}.
|
|
const (
|
|
VisibilityPublic = "public"
|
|
VisibilityPrivate = "private"
|
|
)
|
|
|
|
// CreateRoomRequest is the createRoom body, already parsed.
|
|
type CreateRoomRequest struct {
|
|
// Creator is the full user ID creating the room.
|
|
Creator string
|
|
|
|
// Version is the room version to create at; empty uses this server's
|
|
// default.
|
|
Version string
|
|
|
|
// Preset is one of the three presets; empty derives it from Visibility
|
|
// the way the spec does.
|
|
Preset string
|
|
|
|
// Visibility is "public" to publish the room in this server's directory.
|
|
Visibility string
|
|
|
|
// RoomAliasName is the localpart of an alias to create and set as the
|
|
// canonical alias.
|
|
RoomAliasName string
|
|
|
|
Name string
|
|
Topic string
|
|
|
|
// Invite lists user IDs to invite once the room exists.
|
|
Invite []string
|
|
|
|
// CreationContent is merged into the m.room.create content. It is how a
|
|
// client sets m.federate and creates a space.
|
|
CreationContent json.RawMessage
|
|
|
|
// InitialState are state events to send after the room's own setup
|
|
// events and before the invites.
|
|
InitialState []StateEvent
|
|
|
|
// PowerLevelContentOverride is merged over the m.room.power_levels
|
|
// content this server would otherwise send.
|
|
PowerLevelContentOverride json.RawMessage
|
|
|
|
// IsDirect marks the creator's and the invitees' membership events as a
|
|
// direct chat.
|
|
IsDirect bool
|
|
}
|
|
|
|
// StateEvent is one state event in createRoom's initial_state.
|
|
type StateEvent struct {
|
|
Type string `json:"type"`
|
|
StateKey string `json:"state_key"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
|
|
// CreateRoomResponse is what the client is told.
|
|
type CreateRoomResponse struct {
|
|
RoomID string
|
|
|
|
// Alias is the full alias created from RoomAliasName, or empty.
|
|
Alias string
|
|
}
|
|
|
|
// CreateRoom builds a room from nothing.
|
|
//
|
|
// The order of the events is the spec's order, and it is not arbitrary: each
|
|
// one is authorised against the state the ones before it produced. The
|
|
// create event comes first because nothing can be authorised without it; the
|
|
// creator's join comes next because every later event needs a sender who is
|
|
// in the room; the power levels come before the join rules so that the
|
|
// creator still has the power to set them.
|
|
//
|
|
// A failure part-way through leaves a room that exists and is incomplete.
|
|
// That is the lesser evil: the alternative is holding every event of a new
|
|
// room in memory and writing them in one transaction, which would make the
|
|
// pipeline that authorises them a special case, and the pipeline is the one
|
|
// thing in here that must not have special cases. A half-built room is
|
|
// visible only to its creator and can be purged.
|
|
func (r *API) CreateRoom(ctx context.Context, req CreateRoomRequest) (CreateRoomResponse, error) {
|
|
if !r.isLocal(req.Creator) {
|
|
return CreateRoomResponse{}, fmt.Errorf("%w: %s is not a user of this server", ErrForbidden, req.Creator)
|
|
}
|
|
|
|
version := r.defaultRoomVersion
|
|
if req.Version != "" {
|
|
if err := roomversion.Validate(req.Version); err != nil {
|
|
return CreateRoomResponse{}, fmt.Errorf("%w: %s", ErrUnknownRoomVersion, err)
|
|
}
|
|
version = roomversion.Version(req.Version)
|
|
}
|
|
rules, err := roomversion.Get(version)
|
|
if err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
|
|
preset, err := presetFor(req)
|
|
if err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
|
|
var alias string
|
|
if req.RoomAliasName != "" {
|
|
if alias, err = r.aliasFor(req.RoomAliasName); err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
}
|
|
|
|
roomID, createEvent, err := r.buildCreateEvent(ctx, req, rules)
|
|
if err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
|
|
if err := r.store.CreateRoom(ctx, storage.NewRoom{
|
|
ID: roomID,
|
|
Version: string(version),
|
|
Creator: req.Creator,
|
|
Published: req.Visibility == VisibilityPublic,
|
|
CreatedAtMS: r.nowMS(),
|
|
}); err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
|
|
// The alias is claimed before any event is signed, so that a name
|
|
// already taken does not leave a room behind. It cannot be claimed
|
|
// before the room row, because an alias points at a room; so a clash
|
|
// takes the room row back out, which is safe precisely because nothing
|
|
// has been written into the room yet.
|
|
if alias != "" {
|
|
if err := r.store.CreateAlias(ctx, storage.RoomAlias{
|
|
Alias: alias,
|
|
RoomID: roomID,
|
|
Creator: req.Creator,
|
|
CreatedAtMS: r.nowMS(),
|
|
}); err != nil {
|
|
if purgeErr := r.store.PurgeRoom(ctx, roomID); purgeErr != nil {
|
|
r.log.Error("could not remove the room an alias clash abandoned",
|
|
"room_id", roomID, "error", purgeErr)
|
|
}
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
}
|
|
|
|
// The create event is the one event that cannot go through send: there
|
|
// is no room state to read and no extremity to descend from, and in
|
|
// room version 12 the room ID is its own event ID, so it has to exist
|
|
// before the room does.
|
|
if err := r.persistCreateEvent(ctx, roomID, string(version), rules, createEvent); err != nil {
|
|
return CreateRoomResponse{}, err
|
|
}
|
|
|
|
for _, step := range r.creationEvents(ctx, req, preset, rules, roomID) {
|
|
if _, err := r.send(ctx, step); err != nil {
|
|
return CreateRoomResponse{}, fmt.Errorf("creating %s: setting up %s: %w", roomID, step.Type, err)
|
|
}
|
|
}
|
|
|
|
if alias != "" {
|
|
if _, err := r.send(ctx, sendRequest{
|
|
RoomID: roomID,
|
|
Type: TypeCanonicalAlias,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{"alias": alias}),
|
|
}); err != nil {
|
|
return CreateRoomResponse{}, fmt.Errorf("creating %s: setting the canonical alias: %w", roomID, err)
|
|
}
|
|
}
|
|
|
|
// Invites come last, so that an invited user who looks immediately
|
|
// finds a room that is fully set up.
|
|
for _, invitee := range req.Invite {
|
|
if err := r.InviteUser(ctx, InviteRequest{
|
|
RoomID: roomID,
|
|
Sender: req.Creator,
|
|
Target: invitee,
|
|
IsDirect: req.IsDirect,
|
|
}); err != nil {
|
|
return CreateRoomResponse{}, fmt.Errorf("creating %s: inviting %s: %w", roomID, invitee, err)
|
|
}
|
|
}
|
|
|
|
r.log.Info("created room",
|
|
"room_id", roomID, "version", version, "creator", req.Creator, "preset", preset)
|
|
return CreateRoomResponse{RoomID: roomID, Alias: alias}, nil
|
|
}
|
|
|
|
// buildCreateEvent builds the m.room.create event and works out the room ID.
|
|
//
|
|
// From room version 12 the room ID *is* the create event's ID, so the event
|
|
// has to be built before the room can be named; in earlier versions the ID
|
|
// is minted here and written into the event. The event is checked against
|
|
// the authorisation rules with an empty state, which is the one case rule 1
|
|
// covers.
|
|
func (r *API) buildCreateEvent(ctx context.Context, req CreateRoomRequest, rules roomversion.Rules) (string, *event.Event, error) {
|
|
content, err := r.createContent(req, rules)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
roomID := ""
|
|
if !rules.RoomIDIsCreateEventID {
|
|
if roomID, err = r.generateRoomID(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
|
|
builder := event.Builder{
|
|
RoomID: roomID,
|
|
Type: event.TypeCreate,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: content,
|
|
Depth: 1,
|
|
}
|
|
created, err := builder.Build(r.now(), r.signingKey, rules)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("building the create event: %w", err)
|
|
}
|
|
|
|
if rules.RoomIDIsCreateEventID {
|
|
if roomID, err = event.RoomIDOfCreateEvent(created); err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
|
|
// Rule 1 against an empty state: the version is one we support, the
|
|
// creator is well-formed, and in version 12 the room ID matches.
|
|
if err := auth.Check(ctx, created, auth.State{}, nil); err != nil {
|
|
return "", nil, err
|
|
}
|
|
return roomID, created, nil
|
|
}
|
|
|
|
// createContent assembles the m.room.create content.
|
|
//
|
|
// creation_content from the client is merged in underneath, so a client can
|
|
// set m.federate or make a space without being able to overwrite the
|
|
// properties the room version defines — room_version and creator are put on
|
|
// last for that reason.
|
|
func (r *API) createContent(req CreateRoomRequest, rules roomversion.Rules) (json.RawMessage, error) {
|
|
content := map[string]json.RawMessage{}
|
|
if len(req.CreationContent) > 0 {
|
|
if err := json.Unmarshal(req.CreationContent, &content); err != nil {
|
|
return nil, fmt.Errorf("%w: creation_content must be a JSON object", ErrInvalidContent)
|
|
}
|
|
// These are the server's to decide, whatever the client sent.
|
|
delete(content, "room_version")
|
|
delete(content, "creator")
|
|
delete(content, "additional_creators")
|
|
}
|
|
content["room_version"] = mustMarshal(string(rules.Version))
|
|
if rules.CreateEventRequiresCreator {
|
|
content["creator"] = mustMarshal(req.Creator)
|
|
}
|
|
|
|
raw, err := json.Marshal(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: creation_content cannot be encoded: %v", ErrInvalidContent, err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// persistCreateEvent writes the create event, which is the room's entire
|
|
// state and its only extremity.
|
|
//
|
|
// The state it is authorised against is empty, which is what an empty
|
|
// snapshot records; the state *after* it — the create event alone — persist
|
|
// derives from the event itself, as it does for any other state event.
|
|
func (r *API) persistCreateEvent(ctx context.Context, roomID, version string, rules roomversion.Rules, created *event.Event) error {
|
|
return r.persist(ctx, persistRequest{
|
|
Room: storage.Room{ID: roomID, Version: version},
|
|
Rules: rules,
|
|
Event: created,
|
|
StateBefore: auth.State{},
|
|
})
|
|
}
|
|
|
|
// creationEvents are the events that turn a bare create event into a usable
|
|
// room, in the order they have to be sent.
|
|
func (r *API) creationEvents(ctx context.Context, req CreateRoomRequest, preset string, rules roomversion.Rules, roomID string) []sendRequest {
|
|
steps := []sendRequest{
|
|
// The creator joins before anything else: every event after this
|
|
// one has to be sent by a member of the room.
|
|
{
|
|
RoomID: roomID,
|
|
Type: event.TypeMember,
|
|
StateKey: statePtr(req.Creator),
|
|
Sender: req.Creator,
|
|
Content: r.membershipContent(ctx, event.MembershipJoin, req.Creator, "", req.IsDirect),
|
|
},
|
|
// Power levels before the join rules, so that the creator is still
|
|
// the only one who can set them.
|
|
{
|
|
RoomID: roomID,
|
|
Type: event.TypePowerLevels,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: r.powerLevelsContent(req, preset, rules),
|
|
},
|
|
{
|
|
RoomID: roomID,
|
|
Type: event.TypeJoinRules,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{"join_rule": presetJoinRule(preset)}),
|
|
},
|
|
{
|
|
RoomID: roomID,
|
|
Type: TypeHistoryVisibility,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{
|
|
"history_visibility": HistoryVisibilityShared,
|
|
}),
|
|
},
|
|
{
|
|
RoomID: roomID,
|
|
Type: TypeGuestAccess,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{
|
|
"guest_access": presetGuestAccess(preset),
|
|
}),
|
|
},
|
|
}
|
|
|
|
// initial_state comes after the server's own setup events and may
|
|
// overwrite any of them, which is what lets a client create an
|
|
// encrypted or world-readable room in one call.
|
|
for _, s := range req.InitialState {
|
|
steps = append(steps, sendRequest{
|
|
RoomID: roomID,
|
|
Type: s.Type,
|
|
StateKey: statePtr(s.StateKey),
|
|
Sender: req.Creator,
|
|
Content: s.Content,
|
|
})
|
|
}
|
|
|
|
// name and topic come after initial_state, because the spec gives the
|
|
// dedicated parameters precedence over an initial_state event of the
|
|
// same type.
|
|
if req.Name != "" {
|
|
steps = append(steps, sendRequest{
|
|
RoomID: roomID,
|
|
Type: TypeName,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{"name": req.Name}),
|
|
})
|
|
}
|
|
if req.Topic != "" {
|
|
steps = append(steps, sendRequest{
|
|
RoomID: roomID,
|
|
Type: TypeTopic,
|
|
StateKey: statePtr(""),
|
|
Sender: req.Creator,
|
|
Content: mustMarshal(map[string]any{"topic": req.Topic}),
|
|
})
|
|
}
|
|
return steps
|
|
}
|
|
|
|
// powerLevelsContent is the m.room.power_levels event a new room gets.
|
|
//
|
|
// The levels are the spec's defaults written out rather than left implicit,
|
|
// because a client reading the event is entitled to see what the room
|
|
// actually does, and because power_level_content_override has to have
|
|
// something to override.
|
|
func (r *API) powerLevelsContent(req CreateRoomRequest, preset string, rules roomversion.Rules) json.RawMessage {
|
|
users := map[string]any{}
|
|
if !rules.CreatorsHaveInfinitePower {
|
|
// Up to room version 11 the creator's power has to be written down.
|
|
// From version 12 it cannot be: naming a creator in this event is
|
|
// itself a rejection.
|
|
users[req.Creator] = auth.CreatorPowerLevel
|
|
}
|
|
// trusted_private_chat makes everyone invited an equal, which is the
|
|
// whole point of the preset.
|
|
if preset == PresetTrustedPrivateChat {
|
|
for _, invitee := range req.Invite {
|
|
if rules.CreatorsHaveInfinitePower && invitee == req.Creator {
|
|
continue
|
|
}
|
|
users[invitee] = auth.CreatorPowerLevel
|
|
}
|
|
}
|
|
|
|
content := map[string]any{
|
|
"ban": auth.DefaultBan,
|
|
"kick": auth.DefaultKick,
|
|
"redact": auth.DefaultRedact,
|
|
"invite": auth.DefaultInvite,
|
|
"events_default": auth.DefaultEventsDefault,
|
|
"state_default": auth.DefaultStateDefault,
|
|
"users_default": auth.DefaultUsersDefault,
|
|
"users": users,
|
|
"events": map[string]any{
|
|
// Tombstoning a room replaces it, so it is kept at the level
|
|
// the spec reserves for the room's owner even once others have
|
|
// been promoted to moderator.
|
|
"m.room.tombstone": 100,
|
|
"m.room.power_levels": 100,
|
|
"m.room.history_visibility": 100,
|
|
"m.room.canonical_alias": auth.DefaultStateDefault,
|
|
"m.room.avatar": auth.DefaultStateDefault,
|
|
TypeEncryption: 100,
|
|
"m.room.server_acl": 100,
|
|
// Joining a group call is a state event of the member's own
|
|
// (MatrixRTC, MSC3401), so anyone who may talk in the room
|
|
// may also join its call. Starting one is not a separate act.
|
|
TypeCallMember: 0,
|
|
},
|
|
"notifications": map[string]any{"room": auth.DefaultNotification},
|
|
}
|
|
|
|
raw := mustMarshal(content)
|
|
if len(req.PowerLevelContentOverride) == 0 {
|
|
return raw
|
|
}
|
|
// A malformed override is left out rather than failing the room: the
|
|
// client is told nothing either way, and a room with the default levels
|
|
// is recoverable where a room that does not exist is not. The override
|
|
// is still authorised like any other power levels event, so it cannot
|
|
// be used to give anybody more power than the creator has.
|
|
merged, err := mergeJSON(raw, req.PowerLevelContentOverride)
|
|
if err != nil {
|
|
r.log.Warn("ignoring an unusable power_level_content_override", "error", err)
|
|
return raw
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// presetFor resolves the preset, defaulting the way the spec does: from the
|
|
// directory visibility when the client named no preset.
|
|
func presetFor(req CreateRoomRequest) (string, error) {
|
|
switch req.Preset {
|
|
case PresetPrivateChat, PresetTrustedPrivateChat, PresetPublicChat:
|
|
return req.Preset, nil
|
|
case "":
|
|
if req.Visibility == VisibilityPublic {
|
|
return PresetPublicChat, nil
|
|
}
|
|
return PresetPrivateChat, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: %q is not a known preset", ErrInvalidContent, req.Preset)
|
|
}
|
|
}
|
|
|
|
func presetJoinRule(preset string) string {
|
|
if preset == PresetPublicChat {
|
|
return auth.JoinRulePublic
|
|
}
|
|
return auth.JoinRuleInvite
|
|
}
|
|
|
|
func presetGuestAccess(preset string) string {
|
|
if preset == PresetPublicChat {
|
|
return GuestAccessForbidden
|
|
}
|
|
return GuestAccessCanJoin
|
|
}
|
|
|
|
// roomIDRandomBytes is how much randomness a room ID of a version that
|
|
// still needs one carries. 16 bytes is the same 128 bits as a UUID, encoded
|
|
// without padding.
|
|
const roomIDRandomBytes = 16
|
|
|
|
// generateRoomID mints a "!opaque:server" room ID, for the room versions
|
|
// before 12 where the ID is not derived from the create event.
|
|
func (r *API) generateRoomID() (string, error) {
|
|
buf := make([]byte, roomIDRandomBytes)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("generating a room ID: %w", err)
|
|
}
|
|
roomID := "!" + base64.RawURLEncoding.EncodeToString(buf) + ":" + r.serverName
|
|
if err := id.ValidateRoomID(roomID); err != nil {
|
|
return "", err
|
|
}
|
|
return roomID, nil
|
|
}
|
|
|
|
// statePtr makes a state key addressable, since the empty string is a
|
|
// perfectly good one and nil means "not a state event".
|
|
func statePtr(s string) *string { return &s }
|
|
|
|
// mustMarshal encodes a value this package built itself.
|
|
//
|
|
// Every caller passes a map or a string, which cannot fail to encode; a
|
|
// failure here would be a bug in this file rather than anything a client
|
|
// could cause.
|
|
func mustMarshal(v any) json.RawMessage {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic("roomserver: encoding a value this package built: " + err.Error())
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// mergeJSON overlays the properties of override onto base, at the top level
|
|
// only.
|
|
//
|
|
// A shallow merge is what the spec asks for: power_level_content_override
|
|
// replacing the whole "users" map rather than merging into it is the
|
|
// documented behaviour, and the one a client setting up a room relies on.
|
|
func mergeJSON(base, override json.RawMessage) (json.RawMessage, error) {
|
|
var merged, extra map[string]json.RawMessage
|
|
if err := json.Unmarshal(base, &merged); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(override, &extra); err != nil {
|
|
return nil, fmt.Errorf("%w: the override must be a JSON object", ErrInvalidContent)
|
|
}
|
|
for key, value := range extra {
|
|
merged[key] = value
|
|
}
|
|
return json.Marshal(merged)
|
|
}
|
|
|
|
// TypeCallMember is a member's state in a room's group call (MatrixRTC).
|
|
// It is still under its MSC3401 name, which is what clients send.
|
|
const TypeCallMember = "org.matrix.msc3401.call.member"
|