Files
Warp/server/internal/roomserver/create.go
T
tajniak81andClaude Opus 5.5 76dfc98a86
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
Group voice and video calls with MatrixRTC and LiveKit
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>
2026-10-18 19:33:29 +02:00

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"