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
Implements PLAN.md Phase 3: the roomserver, the room half of storage, the Client-Server room endpoints and the admin room endpoints. A client can now create a room, invite, join, leave, kick, ban, send messages and state, redact, name a room with an alias and find one in the public directory. Nothing talks to another server; every operation that would need to is refused with a message that says so rather than a bare M_FORBIDDEN. roomserver: one input pipeline every event goes through, whatever sent it — validate, authorise, resolve, persist, emit. The rules are not reimplemented here; internal/matrix is pure library code and this is what connects it to a database and to a room's history. The Client-Server handlers do no authorisation of their own, deliberately: whether Alice may kick Bob is a question about the room's power levels, and the one place that is answered correctly is the authorisation rules inside the pipeline. A mutex per room serialises writers. Two events racing into one room would each read the same forward extremities, both become one, and fork the room for no reason — which would then need state resolution to put back together. State resolution is wired in anyway for the multi-extremity case. It is unreachable from outside in this phase, and the alternative is a pipeline that authorises against one arbitrary branch of a fork without saying so. Room versions 3 to 12 all work and are each tested end to end. Version 12 earns that coverage: its room ID is the create event's own ID, its create event carries no room_id, and the create event is excluded from every other event's auth_events — so the state auth.Check is given is the event's auth_events plus the create event, which that version identifies by room ID rather than citing. createRoom sends the spec's events in the spec's order, each authorised against what the ones before it produced. A failure part-way leaves an incomplete room, which is the lesser evil: the alternative is holding every event of a new room in memory and writing them in one transaction, making the pipeline that authorises them a special case. The one failure a second client can cause at any time, an alias clash, takes the room row back out. Redactions stay two decisions, as the spec keeps them. The rules let almost any redaction into the room; whether it applies to its target is a separate test, run inside the same transaction as the redaction itself so that a client holding an event ID can always read the stripped event. storage: events keyed by event ID rather than by a surrogate, since an event ID appears in prev_events, auth_events, state snapshots and client requests alike. Stream ordering comes from a counter row bumped inside the write transaction, because a PostgreSQL sequence is explicitly not transactional and a rolled-back write would leave a hole in the stream that /sync would have to be taught to skip. State snapshots are stored per event and deduplicated by content hash, so the table grows with state changes rather than with traffic — and that is what makes "the state after a forward extremity" answerable at all. The room's listing columns are denormalised onto its row because the public directory orders every published room by member count and pages the result, which cannot be done by reading each room's state in turn. Federation-disabled behaviour from PLAN.md section 3 is enforced here. Restricted joins are the exception that works: the rule needs a signature from the authorising user's server verified, and while federation is off that server is always this one. adminapi: room list and detail, purge, force-leave, and room and event counters on the dashboard. clientapi gains its own rate-limit buckets for createRoom and for joining, both of which write events rather than read them. Tested against the real event core and a real database rather than fakes: what is under test is whether an event this server signs is one the authorisation rules accept, and a fake of either would only prove that the pipeline calls what the fake expects. The storage tests run against both SQL drivers. docs/rooms.md describes the pipeline, the storage decisions and the known gaps — chief among them that history visibility is room-level rather than per-event, and that /sync still reports no rooms, both of which belong with the event stream in Phase 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
541 lines
18 KiB
Go
541 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,
|
|
},
|
|
"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)
|
|
}
|