Phase 3: rooms, local only
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>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-16 10:34:57 +02:00
1 parent 2fbd5f53ce
commit c96f88e994
45 files changed
+9637 -42

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
package roomserver
import (
"context"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// RoomDetails is a room as the admin panel shows it: the stored summary
// plus the things only a count can answer.
type RoomDetails struct {
storage.RoomSummary
// Members counts every m.room.member event in the current state,
// whatever the membership — a room with one member and forty bans is
// not the same room as one with one member.
Members int
// InvitedMembers counts the outstanding invites.
InvitedMembers int
// Aliases are the local aliases pointing at the room.
Aliases []string
// ForwardExtremities are the event IDs the room's DAG currently ends
// at. There is normally one; more than one means the room has forked,
// which is worth seeing on an operator's screen.
ForwardExtremities []string
}
// ListRooms returns a page of every room on this server, for the admin API.
func (r *API) ListRooms(ctx context.Context, filter storage.RoomFilter) ([]storage.RoomSummary, int, error) {
return r.store.ListRooms(ctx, filter)
}
// RoomDetails reads one room in full, for the admin API.
func (r *API) RoomDetails(ctx context.Context, roomID string) (RoomDetails, error) {
summary, err := r.store.RoomSummary(ctx, roomID)
if err != nil {
return RoomDetails{}, err
}
memberships, err := r.store.MembershipEntries(ctx, roomID, nil)
if err != nil {
return RoomDetails{}, err
}
details := RoomDetails{RoomSummary: summary, Members: len(memberships)}
for _, m := range memberships {
if m.Membership == event.MembershipInvite {
details.InvitedMembers++
}
}
if details.Aliases, err = r.store.AliasesForRoom(ctx, roomID); err != nil {
return RoomDetails{}, err
}
if details.Aliases == nil {
details.Aliases = []string{}
}
extremities, err := r.store.Extremities(ctx, roomID)
if err != nil {
return RoomDetails{}, err
}
details.ForwardExtremities = make([]string, 0, len(extremities))
for _, e := range extremities {
details.ForwardExtremities = append(details.ForwardExtremities, e.EventID)
}
return details, nil
}
// PurgeRoom deletes a room and everything belonging to it.
//
// This is the one operation in the roomserver that destroys history, and it
// is irreversible: the events are gone, not tombstoned. It exists because a
// room can become something an operator has to be able to remove — abuse, a
// room nobody is in any more, a room whose version this build no longer
// supports — and because the alternative an operator reaches for otherwise
// is deleting rows by hand.
//
// It does not tell the room's members. Nothing can: the room's events are
// what a membership change would have to be added to.
func (r *API) PurgeRoom(ctx context.Context, roomID string) error {
// The lock is taken so that a purge cannot race a send into the same
// room and leave the event pointing at a room row that is gone.
mu := r.lock(roomID)
mu.Lock()
defer mu.Unlock()
if err := r.store.PurgeRoom(ctx, roomID); err != nil {
return err
}
// The lock itself is dropped: a purged room ID will not come back, and
// keeping its mutex would make the map grow with every purge.
r.locks.Delete(roomID)
r.log.Warn("purged room", "room_id", roomID)
return nil
}
// Stats returns the aggregate room counts for the admin dashboard.
func (r *API) Stats(ctx context.Context) (storage.RoomStats, error) {
return r.store.RoomStats(ctx)
}
+540
View File
@@ -0,0 +1,540 @@
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)
}
+395
View File
@@ -0,0 +1,395 @@
package roomserver
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// MaxAliasLength is the spec's cap on a room alias, sigil and server name
// included.
const MaxAliasLength = id.MaxLength
// aliasFor builds a full local alias from the localpart a client supplied,
// and checks it.
func (r *API) aliasFor(localpart string) (string, error) {
alias := "#" + localpart + ":" + r.serverName
if err := r.validateLocalAlias(alias); err != nil {
return "", err
}
return alias, nil
}
// validateLocalAlias checks an alias and insists it belongs to this server.
//
// The grammar is deliberately the loose one the spec gives for an alias —
// anything but a colon, and no control characters — rather than the
// localpart rules an account is held to. An alias is a label a room's
// members chose, and the spec puts no character restrictions on it beyond
// what parsing needs.
func (r *API) validateLocalAlias(alias string) error {
if err := validateAliasGrammar(alias); err != nil {
return err
}
_, serverName, _ := strings.Cut(alias[1:], ":")
if serverName != r.serverName {
return fmt.Errorf("%w: %s is not an alias of this server", ErrForbidden, alias)
}
return nil
}
// validateAliasGrammar checks an alias of any server.
func validateAliasGrammar(alias string) error {
switch {
case alias == "":
return fmt.Errorf("%w: a room alias must not be empty", ErrInvalidContent)
case alias[0] != '#':
return fmt.Errorf("%w: a room alias must start with '#'", ErrInvalidContent)
case len(alias) > MaxAliasLength:
return fmt.Errorf("%w: a room alias may be at most %d bytes", ErrInvalidContent, MaxAliasLength)
}
localpart, serverName, found := strings.Cut(alias[1:], ":")
switch {
case !found:
return fmt.Errorf("%w: %s has no server name; expected #alias:server", ErrInvalidContent, alias)
case localpart == "":
return fmt.Errorf("%w: %s has an empty localpart", ErrInvalidContent, alias)
case serverName == "":
return fmt.Errorf("%w: %s has an empty server name", ErrInvalidContent, alias)
}
// A control character in an alias would be invisible in every client
// that shows it, which is exactly what makes it worth refusing.
if strings.ContainsFunc(alias, func(c rune) bool { return c < 0x20 || c == 0x7f }) {
return fmt.Errorf("%w: %s contains a control character", ErrInvalidContent, alias)
}
return nil
}
// isLocalAlias reports whether an alias belongs to this server.
func (r *API) isLocalAlias(alias string) bool {
if validateAliasGrammar(alias) != nil {
return false
}
_, serverName, _ := strings.Cut(alias[1:], ":")
return serverName == r.serverName
}
// CreateAlias points a local alias at a room.
//
// The caller has to be in the room. The spec leaves the permission to the
// server, and "a member of the room may give it a name" is the rule that
// matches what an alias is for while keeping a stranger from squatting
// names against rooms they cannot see.
func (r *API) CreateAlias(ctx context.Context, alias, roomID, userID string) error {
if err := r.validateLocalAlias(alias); err != nil {
return err
}
if err := id.ValidateRoomID(roomID); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidContent, err)
}
if _, err := r.store.Room(ctx, roomID); err != nil {
return err
}
if err := r.requireJoined(ctx, roomID, userID); err != nil {
return err
}
return r.store.CreateAlias(ctx, storage.RoomAlias{
Alias: alias,
RoomID: roomID,
Creator: userID,
CreatedAtMS: r.nowMS(),
})
}
// DeleteAlias removes a local alias.
//
// Either the user who created it, or a user who could set the room's
// canonical alias, may delete it — the first because it is theirs, the
// second because the room's moderators have to be able to clean up after a
// member who has left.
func (r *API) DeleteAlias(ctx context.Context, alias, userID string, isAdmin bool) error {
if err := r.validateLocalAlias(alias); err != nil {
return err
}
stored, err := r.store.Alias(ctx, alias)
if err != nil {
return err
}
if !isAdmin && stored.Creator != userID {
canSet, err := r.CanSendStateEvent(ctx, stored.RoomID, userID, TypeCanonicalAlias)
if err != nil {
return err
}
if !canSet {
return fmt.Errorf("%w: %s may not delete the alias %s", ErrForbidden, userID, alias)
}
}
return r.store.DeleteAlias(ctx, alias)
}
// ResolvedAlias is what a directory lookup returns.
type ResolvedAlias struct {
RoomID string
// Servers are the servers known to be in the room, which a client
// passes back as ?server_name= when it joins. With federation off the
// only one is this server.
Servers []string
}
// ResolveAlias turns a room alias into a room ID.
//
// A remote alias has to be asked of the server that owns it, which needs
// federation; with it off the client is told that rather than being told
// the alias does not exist, because the two call for quite different
// reactions.
func (r *API) ResolveAlias(ctx context.Context, alias string) (ResolvedAlias, error) {
if err := validateAliasGrammar(alias); err != nil {
return ResolvedAlias{}, err
}
if !r.isLocalAlias(alias) {
if !r.federationEnabled() {
return ResolvedAlias{}, fmt.Errorf(
"%w: %s belongs to another server, and resolving a remote alias",
ErrFederationDisabled, alias)
}
return ResolvedAlias{}, fmt.Errorf(
"%w: resolving an alias of another server is not implemented yet", ErrFederationDisabled)
}
stored, err := r.store.Alias(ctx, alias)
if err != nil {
return ResolvedAlias{}, err
}
return ResolvedAlias{RoomID: stored.RoomID, Servers: []string{r.serverName}}, nil
}
// RoomAliases lists the local aliases of a room, for a user who may see it.
func (r *API) RoomAliases(ctx context.Context, roomID, userID string) ([]string, error) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
if err := r.requireVisibility(ctx, room, rules, userID); err != nil {
return nil, err
}
aliases, err := r.store.AliasesForRoom(ctx, roomID)
if err != nil {
return nil, err
}
if aliases == nil {
aliases = []string{}
}
return aliases, nil
}
// RoomVisibility reports whether a room is in this server's public
// directory.
func (r *API) RoomVisibility(ctx context.Context, roomID string) (string, error) {
room, err := r.store.Room(ctx, roomID)
if err != nil {
return "", err
}
if room.Published {
return VisibilityPublic, nil
}
return VisibilityPrivate, nil
}
// SetRoomVisibility publishes a room in this server's directory, or
// withdraws it.
//
// The permission is the one the spec hints at and Synapse settled on: a
// user who could set the room's canonical alias, or a server admin. An
// unpublished room that nobody can find is recoverable; a room published by
// a passer-by is not.
func (r *API) SetRoomVisibility(ctx context.Context, roomID, userID, visibility string, isAdmin bool) error {
switch visibility {
case VisibilityPublic, VisibilityPrivate:
default:
return fmt.Errorf("%w: visibility must be %q or %q", ErrInvalidContent,
VisibilityPublic, VisibilityPrivate)
}
if _, err := r.store.Room(ctx, roomID); err != nil {
return err
}
if !isAdmin {
canSet, err := r.CanSendStateEvent(ctx, roomID, userID, TypeCanonicalAlias)
if err != nil {
return err
}
if !canSet {
return fmt.Errorf("%w: %s may not change whether %s is published",
ErrForbidden, userID, roomID)
}
}
return r.store.SetPublished(ctx, roomID, visibility == VisibilityPublic)
}
// PublicRoomsRequest pages and filters the public room directory.
type PublicRoomsRequest struct {
// Limit is how many rooms to return; 0 lets the store decide.
Limit int
// Since is the pagination token from a previous response.
Since string
// Search is the generic search term from the filter.
Search string
// Server is the ?server= parameter: another server's directory, which
// needs federation.
Server string
}
// PublicRoom is one entry in the public room directory.
type PublicRoom struct {
RoomID string `json:"room_id"`
Name string `json:"name,omitempty"`
Topic string `json:"topic,omitempty"`
CanonicalAlias string `json:"canonical_alias,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
NumJoinedMembers int `json:"num_joined_members"`
WorldReadable bool `json:"world_readable"`
GuestCanJoin bool `json:"guest_can_join"`
JoinRule string `json:"join_rule,omitempty"`
RoomType string `json:"room_type,omitempty"`
Aliases []string `json:"aliases,omitempty"`
}
// PublicRoomsResponse is the directory page.
type PublicRoomsResponse struct {
Chunk []PublicRoom `json:"chunk"`
// NextBatch and PrevBatch are the tokens for the pages either side,
// and are absent at the ends.
NextBatch string `json:"next_batch,omitempty"`
PrevBatch string `json:"prev_batch,omitempty"`
TotalRoomCountEstimate int `json:"total_room_count_estimate"`
}
// PublicRooms returns a page of this server's public room directory.
//
// The pagination token is the offset, written out as a decimal. That is
// enough for a directory ordered by member count and is honest about what
// it is: a token that encoded a cursor would imply a stability the ordering
// does not have, since a room gaining a member moves between pages whatever
// the token says.
func (r *API) PublicRooms(ctx context.Context, req PublicRoomsRequest) (PublicRoomsResponse, error) {
if req.Server != "" && req.Server != r.serverName {
if !r.federationEnabled() {
return PublicRoomsResponse{}, fmt.Errorf(
"%w: reading the room directory of %s", ErrFederationDisabled, req.Server)
}
return PublicRoomsResponse{}, fmt.Errorf(
"%w: reading another server's room directory is not implemented yet", ErrFederationDisabled)
}
offset, err := parseDirectoryToken(req.Since)
if err != nil {
return PublicRoomsResponse{}, err
}
summaries, total, err := r.store.PublishedRooms(ctx, storage.RoomFilter{
Search: req.Search,
Limit: req.Limit,
Offset: offset,
})
if err != nil {
return PublicRoomsResponse{}, err
}
resp := PublicRoomsResponse{
Chunk: make([]PublicRoom, 0, len(summaries)),
TotalRoomCountEstimate: total,
}
for _, summary := range summaries {
aliases, err := r.store.AliasesForRoom(ctx, summary.ID)
if err != nil {
return PublicRoomsResponse{}, err
}
resp.Chunk = append(resp.Chunk, publicRoomOf(summary, aliases))
}
if offset > 0 {
resp.PrevBatch = directoryToken(max(offset-len(summaries), 0))
}
if offset+len(summaries) < total {
resp.NextBatch = directoryToken(offset + len(summaries))
}
return resp, nil
}
// publicRoomOf projects a stored room onto a directory entry.
func publicRoomOf(summary storage.RoomSummary, aliases []string) PublicRoom {
return PublicRoom{
RoomID: summary.ID,
Name: summary.Name,
Topic: summary.Topic,
CanonicalAlias: summary.CanonicalAlias,
AvatarURL: summary.AvatarURL,
NumJoinedMembers: summary.JoinedMembers,
WorldReadable: summary.HistoryVisibility == HistoryVisibilityWorldReadable,
GuestCanJoin: summary.GuestAccess == GuestAccessCanJoin,
JoinRule: summary.JoinRule,
RoomType: summary.RoomType,
Aliases: aliases,
}
}
// containsString reports whether needle is in haystack.
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// membershipFilterValues are the memberships a client may filter a member
// list by.
var membershipFilterValues = []string{
event.MembershipJoin, event.MembershipLeave,
event.MembershipInvite, event.MembershipBan, event.MembershipKnock,
}
// ValidateMembershipFilter checks a ?membership= or ?not_membership= value.
func ValidateMembershipFilter(value string) error {
if value == "" || containsString(membershipFilterValues, value) {
return nil
}
return fmt.Errorf("%w: %q is not a membership", ErrInvalidContent, value)
}
// maxDirectoryOffset bounds how far into the directory a pagination token
// may point. The token comes back from a client, so it is input: without a
// bound, "since=99999999999999" is an invitation to make the database count
// past the end of a table.
const maxDirectoryOffset = 1 << 20
// directoryToken renders a directory offset as a pagination token.
func directoryToken(offset int) string {
return strconv.Itoa(offset)
}
// parseDirectoryToken reads a pagination token back.
func parseDirectoryToken(token string) (int, error) {
if token == "" {
return 0, nil
}
offset, err := strconv.Atoi(token)
if err != nil || offset < 0 || offset > maxDirectoryOffset {
return 0, fmt.Errorf("%w: %q is not a pagination token from this server", ErrInvalidContent, token)
}
return offset, nil
}
@@ -0,0 +1,326 @@
package roomserver_test
import (
"errors"
"testing"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
)
func TestAliasLifecycle(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
const alias = "#chat:" + testServerName
if err := f.api.CreateAlias(t.Context(), alias, resp.RoomID, alice); err != nil {
t.Fatalf("CreateAlias: %v", err)
}
resolved, err := f.api.ResolveAlias(t.Context(), alias)
if err != nil {
t.Fatalf("ResolveAlias: %v", err)
}
if resolved.RoomID != resp.RoomID {
t.Errorf("the alias resolves to %q, want %q", resolved.RoomID, resp.RoomID)
}
// A client needs somewhere to join through, which with federation off
// is this server.
if len(resolved.Servers) != 1 || resolved.Servers[0] != testServerName {
t.Errorf("the alias names servers %v, want [%s]", resolved.Servers, testServerName)
}
aliases, err := f.api.RoomAliases(t.Context(), resp.RoomID, alice)
if err != nil {
t.Fatalf("RoomAliases: %v", err)
}
if len(aliases) != 1 || aliases[0] != alias {
t.Errorf("RoomAliases = %v, want [%s]", aliases, alias)
}
// The creator of an alias may remove it.
if err := f.api.DeleteAlias(t.Context(), alias, alice, false); err != nil {
t.Fatalf("DeleteAlias: %v", err)
}
if _, err := f.api.ResolveAlias(t.Context(), alias); !errors.Is(err, roomserver.ErrNoSuchAlias) {
t.Errorf("the deleted alias still resolves: %v", err)
}
}
func TestAliasPermissions(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
// Only a member may name a room, so that a stranger cannot squat names
// against rooms they cannot see.
err := f.api.CreateAlias(t.Context(), "#squat:"+testServerName, resp.RoomID, bob)
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("a stranger creating an alias = %v, want ErrForbidden", err)
}
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
const bobsAlias = "#bobs:" + testServerName
if err := f.api.CreateAlias(t.Context(), bobsAlias, resp.RoomID, bob); err != nil {
t.Fatalf("CreateAlias as a member: %v", err)
}
// carol is nobody here and may not delete it.
if err := f.api.DeleteAlias(t.Context(), bobsAlias, carol, false); !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("a stranger deleting an alias = %v, want ErrForbidden", err)
}
// alice can, because she could set the room's canonical alias — which
// is what lets a room's moderators clean up after a departed member.
if err := f.api.DeleteAlias(t.Context(), bobsAlias, alice, false); err != nil {
t.Errorf("a moderator deleting a member's alias: %v", err)
}
// An alias of another server is not ours to create or resolve.
err = f.api.CreateAlias(t.Context(), "#elsewhere:other.example", resp.RoomID, alice)
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("creating a remote alias = %v, want ErrForbidden", err)
}
_, err = f.api.ResolveAlias(t.Context(), "#elsewhere:other.example")
if !errors.Is(err, roomserver.ErrFederationDisabled) {
t.Errorf("resolving a remote alias = %v, want ErrFederationDisabled", err)
}
// Malformed aliases are refused before anything is looked up.
for _, bad := range []string{"", "general", "#", "#general", "#:" + testServerName} {
if err := f.api.CreateAlias(t.Context(), bad, resp.RoomID, alice); !errors.Is(err, roomserver.ErrInvalidContent) {
t.Errorf("CreateAlias(%q) = %v, want ErrInvalidContent", bad, err)
}
}
}
func TestPublicRoomDirectory(t *testing.T) {
f := newFixture(t, nil)
private := f.createRoom(roomserver.CreateRoomRequest{Name: "Private"})
public := f.createRoom(roomserver.CreateRoomRequest{
Preset: roomserver.PresetPublicChat,
Visibility: roomserver.VisibilityPublic,
Name: "Public Room",
Topic: "Anybody may join",
})
resp, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{})
if err != nil {
t.Fatalf("PublicRooms: %v", err)
}
if resp.TotalRoomCountEstimate != 1 || len(resp.Chunk) != 1 {
t.Fatalf("the directory has %d of %d rooms, want 1 of 1",
len(resp.Chunk), resp.TotalRoomCountEstimate)
}
entry := resp.Chunk[0]
if entry.RoomID != public.RoomID {
t.Errorf("the directory lists %q, want %q", entry.RoomID, public.RoomID)
}
if entry.Name != "Public Room" || entry.Topic != "Anybody may join" {
t.Errorf("the directory entry is %+v", entry)
}
if entry.NumJoinedMembers != 1 {
t.Errorf("num_joined_members = %d, want 1", entry.NumJoinedMembers)
}
if entry.WorldReadable {
t.Error("a shared-history room is reported as world-readable")
}
// The public_chat preset makes the room joinable by anyone with an
// account and closed to guests. The two are independent, which is
// exactly why the pairing is worth pinning down.
if entry.JoinRule != "public" {
t.Errorf("join_rule = %q, want public", entry.JoinRule)
}
if entry.GuestCanJoin {
t.Error("a public_chat room reports that guests may join")
}
// Publishing the private room puts it in, and the two then order by
// member count.
if err := f.api.SetRoomVisibility(t.Context(), private.RoomID, alice,
roomserver.VisibilityPublic, false); err != nil {
t.Fatalf("SetRoomVisibility: %v", err)
}
if visibility, err := f.api.RoomVisibility(t.Context(), private.RoomID); err != nil {
t.Fatalf("RoomVisibility: %v", err)
} else if visibility != roomserver.VisibilityPublic {
t.Errorf("visibility = %q, want public", visibility)
}
resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{})
if err != nil {
t.Fatalf("PublicRooms: %v", err)
}
if resp.TotalRoomCountEstimate != 2 {
t.Errorf("the directory has %d rooms, want 2", resp.TotalRoomCountEstimate)
}
// The search looks at the name.
resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Search: "public"})
if err != nil {
t.Fatalf("PublicRooms with a search: %v", err)
}
if len(resp.Chunk) != 1 || resp.Chunk[0].RoomID != public.RoomID {
t.Errorf("searching for \"public\" returned %+v", resp.Chunk)
}
// Withdrawing a room takes it back out.
if err := f.api.SetRoomVisibility(t.Context(), public.RoomID, alice,
roomserver.VisibilityPrivate, false); err != nil {
t.Fatalf("SetRoomVisibility: %v", err)
}
resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{})
if err != nil {
t.Fatalf("PublicRooms: %v", err)
}
if resp.TotalRoomCountEstimate != 1 {
t.Errorf("the directory has %d rooms after a withdrawal, want 1", resp.TotalRoomCountEstimate)
}
}
func TestPublicRoomDirectoryPaging(t *testing.T) {
f := newFixture(t, nil)
for range 5 {
f.createRoom(roomserver.CreateRoomRequest{
Preset: roomserver.PresetPublicChat,
Visibility: roomserver.VisibilityPublic,
})
}
first, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Limit: 2})
if err != nil {
t.Fatalf("PublicRooms: %v", err)
}
if len(first.Chunk) != 2 || first.NextBatch == "" || first.PrevBatch != "" {
t.Fatalf("the first page is %d rooms, next=%q prev=%q",
len(first.Chunk), first.NextBatch, first.PrevBatch)
}
second, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{
Limit: 2, Since: first.NextBatch,
})
if err != nil {
t.Fatalf("PublicRooms page two: %v", err)
}
if len(second.Chunk) != 2 || second.PrevBatch == "" {
t.Fatalf("the second page is %d rooms, prev=%q", len(second.Chunk), second.PrevBatch)
}
if second.Chunk[0].RoomID == first.Chunk[0].RoomID {
t.Error("the second page repeats the first")
}
last, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{
Limit: 2, Since: second.NextBatch,
})
if err != nil {
t.Fatalf("PublicRooms page three: %v", err)
}
if len(last.Chunk) != 1 || last.NextBatch != "" {
t.Errorf("the last page is %d rooms, next=%q", len(last.Chunk), last.NextBatch)
}
// A token this server did not issue is refused rather than clamped: a
// client that made one up should hear about it.
if _, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{
Since: "not-a-token",
}); !errors.Is(err, roomserver.ErrInvalidContent) {
t.Errorf("an invented pagination token = %v, want ErrInvalidContent", err)
}
}
func TestPublicRoomDirectoryPermissions(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
// An ordinary member cannot publish the room: a room published by a
// passer-by cannot be taken back by its moderators.
err := f.api.SetRoomVisibility(t.Context(), resp.RoomID, bob,
roomserver.VisibilityPublic, false)
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("a member publishing a room = %v, want ErrForbidden", err)
}
// An admin can, whoever they are.
if err := f.api.SetRoomVisibility(t.Context(), resp.RoomID, bob,
roomserver.VisibilityPublic, true); err != nil {
t.Errorf("an admin publishing a room: %v", err)
}
// A value that is neither is a bad request, not a silent no-op.
err = f.api.SetRoomVisibility(t.Context(), resp.RoomID, alice, "secret", false)
if !errors.Is(err, roomserver.ErrInvalidContent) {
t.Errorf("an invented visibility = %v, want ErrInvalidContent", err)
}
// Another server's directory needs federation.
_, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Server: "other.example"})
if !errors.Is(err, roomserver.ErrFederationDisabled) {
t.Errorf("reading a remote directory = %v, want ErrFederationDisabled", err)
}
}
func TestAdminRoomListAndPurge(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Preset: roomserver.PresetPublicChat,
Name: "Doomed",
RoomAliasName: "doomed",
})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
if _, err := f.sendMessage(resp.RoomID, alice, "hello", "m1"); err != nil {
t.Fatalf("sending a message: %v", err)
}
details, err := f.api.RoomDetails(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomDetails: %v", err)
}
if details.Name != "Doomed" || details.JoinedMembers != 2 || details.Members != 2 {
t.Errorf("RoomDetails = %+v", details)
}
if len(details.Aliases) != 1 {
t.Errorf("RoomDetails lists %v aliases, want one", details.Aliases)
}
// A room filled by one server has a single forward extremity; more
// than one would mean it had forked.
if len(details.ForwardExtremities) != 1 {
t.Errorf("the room has %d forward extremities, want 1", len(details.ForwardExtremities))
}
stats, err := f.api.Stats(t.Context())
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Rooms != 1 || stats.Published != 0 || stats.Events == 0 {
t.Errorf("Stats = %+v", stats)
}
if err := f.api.PurgeRoom(t.Context(), resp.RoomID); err != nil {
t.Fatalf("PurgeRoom: %v", err)
}
if _, err := f.api.RoomDetails(t.Context(), resp.RoomID); !errors.Is(err, roomserver.ErrNoSuchRoom) {
t.Errorf("the purged room is still there: %v", err)
}
// The alias went with it, so the name can be used again.
if _, err := f.api.ResolveAlias(t.Context(), resp.Alias); !errors.Is(err, roomserver.ErrNoSuchAlias) {
t.Errorf("the purged room's alias still resolves: %v", err)
}
if rooms, err := f.api.JoinedRooms(t.Context(), bob); err != nil {
t.Fatalf("JoinedRooms: %v", err)
} else if len(rooms) != 0 {
t.Errorf("bob is still joined to %v after the purge", rooms)
}
if err := f.api.PurgeRoom(t.Context(), resp.RoomID); !errors.Is(err, roomserver.ErrNoSuchRoom) {
t.Errorf("purging twice = %v, want ErrNoSuchRoom", err)
}
}
+420
View File
@@ -0,0 +1,420 @@
package roomserver
import (
"context"
"encoding/json"
"errors"
"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/matrix/stateres"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// sendRequest is one event this server is about to create.
//
// What the caller supplies is what the event says. Everything that makes it
// a valid link in the room's DAG — prev_events, depth, auth_events, the
// hashes and the signature — is worked out by the pipeline, because only the
// pipeline holds the room lock that makes those answers stable.
type sendRequest struct {
RoomID string
Type string
// StateKey makes this a state event when it is non-nil. The empty
// string is a valid state key.
StateKey *string
// Sender is the full user ID sending the event. It must be a user of
// this server: an event is signed by its sender's server, and this
// server can only sign as itself.
Sender string
Content json.RawMessage
// Redacts is the target of an m.room.redaction event. The builder puts
// it wherever the room version keeps it.
Redacts string
// Transaction, when set, records the client transaction ID this event
// came from, so that a retried request does not send a second event.
Transaction *storage.TransactionRef
}
// send is the input pipeline for an event this server creates.
//
// The stages are the ones PLAN.md names. Validate: the sender is ours and
// the content is something we will sign. Authorise: the event is built
// against the room's state and run through the authorisation rules, with
// exactly the auth_events the selection algorithm asks for. Resolve: the
// state the event is judged against is the state after the room's forward
// extremities, resolved when there is more than one of them. Persist: the
// event, its state snapshot, the new current state and the new extremity go
// down in one transaction. Emit: the sync API is told.
//
// Everything after the lock is taken is deterministic, which is what lets
// two clients sending into the same room at the same moment produce a
// straight chain rather than a fork.
func (r *API) send(ctx context.Context, req sendRequest) (*event.Event, error) {
if !r.isLocal(req.Sender) {
return nil, fmt.Errorf("%w: %s is not a user of this server", ErrForbidden, req.Sender)
}
content, err := validateContent(req.Content)
if err != nil {
return nil, err
}
req.Content = content
mu := r.lock(req.RoomID)
mu.Lock()
defer mu.Unlock()
room, err := r.store.Room(ctx, req.RoomID)
if err != nil {
return nil, err
}
rules, err := r.rules(room)
if err != nil {
return nil, err
}
// A retried request must not send a second event. The check is inside
// the lock so that two copies of the same request racing each other
// cannot both find the transaction unseen.
if req.Transaction != nil {
switch existing, err := r.store.TransactionEvent(ctx, *req.Transaction); {
case err == nil:
stored, err := r.store.Event(ctx, req.RoomID, existing)
if err != nil {
return nil, err
}
return parseEvent(stored, rules)
case !isNotFound(err):
return nil, err
}
}
st, extremities, replacesWholeState, err := r.roomState(ctx, room, rules)
if err != nil {
return nil, err
}
authEvents, err := auth.SelectAuthEvents(rules, req.Type, req.Sender, req.StateKey, req.Content, st)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrRejected, err)
}
built, err := r.build(req, rules, authEvents, extremities)
if err != nil {
return nil, err
}
// Rule 3: the auth_events the event declares are well-formed and are
// the ones the selection algorithm asks for. The pipeline chose them
// itself a moment ago, so this can only fail on a bug in that choice —
// which is exactly the bug worth catching before an event is in a room
// and every other server has to agree with it.
if err := auth.CheckAuthEvents(built, authEvents); err != nil {
return nil, err
}
// The rules are applied against the state the event's own auth_events
// name, which is what every other server in the room will do when it
// sees this event. For an event created here that state is also the
// room's current state, so there is no separate soft-failure check to
// make: the two can only differ for an event that arrives from
// somewhere else, which is Phase 5.
if err := auth.Check(ctx, built, checkState(authEvents, st), r.verifier()); err != nil {
return nil, err
}
if err := r.persist(ctx, persistRequest{
Room: room,
Rules: rules,
Event: built,
StateBefore: st,
ReplaceWholeState: replacesWholeState,
Transaction: req.Transaction,
RedactsEventID: req.Redacts,
}); err != nil {
return nil, err
}
return built, nil
}
// build assembles and signs the event.
func (r *API) build(req sendRequest, rules roomversion.Rules, authEvents, prevEvents []*event.Event) (*event.Event, error) {
builder := event.Builder{
Type: req.Type,
StateKey: req.StateKey,
Sender: req.Sender,
Content: req.Content,
Redacts: req.Redacts,
AuthEvents: eventIDs(authEvents),
PrevEvents: eventIDs(prevEvents),
Depth: nextDepth(prevEvents),
}
// A room version 12 create event carries no room_id, because the room
// ID is its own event ID. Nothing else in a room may leave it out.
if !rules.RoomIDIsCreateEventID || req.Type != event.TypeCreate {
builder.RoomID = req.RoomID
}
built, err := builder.Build(r.now(), r.signingKey, rules)
if err != nil {
return nil, fmt.Errorf("building a %s event: %w", req.Type, err)
}
return built, nil
}
// checkState is the state an event is judged against: the events its
// auth_events name, plus the create event.
//
// The create event is added separately because from room version 12 it is
// not cited in auth_events at all — the room ID *is* its event ID, so
// naming it would be saying the same thing twice. The rules still need it:
// it is what says who the room's creators are and, in that same version,
// what the room's ID should be. In earlier versions it is already among the
// auth events and this changes nothing.
func checkState(authEvents []*event.Event, roomState auth.State) auth.State {
st := auth.NewState(authEvents)
if st.Create() == nil {
if create := roomState.Create(); create != nil {
st[auth.StateKey{Type: event.TypeCreate}] = create
}
}
return st
}
// eventIDs projects events onto their IDs, in order.
func eventIDs(events []*event.Event) []string {
out := make([]string, 0, len(events))
for _, e := range events {
out = append(out, e.EventID())
}
return out
}
// nextDepth is one more than the greatest depth among the prev_events.
//
// Depth is advisory — it orders events for display and is never used to
// authorise one — so a room that reaches the limit stays there rather than
// failing: refusing to accept any further events would be a worse outcome
// than a flat tail, and the limit is 2^53-1 deep.
func nextDepth(prevEvents []*event.Event) int64 {
var depth int64
for _, e := range prevEvents {
if e.Depth() > depth {
depth = e.Depth()
}
}
if depth >= event.MaxDepth {
return event.MaxDepth
}
return depth + 1
}
// roomState returns the state the next event in a room is authorised
// against, along with the room's forward extremities.
//
// The stored current state is the answer whenever a room has a single
// forward extremity, which is every room this server fills on its own: the
// lock in send makes each new event a child of the one before it. A room
// with several extremities has forked, and the state is then resolved from
// the state after each of them — the third return value says so, because a
// resolved state has to be written out whole: tuples the resolution dropped
// have to go, and an incremental update cannot express that.
//
// Only federation forks a room, so in Phase 3 the second path is unreachable
// from outside. It is here rather than in Phase 5 because the alternative is
// a pipeline that authorises against one arbitrary branch of a fork without
// saying so.
func (r *API) roomState(ctx context.Context, room storage.Room, rules roomversion.Rules) (auth.State, []*event.Event, bool, error) {
storedExtremities, err := r.store.Extremities(ctx, room.ID)
if err != nil {
return nil, nil, false, err
}
extremities, err := parseEvents(storedExtremities, rules)
if err != nil {
return nil, nil, false, err
}
if len(extremities) <= 1 {
stored, err := r.store.CurrentStateEvents(ctx, room.ID)
if err != nil {
return nil, nil, false, err
}
events, err := parseEvents(stored, rules)
if err != nil {
return nil, nil, false, err
}
return auth.NewState(events), extremities, false, nil
}
stateSets := make([][]*event.Event, 0, len(extremities))
for _, e := range extremities {
set, err := r.stateAfter(ctx, room.ID, rules, e)
if err != nil {
return nil, nil, false, err
}
stateSets = append(stateSets, set)
}
resolved, err := stateres.Resolve(ctx, rules, stateSets,
eventProvider{store: r.store, roomID: room.ID, rules: rules})
if err != nil {
return nil, nil, false, fmt.Errorf("resolving the state of %s: %w", room.ID, err)
}
return resolved, extremities, true, nil
}
// stateAfter returns the room state as of an event, the event included.
//
// The snapshot stored against an event is the state *before* it; adding the
// event itself when it is a state event is what turns that into the state a
// child of it would see.
func (r *API) stateAfter(ctx context.Context, roomID string, rules roomversion.Rules, e *event.Event) ([]*event.Event, error) {
entries, err := r.store.StateBefore(ctx, roomID, e.EventID())
if err != nil {
return nil, err
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.EventID)
}
stored, err := r.store.EventsByID(ctx, roomID, ids)
if err != nil {
return nil, err
}
events, err := parseEvents(stored, rules)
if err != nil {
return nil, err
}
if e.IsState() {
events = append(events, e)
}
return events, nil
}
// persistRequest is everything the persist stage needs.
type persistRequest struct {
Room storage.Room
Rules roomversion.Rules
Event *event.Event
// StateBefore is the room state the event was authorised against.
StateBefore auth.State
// ReplaceWholeState says StateBefore came out of state resolution, so
// the new current state has to be written out in full.
ReplaceWholeState bool
Transaction *storage.TransactionRef
RedactsEventID string
}
// persist writes the event and everything its arrival changes, then emits.
func (r *API) persist(ctx context.Context, req persistRequest) error {
e := req.Event
stateKey, isState := e.StateKey()
write := storage.RoomWrite{
Event: storage.StoredEvent{
RoomID: req.Room.ID,
EventID: e.EventID(),
Type: e.Type(),
Sender: e.Sender(),
Depth: e.Depth(),
OriginServerTS: e.OriginServerTS(),
JSON: e.JSON(),
},
StateBefore: stateEntries(req.StateBefore),
Extremities: []string{e.EventID()},
Transaction: req.Transaction,
}
if isState {
write.Event.StateKey = &stateKey
}
// The state after this event. For the ordinary case that is one tuple
// to upsert; after a resolution it is the whole state, which the store
// is told to write as a replacement.
stateAfter := req.StateBefore
if req.ReplaceWholeState {
write.StateDeltaIsWholeState = true
write.StateDelta = stateEntries(req.StateBefore)
}
if isState {
entry := stateEntry(e.Type(), stateKey, e)
if req.ReplaceWholeState {
write.StateDelta = upsertEntry(write.StateDelta, entry)
} else {
write.StateDelta = []storage.StateEntry{entry}
}
stateAfter = withState(req.StateBefore, e.Type(), stateKey, e)
}
// The denormalised listing columns are recomputed only when this event
// is one of the ones behind them, which keeps a room full of messages
// from rewriting its own row on every line of chat.
if isState && affectsRoomProfile(e.Type()) {
profile := roomProfile(stateAfter)
write.Profile = &profile
}
if req.RedactsEventID != "" {
if err := r.prepareRedaction(ctx, &write, req); err != nil {
return err
}
}
ordering, err := r.store.Write(ctx, write)
if err != nil {
return err
}
r.log.Debug("persisted event",
"room_id", req.Room.ID, "event_id", e.EventID(), "type", e.Type(),
"sender", e.Sender(), "stream_ordering", ordering)
if r.notifier != nil {
r.notifier.EventPersisted(ctx, Notification{
RoomID: req.Room.ID,
Event: e,
StreamOrdering: ordering,
StateChanged: isState,
})
}
return nil
}
// withState returns st with one piece of state replaced, leaving st alone.
func withState(st auth.State, eventType, stateKey string, e *event.Event) auth.State {
out := make(auth.State, len(st)+1)
for key, value := range st {
out[key] = value
}
out[auth.StateKey{Type: eventType, StateKey: stateKey}] = e
return out
}
// upsertEntry replaces the matching tuple in entries, or appends it.
func upsertEntry(entries []storage.StateEntry, entry storage.StateEntry) []storage.StateEntry {
for i, existing := range entries {
if existing.Type == entry.Type && existing.StateKey == entry.StateKey {
entries[i] = entry
return entries
}
}
return append(entries, entry)
}
// isNotFound reports whether err is one of the store's "nothing there"
// sentinels, which several callers treat as an ordinary answer rather than
// as a failure.
func isNotFound(err error) bool {
return errors.Is(err, ErrNoSuchEvent) ||
errors.Is(err, ErrNoSuchRoom) ||
errors.Is(err, ErrNoSuchAlias)
}
+459
View File
@@ -0,0 +1,459 @@
package roomserver
import (
"context"
"encoding/json"
"errors"
"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/storage"
)
// JoinRequest asks to join a room.
type JoinRequest struct {
// RoomID is the room to join. The caller has already resolved an alias.
RoomID string
// User is the full user ID joining.
User string
// Reason is an optional note put in the membership event.
Reason string
// ServerNames are the ?server_name= hints a client sends for a room its
// own server may not know. They are only usable over federation, and
// their presence for a room this server does not have is what turns a
// join into a federated one.
ServerNames []string
}
// JoinRoom joins a local room.
//
// Joining a room this server does not have is a federated join: the whole
// room has to be fetched from a server that does have it, which is Phase 5.
// While federation is off that is refused with a message saying so, which is
// the behaviour PLAN.md section 3 asks for — a client otherwise sees a bare
// M_FORBIDDEN and the operator goes looking through power levels.
func (r *API) JoinRoom(ctx context.Context, req JoinRequest) (string, error) {
if !r.isLocal(req.User) {
return "", fmt.Errorf("%w: %s is not a user of this server", ErrForbidden, req.User)
}
switch _, err := r.store.Room(ctx, req.RoomID); {
case errors.Is(err, ErrNoSuchRoom):
if !r.federationEnabled() {
return "", fmt.Errorf("%w: %s is not a room on this server, and joining a remote room",
ErrFederationDisabled, req.RoomID)
}
// With federation on, a room we do not have still has to be
// fetched from a server that does. That is Phase 5.
return "", fmt.Errorf("%w: joining a room this server does not have is not implemented yet",
ErrFederationDisabled)
case err != nil:
return "", err
}
// Joining a room you are already in is a no-op rather than a second
// event: clients call /join to make sure, and every extra membership
// event would show up in everybody's timeline.
membership, err := r.store.Membership(ctx, req.RoomID, req.User)
if err != nil {
return "", err
}
if membership.Membership == event.MembershipJoin {
return req.RoomID, nil
}
content, err := r.joinContent(ctx, req)
if err != nil {
return "", err
}
if _, err := r.send(ctx, sendRequest{
RoomID: req.RoomID,
Type: event.TypeMember,
StateKey: statePtr(req.User),
Sender: req.User,
Content: content,
}); err != nil {
return "", err
}
return req.RoomID, nil
}
// joinContent builds the m.room.member content of a join, including the
// authorisation a restricted room needs.
func (r *API) joinContent(ctx context.Context, req JoinRequest) (json.RawMessage, error) {
content := r.membershipContentMap(ctx, event.MembershipJoin, req.User, req.Reason, false)
authorisedVia, err := r.restrictedJoinAuthoriser(ctx, req)
if err != nil {
return nil, err
}
if authorisedVia != "" {
content["join_authorised_via_users_server"] = authorisedVia
}
return mustMarshal(content), nil
}
// restrictedJoinAuthoriser finds a local user who can authorise a join
// under the restricted join rule, or returns "" when the rule does not
// apply.
//
// The restricted join rule lets membership of another room stand in for an
// invite, but somebody has to attest that the joining user really is in one
// of the allowed rooms. That attestation is a signature from the authorising
// user's server over the membership event, so the authoriser has to be a
// user this server can sign for and who has the power to invite. While
// federation is off every room is local, so this server can always see the
// allowed rooms itself.
func (r *API) restrictedJoinAuthoriser(ctx context.Context, req JoinRequest) (string, error) {
room, err := r.store.Room(ctx, req.RoomID)
if err != nil {
return "", err
}
rules, err := r.rules(room)
if err != nil {
return "", err
}
if !rules.RestrictedJoinRule {
return "", nil
}
st, err := r.currentState(ctx, room, rules)
if err != nil {
return "", err
}
switch st.JoinRule() {
case auth.JoinRuleRestricted:
case auth.JoinRuleKnockRestricted:
if !rules.KnockRestrictedJoinRule {
return "", nil
}
default:
return "", nil
}
// An invited user joins on the invite, with no authorisation needed,
// and the rules check the invite first.
if st.Membership(req.User) == event.MembershipInvite {
return "", nil
}
allowed, err := allowedRooms(st)
if err != nil {
return "", err
}
inAnAllowedRoom := false
for _, allowedRoom := range allowed {
membership, err := r.store.Membership(ctx, allowedRoom, req.User)
if err != nil {
return "", err
}
if membership.Membership == event.MembershipJoin {
inAnAllowedRoom = true
break
}
}
if !inAnAllowedRoom {
return "", fmt.Errorf("%w: %s is not joined to any room that grants entry to %s",
ErrForbidden, req.User, req.RoomID)
}
return r.localUserWhoCanInvite(st, req.RoomID)
}
// allowedRooms reads the room IDs an m.room.join_rules event grants entry
// through.
func allowedRooms(st auth.State) ([]string, error) {
joinRules := st.JoinRules()
if joinRules == nil {
return nil, nil
}
var content struct {
Allow []struct {
Type string `json:"type"`
RoomID string `json:"room_id"`
} `json:"allow"`
}
if err := json.Unmarshal(joinRules.Content(), &content); err != nil {
return nil, fmt.Errorf("%w: the room's join rules are unreadable: %v", ErrRejected, err)
}
var out []string
for _, entry := range content.Allow {
// "m.room_membership" is the only rule type the spec defines; an
// unknown one grants nothing rather than everything.
if entry.Type == "m.room_membership" && entry.RoomID != "" {
out = append(out, entry.RoomID)
}
}
return out, nil
}
// localUserWhoCanInvite picks a joined local user with the power to invite,
// to authorise a restricted join.
//
// Which user it is does not matter to the rules — only that they are in the
// room, can invite, and belong to a server that will sign for them. The
// room's creators come first because they always qualify.
func (r *API) localUserWhoCanInvite(st auth.State, roomID string) (string, error) {
create := st.Create()
if create == nil {
// A room with no create event in its current state is corrupt, and
// nothing here can judge anything about it.
return "", fmt.Errorf("%w: %s has no create event in its state", ErrRejected, roomID)
}
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), create, create.Rules())
if err != nil {
return "", fmt.Errorf("%w: %v", ErrRejected, err)
}
creators, err := auth.Creators(create)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrRejected, err)
}
for _, creator := range creators {
if r.isLocal(creator) && st.Membership(creator) == event.MembershipJoin {
return creator, nil
}
}
for key, member := range st {
if key.Type != event.TypeMember || !r.isLocal(key.StateKey) {
continue
}
if membership, err := member.Membership(); err != nil || membership != event.MembershipJoin {
continue
}
if powerLevels.UserLevel(key.StateKey) >= powerLevels.Invite {
return key.StateKey, nil
}
}
return "", fmt.Errorf("%w: no user of this server in %s can authorise a restricted join",
ErrForbidden, roomID)
}
// LeaveRoom leaves a room, or rejects an invite to one.
//
// Both are the same event: an m.room.member with membership "leave" sent by
// the user themselves. Whether they were joined, invited or knocking is the
// authorisation rules' business, and a user who was never in the room is
// refused by them.
func (r *API) LeaveRoom(ctx context.Context, roomID, userID, reason string) error {
_, err := r.send(ctx, sendRequest{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(userID),
Sender: userID,
Content: r.membershipContent(ctx, event.MembershipLeave, userID, reason, false),
})
return err
}
// InviteRequest asks to invite a user to a room.
type InviteRequest struct {
RoomID string
// Sender is the full user ID doing the inviting.
Sender string
// Target is the full user ID being invited.
Target string
Reason string
// IsDirect marks the invite as a direct chat.
IsDirect bool
}
// InviteUser invites a user to a room.
//
// Inviting a user of another server means asking that server to
// countersign the invite, which needs federation. With it off the invite is
// refused with a message that says why rather than a bare M_FORBIDDEN —
// PLAN.md section 3.
func (r *API) InviteUser(ctx context.Context, req InviteRequest) error {
if err := validateUserID(req.Target); err != nil {
return err
}
if !r.isLocal(req.Target) {
if !r.federationEnabled() {
return fmt.Errorf("%w: %s is on another server, and inviting a remote user",
ErrFederationDisabled, req.Target)
}
return fmt.Errorf("%w: inviting a user of another server is not implemented yet",
ErrFederationDisabled)
}
_, err := r.send(ctx, sendRequest{
RoomID: req.RoomID,
Type: event.TypeMember,
StateKey: statePtr(req.Target),
Sender: req.Sender,
Content: r.membershipContent(ctx, event.MembershipInvite, req.Target, req.Reason, req.IsDirect),
})
return err
}
// KickUser removes a user from a room without barring them from returning.
//
// A kick is a membership change to "leave" made by somebody other than the
// user. It works on an invited or knocking user too, which is how an invite
// is withdrawn or a knock refused.
//
// A target who is not in the room at all is refused here rather than by the
// rules, which would accept a "leave" over a "leave" and add an event to
// the room that says nothing — and that every member would then see in
// their timeline. Lifting a ban is /unban, not a kick.
func (r *API) KickUser(ctx context.Context, roomID, sender, target, reason string) error {
if err := validateUserID(target); err != nil {
return err
}
membership, err := r.store.Membership(ctx, roomID, target)
if err != nil {
return err
}
switch membership.Membership {
case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock:
default:
return fmt.Errorf("%w: %s is not in %s", ErrForbidden, target, roomID)
}
_, err = r.send(ctx, sendRequest{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(target),
Sender: sender,
Content: r.membershipContent(ctx, event.MembershipLeave, target, reason, false),
})
return err
}
// BanUser bars a user from a room.
func (r *API) BanUser(ctx context.Context, roomID, sender, target, reason string) error {
if err := validateUserID(target); err != nil {
return err
}
_, err := r.send(ctx, sendRequest{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(target),
Sender: sender,
Content: r.membershipContent(ctx, event.MembershipBan, target, reason, false),
})
return err
}
// UnbanUser lifts a ban, which leaves the user outside the room rather than
// in it.
func (r *API) UnbanUser(ctx context.Context, roomID, sender, target, reason string) error {
if err := validateUserID(target); err != nil {
return err
}
// Unbanning someone who is not banned is refused here rather than by
// the rules, which would happily accept a "leave" over a "leave" and
// produce an event that says nothing.
membership, err := r.store.Membership(ctx, roomID, target)
if err != nil {
return err
}
if membership.Membership != event.MembershipBan {
return fmt.Errorf("%w: %s is not banned from %s", ErrForbidden, target, roomID)
}
_, err = r.send(ctx, sendRequest{
RoomID: roomID,
Type: event.TypeMember,
StateKey: statePtr(target),
Sender: sender,
Content: r.membershipContent(ctx, event.MembershipLeave, target, reason, false),
})
return err
}
// ForceLeave makes a user leave a room without any authorisation check,
// for the admin API.
//
// It is still an ordinary membership event, signed and added to the room,
// so the other members see what happened. What it skips is only the
// question of whether the user was allowed to leave — a user stuck in a
// broken room is the case this exists for, and in a broken room the rules
// are exactly what is in the way.
func (r *API) ForceLeave(ctx context.Context, roomID, userID string) error {
membership, err := r.store.Membership(ctx, roomID, userID)
if err != nil {
return err
}
switch membership.Membership {
case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock:
default:
return fmt.Errorf("%w: %s is not in %s", ErrForbidden, userID, roomID)
}
return r.LeaveRoom(ctx, roomID, userID, "Removed by a server administrator")
}
// membershipContent builds an m.room.member content.
func (r *API) membershipContent(ctx context.Context, membership, userID, reason string, isDirect bool) json.RawMessage {
return mustMarshal(r.membershipContentMap(ctx, membership, userID, reason, isDirect))
}
// membershipContentMap is membershipContent before encoding, so that a
// restricted join can add its authorisation to it.
//
// The profile is copied into the event on purpose: a client rendering a
// room's history has to show the name the member had at the time, and the
// membership event is the only record of it. A profile that cannot be read
// is left out rather than failing the membership change — being unable to
// join a room because the display name lookup failed would be the wrong
// trade.
func (r *API) membershipContentMap(ctx context.Context, membership, userID, reason string, isDirect bool) map[string]any {
content := map[string]any{"membership": membership}
if reason != "" {
content["reason"] = reason
}
if isDirect {
content["is_direct"] = true
}
// A ban or a kick says nothing about the target's profile: the event is
// about the sender's decision, and copying a display name into it would
// only give a moderator a way to write into somebody else's membership.
if membership != event.MembershipJoin && membership != event.MembershipInvite {
return content
}
if r.profiles == nil {
return content
}
localpart, ok := r.localpartOf(userID)
if !ok {
return content
}
account, err := r.profiles.Account(ctx, localpart)
if err != nil {
if !errors.Is(err, storage.ErrNoSuchUser) {
r.log.Warn("could not read a profile for a membership event",
"user_id", userID, "error", err)
}
return content
}
if account.DisplayName != "" {
content["displayname"] = account.DisplayName
}
if account.AvatarURL != "" {
content["avatar_url"] = account.AvatarURL
}
return content
}
// validateUserID checks a user ID arriving from a client against the
// grammar events use, which is laxer than the one this server's own
// accounts are held to: a room can contain user IDs minted years ago.
func validateUserID(userID string) error {
if err := id.ValidateUserID(userID); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidContent, err)
}
return nil
}
@@ -0,0 +1,530 @@
package roomserver_test
import (
"encoding/json"
"errors"
"testing"
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
)
func TestJoinPublicRoom(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
joined, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID,
User: bob,
})
if err != nil {
t.Fatalf("JoinRoom: %v", err)
}
if joined != resp.RoomID {
t.Errorf("JoinRoom returned %q, want %q", joined, resp.RoomID)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipJoin {
t.Errorf("bob's membership is %q, want join", got)
}
// A second join is a no-op, not a second event: clients call /join to
// make sure, and every extra membership event lands in everybody's
// timeline.
before, err := f.store.Event(t.Context(), resp.RoomID, f.memberEventID(resp.RoomID, bob))
if err != nil {
t.Fatalf("Event: %v", err)
}
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("joining twice: %v", err)
}
after, err := f.store.Event(t.Context(), resp.RoomID, f.memberEventID(resp.RoomID, bob))
if err != nil {
t.Fatalf("Event: %v", err)
}
if after.EventID != before.EventID {
t.Errorf("joining twice produced a second membership event")
}
// The joined-member count follows.
summary, err := f.store.RoomSummary(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomSummary: %v", err)
}
if summary.JoinedMembers != 2 {
t.Errorf("the room has %d joined members, want 2", summary.JoinedMembers)
}
}
// memberEventID is the event ID of a user's current membership event.
func (f *fixture) memberEventID(roomID, userID string) string {
f.t.Helper()
entry, err := f.store.Membership(f.t.Context(), roomID, userID)
if err != nil {
f.t.Fatalf("Membership: %v", err)
}
return entry.EventID
}
func TestJoinInviteOnlyRoom(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPrivateChat})
// Without an invite the rules refuse the join. The refusal comes from
// them, not from a check in this package.
_, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Fatalf("joining an invite-only room = %v, want ErrRejected", err)
}
if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{
RoomID: resp.RoomID, Sender: alice, Target: bob,
}); err != nil {
t.Fatalf("InviteUser: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipInvite {
t.Errorf("bob's membership after the invite is %q, want invite", got)
}
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("joining after an invite: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipJoin {
t.Errorf("bob's membership after joining is %q, want join", got)
}
}
// TestInviteRequiresPower is the rule a room's moderators rely on.
func TestInviteRequiresPower(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
// The default invite level is 0, so bob can invite. Raise it and he
// cannot.
if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: event.TypePowerLevels,
StateKey: stringPtr(""),
Content: json.RawMessage(`{"invite":50,"users":{"` + alice + `":100}}`),
}); err != nil {
t.Fatalf("raising the invite level: %v", err)
}
err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{
RoomID: resp.RoomID, Sender: bob, Target: carol,
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("an invite without the power = %v, want ErrRejected", err)
}
}
func TestLeaveAndRejectInvite(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
if err := f.api.LeaveRoom(t.Context(), resp.RoomID, bob, "bye"); err != nil {
t.Fatalf("LeaveRoom: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave {
t.Errorf("bob's membership after leaving is %q, want leave", got)
}
// The same endpoint rejects an invite, which is why it has to work on
// a room the caller was never joined to.
if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{
RoomID: resp.RoomID, Sender: alice, Target: carol,
}); err != nil {
t.Fatalf("InviteUser: %v", err)
}
if err := f.api.LeaveRoom(t.Context(), resp.RoomID, carol, "no thanks"); err != nil {
t.Fatalf("rejecting an invite: %v", err)
}
if got := f.membership(resp.RoomID, carol); got != event.MembershipLeave {
t.Errorf("carol's membership after rejecting is %q, want leave", got)
}
// Leaving a room you were never in is refused by the rules.
err := f.api.LeaveRoom(t.Context(), resp.RoomID, "@dave:"+testServerName, "")
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("leaving a room never joined = %v, want ErrRejected", err)
}
}
func TestKickBanAndUnban(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
join := func(user string) {
t.Helper()
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: user,
}); err != nil {
t.Fatalf("%s joining: %v", user, err)
}
}
join(bob)
if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, "behave"); err != nil {
t.Fatalf("KickUser: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave {
t.Errorf("bob's membership after the kick is %q, want leave", got)
}
// A kick is not a ban: bob can come back.
join(bob)
if err := f.api.BanUser(t.Context(), resp.RoomID, alice, bob, "enough"); err != nil {
t.Fatalf("BanUser: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipBan {
t.Errorf("bob's membership after the ban is %q, want ban", got)
}
_, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("a banned user joining = %v, want ErrRejected", err)
}
if err := f.api.UnbanUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil {
t.Fatalf("UnbanUser: %v", err)
}
// Unbanning leaves the user outside the room, not in it.
if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave {
t.Errorf("bob's membership after the unban is %q, want leave", got)
}
join(bob)
// Unbanning somebody who is not banned says so rather than writing an
// event that means nothing.
err = f.api.UnbanUser(t.Context(), resp.RoomID, alice, bob, "")
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("unbanning a member = %v, want ErrForbidden", err)
}
}
// TestKickRequiresPower checks that a member cannot remove a peer.
func TestKickRequiresPower(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
for _, user := range []string{bob, carol} {
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: user,
}); err != nil {
t.Fatalf("%s joining: %v", user, err)
}
}
err := f.api.KickUser(t.Context(), resp.RoomID, bob, carol, "")
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("a kick without the power = %v, want ErrRejected", err)
}
}
// TestMembershipRemoteUsersNeedFederation is the behaviour PLAN.md section 3
// asks for: a clear refusal rather than a bare M_FORBIDDEN.
func TestMembershipRemoteUsersNeedFederation(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{
RoomID: resp.RoomID, Sender: alice, Target: "@eve:elsewhere.example",
})
if !errors.Is(err, roomserver.ErrFederationDisabled) {
t.Errorf("inviting a remote user = %v, want ErrFederationDisabled", err)
}
_, err = f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: "!elsewhere:elsewhere.example", User: alice,
})
if !errors.Is(err, roomserver.ErrFederationDisabled) {
t.Errorf("joining a remote room = %v, want ErrFederationDisabled", err)
}
// With federation on it is still not implemented, and says so — but it
// says it as a federation problem rather than as a missing room.
g := newFixture(t, func(opts *roomserver.Options) {
opts.FederationEnabled = func() bool { return true }
})
_, err = g.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: "!elsewhere:elsewhere.example", User: alice,
})
if !errors.Is(err, roomserver.ErrFederationDisabled) {
t.Errorf("joining a remote room with federation on = %v, want ErrFederationDisabled", err)
}
}
// TestRestrictedJoin is the one authorisation rule that needs a signature
// checked, and so the one that needs this server to vouch for a join.
func TestRestrictedJoin(t *testing.T) {
f := newFixture(t, nil)
// The space whose membership grants entry.
space := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
restricted := f.createRoom(roomserver.CreateRoomRequest{
Version: "11",
InitialState: []roomserver.StateEvent{{
Type: event.TypeJoinRules,
Content: json.RawMessage(`{"join_rule":"restricted","allow":[` +
`{"type":"m.room_membership","room_id":"` + space.RoomID + `"}]}`),
}},
})
// Not in the space: no entry.
_, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: restricted.RoomID, User: bob,
})
if !errors.Is(err, roomserver.ErrForbidden) {
t.Fatalf("joining a restricted room from outside = %v, want ErrForbidden", err)
}
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: space.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining the space: %v", err)
}
// In the space: the join is authorised via a local user with the power
// to invite, and the event carries their server's signature.
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: restricted.RoomID, User: bob,
}); err != nil {
t.Fatalf("joining a restricted room from the space: %v", err)
}
if got := f.membership(restricted.RoomID, bob); got != event.MembershipJoin {
t.Errorf("bob's membership is %q, want join", got)
}
st := f.state(restricted.RoomID)
member := st.Member(bob)
if member == nil {
t.Fatal("bob has no membership event")
}
var content struct {
AuthorisedVia string `json:"join_authorised_via_users_server"`
}
if err := json.Unmarshal(member.Content(), &content); err != nil {
t.Fatalf("the membership content is not an object: %v", err)
}
if content.AuthorisedVia != alice {
t.Errorf("join_authorised_via_users_server = %q, want %q", content.AuthorisedVia, alice)
}
}
func TestJoinedRoomsAndMembers(t *testing.T) {
f := newFixture(t, nil)
first := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
second := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: first.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
rooms, err := f.api.JoinedRooms(t.Context(), bob)
if err != nil {
t.Fatalf("JoinedRooms: %v", err)
}
if len(rooms) != 1 || rooms[0] != first.RoomID {
t.Errorf("bob's joined rooms = %v, want [%s]", rooms, first.RoomID)
}
aliceRooms, err := f.api.JoinedRooms(t.Context(), alice)
if err != nil {
t.Fatalf("JoinedRooms: %v", err)
}
if len(aliceRooms) != 2 {
t.Errorf("alice is joined to %v, want both rooms", aliceRooms)
}
joined, err := f.api.JoinedMembers(t.Context(), first.RoomID, alice)
if err != nil {
t.Fatalf("JoinedMembers: %v", err)
}
if len(joined) != 2 {
t.Fatalf("JoinedMembers returned %d members, want 2: %+v", len(joined), joined)
}
if joined[bob].DisplayName != "Bob" {
t.Errorf("bob's display name is %q, want Bob", joined[bob].DisplayName)
}
// /joined_members is for members: a stranger is refused.
if _, err := f.api.JoinedMembers(t.Context(), second.RoomID, bob); !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("JoinedMembers for a stranger = %v, want ErrForbidden", err)
}
// /members includes the users who have left, unless filtered.
if err := f.api.LeaveRoom(t.Context(), first.RoomID, bob, ""); err != nil {
t.Fatalf("LeaveRoom: %v", err)
}
all, err := f.api.Members(t.Context(), roomserver.MembersRequest{
RoomID: first.RoomID, User: alice,
})
if err != nil {
t.Fatalf("Members: %v", err)
}
if len(all) != 2 {
t.Errorf("Members returned %d events, want 2 (one of them a leave)", len(all))
}
onlyJoined, err := f.api.Members(t.Context(), roomserver.MembersRequest{
RoomID: first.RoomID, User: alice, Membership: event.MembershipJoin,
})
if err != nil {
t.Fatalf("Members with a filter: %v", err)
}
if len(onlyJoined) != 1 {
t.Errorf("filtering by join returned %d events, want 1", len(onlyJoined))
}
notJoined, err := f.api.Members(t.Context(), roomserver.MembersRequest{
RoomID: first.RoomID, User: alice, NotMembership: event.MembershipJoin,
})
if err != nil {
t.Fatalf("Members with not_membership: %v", err)
}
if len(notJoined) != 1 {
t.Errorf("not_membership=join returned %d events, want 1", len(notJoined))
}
}
// TestStateVisibility is the room-level visibility check the read endpoints
// make: a member sees the room, a stranger does not, and anyone sees a
// world-readable one.
func TestStateVisibility(t *testing.T) {
f := newFixture(t, nil)
private := f.createRoom(roomserver.CreateRoomRequest{})
if _, err := f.api.RoomState(t.Context(), private.RoomID, alice); err != nil {
t.Errorf("the creator cannot read their own room's state: %v", err)
}
if _, err := f.api.RoomState(t.Context(), private.RoomID, bob); !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("a stranger reading state = %v, want ErrForbidden", err)
}
readable := f.createRoom(roomserver.CreateRoomRequest{
InitialState: []roomserver.StateEvent{{
Type: event.TypeHistoryVisibility,
Content: json.RawMessage(`{"history_visibility":"world_readable"}`),
}},
})
if _, err := f.api.RoomState(t.Context(), readable.RoomID, bob); err != nil {
t.Errorf("a stranger cannot read a world-readable room's state: %v", err)
}
}
// TestForceLeave is the admin escape hatch: it adds a real membership
// event, so the room's other members see what happened.
func TestForceLeave(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
if err := f.api.ForceLeave(t.Context(), resp.RoomID, bob); err != nil {
t.Fatalf("ForceLeave: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave {
t.Errorf("bob's membership is %q, want leave", got)
}
// A user who is not in the room has nothing to leave.
if err := f.api.ForceLeave(t.Context(), resp.RoomID, carol); !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("force-leaving a stranger = %v, want ErrForbidden", err)
}
}
// TestPowerLevelsCannotBeRaisedAboveYourOwn is the rule that keeps a
// moderator from promoting themselves.
func TestPowerLevelsCannotBeRaisedAboveYourOwn(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Version: "11",
Preset: roomserver.PresetPublicChat,
PowerLevelContentOverride: json.RawMessage(
`{"users":{"` + alice + `":100,"` + bob + `":50}}`),
})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
_, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: bob,
Type: event.TypePowerLevels,
StateKey: stringPtr(""),
Content: json.RawMessage(`{"users":{"` + alice + `":100,"` + bob + `":100}}`),
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("promoting yourself = %v, want ErrRejected", err)
}
st := f.state(resp.RoomID)
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), st.Create(), st.Create().Rules())
if err != nil {
t.Fatalf("ResolvePowerLevels: %v", err)
}
if got := powerLevels.UserLevel(bob); got != 50 {
t.Errorf("bob's power level is %d, want 50", got)
}
}
// TestKickRequiresTheTargetToBeInTheRoom keeps a kick from adding a leave
// event for somebody who was never there — which the authorisation rules
// would accept, and which every member of the room would then see.
func TestKickRequiresTheTargetToBeInTheRoom(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, "")
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("kicking a stranger = %v, want ErrForbidden", err)
}
// An invite can be withdrawn with a kick, which is what the endpoint
// is for as much as removing a member is.
if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{
RoomID: resp.RoomID, Sender: alice, Target: bob,
}); err != nil {
t.Fatalf("InviteUser: %v", err)
}
if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil {
t.Fatalf("withdrawing an invite with a kick: %v", err)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave {
t.Errorf("bob's membership is %q, want leave", got)
}
// Lifting a ban is /unban, not a kick.
if err := f.api.BanUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil {
t.Fatalf("BanUser: %v", err)
}
if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, ""); !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("kicking a banned user = %v, want ErrForbidden", err)
}
}
+106
View File
@@ -0,0 +1,106 @@
package roomserver
import (
"encoding/json"
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The state event types the Client-Server API gives a room its public face
// with. Only these are in the spec's "stripped state" and in a public room
// directory entry, which is why only these are denormalised.
const (
TypeName = "m.room.name"
TypeTopic = "m.room.topic"
TypeAvatar = "m.room.avatar"
TypeCanonicalAlias = "m.room.canonical_alias"
TypeGuestAccess = "m.room.guest_access"
TypeEncryption = "m.room.encryption"
TypeMessage = "m.room.message"
TypeHistoryVisibility = event.TypeHistoryVisibility
)
// History visibility values, in the order the spec lists them.
const (
HistoryVisibilityWorldReadable = "world_readable"
HistoryVisibilityShared = "shared"
HistoryVisibilityInvited = "invited"
HistoryVisibilityJoined = "joined"
)
// Guest access values.
const (
GuestAccessCanJoin = "can_join"
GuestAccessForbidden = "forbidden"
)
// affectsRoomProfile reports whether an event of this type changes the
// denormalised room listing columns.
func affectsRoomProfile(eventType string) bool {
switch eventType {
case event.TypeCreate, event.TypeJoinRules, TypeName, TypeTopic,
TypeAvatar, TypeCanonicalAlias, TypeHistoryVisibility, TypeGuestAccess:
return true
}
return false
}
// roomProfile reads the denormalised listing columns out of a room's state.
//
// Every field is taken from the state rather than accumulated, so a profile
// recomputed after any one of these events is the whole truth about the
// room and not a patch that could drift from it.
func roomProfile(st auth.State) storage.RoomProfile {
profile := storage.RoomProfile{
JoinRule: st.JoinRule(),
// These two have spec defaults for a room that never set them, and
// the defaults are what a client would apply, so they are what the
// stored row should say.
HistoryVisibility: HistoryVisibilityShared,
GuestAccess: GuestAccessForbidden,
}
profile.Name = stringFromContent(st, TypeName, "", "name")
profile.Topic = stringFromContent(st, TypeTopic, "", "topic")
profile.AvatarURL = stringFromContent(st, TypeAvatar, "", "url")
profile.CanonicalAlias = stringFromContent(st, TypeCanonicalAlias, "", "alias")
profile.RoomType = stringFromContent(st, event.TypeCreate, "", "type")
if v := stringFromContent(st, TypeHistoryVisibility, "", "history_visibility"); v != "" {
profile.HistoryVisibility = v
}
if v := stringFromContent(st, TypeGuestAccess, "", "guest_access"); v != "" {
profile.GuestAccess = v
}
return profile
}
// stringFromContent reads one string property out of a state event's
// content, or returns "" when the event, the property or the type is not
// what it should be.
//
// A malformed value is treated as absent on purpose. This is a projection
// for a room listing, not an authorisation input: the right behaviour for a
// room whose name is a number is to show it without a name, not to fail
// every read of the room.
func stringFromContent(st auth.State, eventType, stateKey, property string) string {
e := st.Get(eventType, stateKey)
if e == nil {
return ""
}
var content map[string]json.RawMessage
if err := json.Unmarshal(e.Content(), &content); err != nil {
return ""
}
raw, ok := content[property]
if !ok {
return ""
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return ""
}
return value
}
+417
View File
@@ -0,0 +1,417 @@
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 string `json:"room_id"`
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) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return ClientEvent{}, err
}
if err := r.requireVisibility(ctx, room, rules, userID); err != nil {
return ClientEvent{}, err
}
stored, err := r.store.Event(ctx, roomID, eventID)
if err != nil {
return ClientEvent{}, err
}
e, err := parseEvent(stored, rules)
if err != nil {
return ClientEvent{}, err
}
return r.clientEvent(stored, e, roomID), nil
}
// RoomState returns a room's current state in the client shape.
func (r *API) RoomState(ctx context.Context, roomID, userID string) ([]ClientEvent, error) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
if err := r.requireVisibility(ctx, room, rules, userID); err != nil {
return nil, err
}
stored, err := r.store.CurrentStateEvents(ctx, roomID)
if err != nil {
return nil, err
}
out := make([]ClientEvent, 0, len(stored))
for _, s := range stored {
e, err := parseEvent(s, rules)
if err != nil {
return nil, err
}
out = append(out, r.clientEvent(s, e, roomID))
}
return out, nil
}
// 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) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return nil, err
}
if err := r.requireVisibility(ctx, room, rules, userID); err != nil {
return nil, err
}
stored, err := r.store.CurrentStateEvent(ctx, roomID, eventType, stateKey)
if err != nil {
return nil, err
}
e, err := parseEvent(stored, rules)
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) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return ClientEvent{}, err
}
if err := r.requireVisibility(ctx, room, rules, userID); err != nil {
return ClientEvent{}, err
}
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
}
// Members returns a room's m.room.member events.
func (r *API) Members(ctx context.Context, req MembersRequest) ([]ClientEvent, error) {
room, rules, err := r.roomAndRules(ctx, req.RoomID)
if err != nil {
return nil, err
}
if err := r.requireVisibility(ctx, room, rules, req.User); err != nil {
return nil, err
}
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
}
out := make([]ClientEvent, 0, len(stored))
for _, s := range stored {
e, err := parseEvent(s, rules)
if err != nil {
return nil, err
}
if req.NotMembership != "" {
membership, err := e.Membership()
if err == nil && membership == req.NotMembership {
continue
}
}
out = append(out, r.clientEvent(s, e, req.RoomID))
}
return out, nil
}
// 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
}
// 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
}
// requireVisibility refuses a caller who may not see a room's state.
//
// This is a room-level test, not the per-event history visibility the spec
// defines. A member sees the room; so does anyone at all when the room's
// history is world_readable; a user who has left sees the state as it was
// when they left, which is their own membership event's snapshot and which
// this returns the current state for instead.
//
// That last case is the gap, and it is deliberate. Serving a departed
// member the state as of their leave event means answering every state read
// from a historical snapshot and filtering each event by the visibility in
// force when it was sent — which is the same machinery /messages and
// /context need, and so belongs with them in Phase 4 rather than being
// built twice. Until then a user who has left a room is refused, which errs
// towards showing them too little.
func (r *API) requireVisibility(ctx context.Context, room storage.Room, rules roomversion.Rules, userID string) error {
entry, err := r.store.Membership(ctx, room.ID, userID)
if err != nil {
return err
}
if entry.Membership == event.MembershipJoin {
return nil
}
summary, err := r.store.RoomSummary(ctx, room.ID)
if err != nil {
return err
}
if summary.HistoryVisibility == HistoryVisibilityWorldReadable {
return nil
}
// An invited user can see enough of the room to decide, which the spec
// delivers as stripped state on the invite rather than as a state read.
if entry.Membership == event.MembershipInvite {
return fmt.Errorf("%w: %s has been invited to %s but has not joined it",
ErrForbidden, userID, room.ID)
}
return fmt.Errorf("%w: %s is not in %s", ErrForbidden, userID, room.ID)
}
// 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
}
+235
View File
@@ -0,0 +1,235 @@
package roomserver_test
import (
"encoding/json"
"errors"
"testing"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// contentOf reads a stored event's content as a map.
func (f *fixture) contentOf(roomID, eventID string) map[string]any {
f.t.Helper()
stored, err := f.store.Event(f.t.Context(), roomID, eventID)
if err != nil {
f.t.Fatalf("Event: %v", err)
}
var body struct {
Content map[string]any `json:"content"`
}
if err := json.Unmarshal(stored.JSON, &body); err != nil {
f.t.Fatalf("the stored event is not an object: %v", err)
}
return body.Content
}
func TestRedactOwnEvent(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
target, err := f.sendMessage(resp.RoomID, alice, "something regrettable", "m1")
if err != nil {
t.Fatalf("sending the message: %v", err)
}
if body := f.contentOf(resp.RoomID, target); body["body"] == nil {
t.Fatal("the message was stored without its body")
}
redaction, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID,
Sender: alice,
EventID: target,
Reason: "a mistake",
})
if err != nil {
t.Fatalf("Redact: %v", err)
}
// The event is stripped in place, and still has its original ID: the
// ID is the reference hash of the redacted form, so redacting an event
// does not rename it.
stored, err := f.store.Event(t.Context(), resp.RoomID, target)
if err != nil {
t.Fatalf("Event: %v", err)
}
if stored.RedactedBy != redaction {
t.Errorf("redacted_by = %q, want %q", stored.RedactedBy, redaction)
}
if body := f.contentOf(resp.RoomID, target); len(body) != 0 {
t.Errorf("the redacted event still has content: %v", body)
}
// A client reading it back is told why it is empty.
clientEvent, err := f.api.Event(t.Context(), resp.RoomID, alice, target)
if err != nil {
t.Fatalf("Event: %v", err)
}
var unsigned struct {
RedactedBecause struct {
EventID string `json:"event_id"`
} `json:"redacted_because"`
}
if err := json.Unmarshal(clientEvent.Unsigned, &unsigned); err != nil {
t.Fatalf("the unsigned block is not an object: %v", err)
}
if unsigned.RedactedBecause.EventID != redaction {
t.Errorf("unsigned.redacted_because names %q, want %q",
unsigned.RedactedBecause.EventID, redaction)
}
}
// TestRedactAnotherUsersEvent checks the second half of the spec's test
// under "Handling redactions", which is the one that actually fires on a
// server with no federation: a redaction applies when it comes from the
// same server as the event it redacts, whatever the sender's power level,
// because a server may always take back its own users' events.
//
// Every user here is on one server, so that rule covers every pair of them
// and the power-level branch cannot be reached. Exercising that branch
// needs an event from another server, which is Phase 5; the branch itself
// is tested in internal/matrix/auth.
func TestRedactAnotherUsersEvent(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Preset: roomserver.PresetPublicChat,
// carol is an ordinary member with no redact power, so if the
// redaction applies it is the same-server rule that did it.
PowerLevelContentOverride: json.RawMessage(
`{"users":{"` + alice + `":100},"redact":50}`),
})
for _, user := range []string{bob, carol} {
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: user,
}); err != nil {
t.Fatalf("%s joining: %v", user, err)
}
}
target, err := f.sendMessage(resp.RoomID, bob, "bob's message", "m1")
if err != nil {
t.Fatalf("sending the message: %v", err)
}
redaction, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: carol, EventID: target,
})
if err != nil {
t.Fatalf("Redact: %v", err)
}
stored, err := f.store.Event(t.Context(), resp.RoomID, target)
if err != nil {
t.Fatalf("Event: %v", err)
}
if stored.RedactedBy != redaction {
t.Errorf("redacted_by = %q, want %q", stored.RedactedBy, redaction)
}
if body := f.contentOf(resp.RoomID, target); len(body) != 0 {
t.Errorf("the redacted event still has content: %v", body)
}
// A second redaction has nothing left to remove and must not rewrite
// which redaction stripped the event.
if _, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: alice, EventID: target,
}); err != nil {
t.Fatalf("redacting twice: %v", err)
}
stored, err = f.store.Event(t.Context(), resp.RoomID, target)
if err != nil {
t.Fatalf("Event: %v", err)
}
if stored.RedactedBy != redaction {
t.Errorf("a second redaction took over: redacted_by = %q, want %q",
stored.RedactedBy, redaction)
}
}
// TestRedactStateEventKeepsItsKeys is what the redaction algorithm is for:
// stripping a membership event must not strip the membership, or the room
// would forget who is in it.
func TestRedactStateEventKeepsItsKeys(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{
RoomID: resp.RoomID, User: bob,
}); err != nil {
t.Fatalf("bob joining: %v", err)
}
memberEvent := f.memberEventID(resp.RoomID, bob)
if _, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: alice, EventID: memberEvent,
}); err != nil {
t.Fatalf("Redact: %v", err)
}
body := f.contentOf(resp.RoomID, memberEvent)
if body["membership"] != event.MembershipJoin {
t.Errorf("the redacted membership event lost its membership: %v", body)
}
// The display name is not protected, so it goes.
if body["displayname"] != nil {
t.Errorf("the redacted membership event kept its display name: %v", body)
}
// Bob is still in the room.
if got := f.membership(resp.RoomID, bob); got != event.MembershipJoin {
t.Errorf("bob's membership after the redaction is %q, want join", got)
}
}
func TestRedactRefusals(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
// An event that does not exist is a 404, not an event in the room
// that refers to nothing.
_, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: alice, EventID: "$nope",
})
if !errors.Is(err, roomserver.ErrNoSuchEvent) {
t.Errorf("redacting an unknown event = %v, want ErrNoSuchEvent", err)
}
// A non-member cannot send a redaction at all.
target, err := f.sendMessage(resp.RoomID, alice, "hello", "m1")
if err != nil {
t.Fatalf("sending the message: %v", err)
}
_, err = f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: bob, EventID: target,
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("a stranger redacting = %v, want ErrRejected", err)
}
}
func TestRedactIsIdempotentPerTransaction(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
target, err := f.sendMessage(resp.RoomID, alice, "hello", "m1")
if err != nil {
t.Fatalf("sending the message: %v", err)
}
ref := &storage.TransactionRef{Localpart: "alice", DeviceID: "DEV", TxnID: "r1"}
first, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: alice, EventID: target, Transaction: ref,
})
if err != nil {
t.Fatalf("first redaction: %v", err)
}
again, err := f.api.Redact(t.Context(), roomserver.RedactRequest{
RoomID: resp.RoomID, Sender: alice, EventID: target, Transaction: ref,
})
if err != nil {
t.Fatalf("retried redaction: %v", err)
}
if again != first {
t.Errorf("a retried redaction produced a second event: %q then %q", first, again)
}
}
+355
View File
@@ -0,0 +1,355 @@
// Package roomserver owns rooms: it is the only thing in Warp that creates
// an event, and the only thing that decides whether one belongs in a room.
//
// Everything here runs through one pipeline — validate, authorise, resolve,
// persist, emit — because every event has to be judged the same way whether
// it came from a client, from `createRoom` or, from Phase 5, from another
// server. The rules themselves are not here: they are in internal/matrix,
// which is pure library code with no storage and no HTTP. This package is
// what connects those rules to a database and to a room's history.
//
// What the Client-Server API handlers get is a small set of operations —
// create a room, change a membership, send an event, redact one, read the
// state — each of which takes user IDs and content and returns an event ID
// or an error. Handlers do no authorisation of their own, and could not: a
// membership change is allowed or refused by the room's power levels and
// join rules, which only this package reads.
package roomserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"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/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart.
//
// The storage and auth sentinels are re-exported rather than translated, so
// that errors.Is works whichever package a caller reaches for.
var (
ErrNoSuchRoom = storage.ErrNoSuchRoom
ErrNoSuchEvent = storage.ErrNoSuchEvent
ErrNoSuchAlias = storage.ErrNoSuchAlias
ErrAliasExists = storage.ErrAliasExists
// ErrRejected means the authorisation rules refused the event. The
// wrapped message names the rule that said no, and is safe to show a
// client: it says why the room would not accept what they sent.
ErrRejected = auth.ErrRejected
// ErrForbidden is a refusal from a check that sits above the
// authorisation rules — not being in a room whose state you asked for,
// or deleting somebody else's alias.
ErrForbidden = errors.New("forbidden")
// ErrFederationDisabled is an operation that would need to talk to
// another server while federation is off.
ErrFederationDisabled = errors.New("federation is disabled")
// ErrInvalidContent is event content this server will not sign: not an
// object, too large, or missing something its type requires.
ErrInvalidContent = errors.New("invalid event content")
// ErrUnsupportedRoomVersion is a room version Warp cannot take part in.
ErrUnsupportedRoomVersion = roomversion.ErrUnsupportedVersion
// ErrUnknownRoomVersion is a room version that does not exist.
ErrUnknownRoomVersion = roomversion.ErrUnknownVersion
)
// Profiles supplies the display name and avatar that a membership event
// carries a copy of.
//
// Membership events duplicate the profile on purpose: a client showing a
// room's history has to render the name the sender had at the time, and the
// member event is the only place that is recorded. *userapi.API satisfies
// this as it stands.
type Profiles interface {
Account(ctx context.Context, localpart string) (storage.Account, error)
}
// Notification is one persisted event, as the emit stage reports it.
type Notification struct {
RoomID string
// Event is the event as it was persisted.
Event *event.Event
// StreamOrdering is the position the event was given in this server's
// event stream.
StreamOrdering int64
// StateChanged reports whether the room's current state moved.
StateChanged bool
}
// Notifier is told about every event this server persists, in the order
// they were persisted.
//
// It is the emit stage of the input pipeline and the seam the sync API
// (PLAN.md phase 4) plugs into: a client long-polling /sync has to be woken
// the moment an event lands, and the input pipeline is the only place that
// knows one has. Phase 3 ships without a notifier, so this is nil and the
// stage is skipped.
type Notifier interface {
EventPersisted(ctx context.Context, n Notification)
}
// API is the roomserver component.
type API struct {
serverName string
store storage.RoomStore
profiles Profiles
signingKey *signing.KeyPair
notifier Notifier
log *slog.Logger
now func() time.Time
// federationEnabled reports the live state of the federation switch.
// Several operations are refused outright while it is off, and the
// answer has to be read per request because an admin can flip it.
federationEnabled func() bool
// defaultRoomVersion is what createRoom uses when the client does not
// ask for one.
defaultRoomVersion roomversion.Version
// locks serialises the writers of each room. Two events racing into one
// room would each pick up the same forward extremities and both become
// one, forking the room for no reason — and then needing state
// resolution to put it back together. Taking a lock per room instead
// keeps every locally created event a child of the one before it.
locks sync.Map // room ID -> *sync.Mutex
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name. It is the domain part of the
// user IDs this server may send events as, and of the room IDs and
// aliases it mints.
ServerName string
// Store is the persistence layer.
Store storage.RoomStore
// Profiles supplies the display name and avatar of a local user.
Profiles Profiles
// SigningKey signs every event this server creates.
SigningKey *signing.KeyPair
// DefaultRoomVersion overrides the registry's default for createRoom.
// Empty uses roomversion.Default.
DefaultRoomVersion string
// FederationEnabled reports the live federation switch. Nil means off,
// which is the safe default for a half-wired server.
FederationEnabled func() bool
// Notifier receives every persisted event. Nil skips the emit stage.
Notifier Notifier
Logger *slog.Logger
// Now is the clock, so that tests can stamp events deterministically.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("roomserver: ServerName must not be empty")
case opts.Store == nil:
return nil, errors.New("roomserver: Store must not be nil")
case opts.SigningKey == nil:
// Without a key nothing here can produce an event, and finding that
// out on the first createRoom rather than at startup would mean a
// server that looks healthy and cannot be used.
return nil, errors.New("roomserver: SigningKey must not be nil")
}
version := roomversion.Default
if opts.DefaultRoomVersion != "" {
if _, err := roomversion.Get(roomversion.Version(opts.DefaultRoomVersion)); err != nil {
return nil, fmt.Errorf("roomserver: default room version %q: %w", opts.DefaultRoomVersion, err)
}
version = roomversion.Version(opts.DefaultRoomVersion)
}
if opts.FederationEnabled == nil {
opts.FederationEnabled = func() bool { return false }
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
profiles: opts.Profiles,
signingKey: opts.SigningKey,
notifier: opts.Notifier,
log: opts.Logger,
now: opts.Now,
federationEnabled: opts.FederationEnabled,
defaultRoomVersion: version,
}, nil
}
// ServerName returns the server name this roomserver signs events as.
func (r *API) ServerName() string { return r.serverName }
// DefaultRoomVersion returns the version createRoom uses by default.
func (r *API) DefaultRoomVersion() string { return string(r.defaultRoomVersion) }
// nowMS is the current time in the unit events carry.
func (r *API) nowMS() int64 { return r.now().UnixMilli() }
// lock serialises the writers of one room; see API.locks.
func (r *API) lock(roomID string) *sync.Mutex {
actual, _ := r.locks.LoadOrStore(roomID, &sync.Mutex{})
mu, ok := actual.(*sync.Mutex)
if !ok {
// Unreachable: nothing else ever stores into this map.
panic("roomserver: room lock map holds something that is not a mutex")
}
return mu
}
// isLocal reports whether a user ID belongs to this server.
func (r *API) isLocal(userID string) bool {
serverName, err := id.ServerNameOf(userID)
return err == nil && serverName == r.serverName
}
// localpartOf returns the localpart of a user ID of this server.
func (r *API) localpartOf(userID string) (string, bool) {
localpart, serverName, err := id.SplitUserID(userID)
if err != nil || serverName != r.serverName {
return "", false
}
return localpart, true
}
// rules returns the room version rules a room's events are read under.
//
// A room whose version is no longer supported is a real possibility — a
// future build could drop one — and it has to fail as a room-level error
// rather than a parse error on every event in it.
func (r *API) rules(room storage.Room) (roomversion.Rules, error) {
rules, err := roomversion.Get(roomversion.Version(room.Version))
if err != nil {
return roomversion.Rules{}, fmt.Errorf("room %s is version %q: %w", room.ID, room.Version, err)
}
return rules, nil
}
// parseEvent turns a stored event back into an Event.
//
// A redacted event parses from its redacted form and keeps its original
// event ID, because the ID is the reference hash of the redacted form in
// the first place.
func parseEvent(stored storage.StoredEvent, rules roomversion.Rules) (*event.Event, error) {
e, err := event.Parse(stored.JSON, rules)
if err != nil {
return nil, fmt.Errorf("stored event %s is unreadable: %w", stored.EventID, err)
}
return e, nil
}
// parseEvents parses a batch, failing on the first unreadable one.
//
// An unreadable stored event is a corrupted database or a bug in this
// server's own encoder, never input: everything in the store was parsed and
// validated on the way in. It must not be skipped quietly, because the
// events being parsed here are a room's state and dropping one would
// silently widen what the room allows.
func parseEvents(stored []storage.StoredEvent, rules roomversion.Rules) ([]*event.Event, error) {
out := make([]*event.Event, 0, len(stored))
for _, s := range stored {
e, err := parseEvent(s, rules)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
// stateEntries projects a state map onto the rows the store keeps.
func stateEntries(st auth.State) []storage.StateEntry {
out := make([]storage.StateEntry, 0, len(st))
for key, e := range st {
out = append(out, stateEntry(key.Type, key.StateKey, e))
}
return out
}
// stateEntry projects one piece of state onto a storage row, carrying the
// membership along for an m.room.member event so that the store can index
// it.
func stateEntry(eventType, stateKey string, e *event.Event) storage.StateEntry {
entry := storage.StateEntry{Type: eventType, StateKey: stateKey, EventID: e.EventID()}
if eventType == event.TypeMember {
// A member event whose content has no membership would have been
// refused on the way in, so an error here cannot happen; the empty
// string it leaves behind is also the correct "not a member".
entry.Membership, _ = e.Membership()
}
return entry
}
// eventProvider lets state resolution read a room's auth chains.
type eventProvider struct {
store storage.RoomStore
roomID string
rules roomversion.Rules
}
// EventsByID implements stateres.EventProvider.
//
// Events this server does not hold are left out rather than reported as an
// error, which is what the algorithm requires: a rejected or missing auth
// event takes no part in resolution.
func (p eventProvider) EventsByID(ctx context.Context, ids []string) ([]*event.Event, error) {
stored, err := p.store.EventsByID(ctx, p.roomID, ids)
if err != nil {
return nil, err
}
return parseEvents(stored, p.rules)
}
// validateContent checks event content before this server signs it.
//
// The size limit is the spec's limit on a whole event, applied to the
// content alone: the rest of an event is at most a few hundred bytes, so
// anything bigger than this cannot be made to fit and the client should hear
// about it before the event is built, hashed and signed.
func validateContent(content json.RawMessage) (json.RawMessage, error) {
if len(content) == 0 {
return json.RawMessage("{}"), nil
}
if len(content) > event.MaxEventSize {
return nil, fmt.Errorf("%w: content is %d bytes, the limit for a whole event is %d",
ErrInvalidContent, len(content), event.MaxEventSize)
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(content, &probe); err != nil {
return nil, fmt.Errorf("%w: content must be a JSON object", ErrInvalidContent)
}
return content, nil
}
@@ -0,0 +1,701 @@
package roomserver_test
import (
"context"
"encoding/json"
"errors"
"log/slog"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"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/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
)
const testServerName = "warp.example"
const (
alice = "@alice:" + testServerName
bob = "@bob:" + testServerName
carol = "@carol:" + testServerName
)
// fixture is a roomserver on a throwaway SQLite database.
//
// It is built from the real event core and the real store rather than from
// fakes, because what these tests are about 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.
type fixture struct {
t *testing.T
api *roomserver.API
store storage.RoomStore
db storage.Database
}
// profiles is a stand-in for the userapi, so that these tests do not need
// an account table to put a display name on a membership event.
type profiles map[string]storage.Account
func (p profiles) Account(_ context.Context, localpart string) (storage.Account, error) {
account, ok := p[localpart]
if !ok {
return storage.Account{}, storage.ErrNoSuchUser
}
return account, nil
}
func newFixture(t *testing.T, tweak func(*roomserver.Options)) *fixture {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path + "?_pragma=busy_timeout(5000)",
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
key, err := signing.Generate("test")
if err != nil {
t.Fatalf("signing.Generate: %v", err)
}
opts := roomserver.Options{
ServerName: testServerName,
Store: db.Rooms(),
SigningKey: key,
Profiles: profiles{
"alice": {Localpart: "alice", DisplayName: "Alice"},
"bob": {Localpart: "bob", DisplayName: "Bob"},
"carol": {Localpart: "carol", DisplayName: "Carol"},
},
Logger: slog.New(slog.DiscardHandler),
}
if tweak != nil {
tweak(&opts)
}
api, err := roomserver.New(opts)
if err != nil {
t.Fatalf("roomserver.New: %v", err)
}
return &fixture{t: t, api: api, store: db.Rooms(), db: db}
}
// createRoom creates a room and fails the test if it cannot.
func (f *fixture) createRoom(req roomserver.CreateRoomRequest) roomserver.CreateRoomResponse {
f.t.Helper()
if req.Creator == "" {
req.Creator = alice
}
resp, err := f.api.CreateRoom(f.t.Context(), req)
if err != nil {
f.t.Fatalf("CreateRoom: %v", err)
}
return resp
}
// state returns a room's current state as the authorisation rules see it.
func (f *fixture) state(roomID string) auth.State {
f.t.Helper()
room, err := f.store.Room(f.t.Context(), roomID)
if err != nil {
f.t.Fatalf("Room: %v", err)
}
rules, err := roomversion.Get(roomversion.Version(room.Version))
if err != nil {
f.t.Fatalf("roomversion.Get: %v", err)
}
stored, err := f.store.CurrentStateEvents(f.t.Context(), roomID)
if err != nil {
f.t.Fatalf("CurrentStateEvents: %v", err)
}
events := make([]*event.Event, 0, len(stored))
for _, s := range stored {
e, err := event.Parse(s.JSON, rules)
if err != nil {
f.t.Fatalf("parsing stored event %s: %v", s.EventID, err)
}
events = append(events, e)
}
return auth.NewState(events)
}
// membership is a user's membership in a room, or "".
func (f *fixture) membership(roomID, userID string) string {
f.t.Helper()
got, err := f.api.Membership(f.t.Context(), roomID, userID)
if err != nil {
f.t.Fatalf("Membership: %v", err)
}
return got
}
// sendMessage sends an m.room.message, which is the ordinary case.
func (f *fixture) sendMessage(roomID, sender, body, txnID string) (string, error) {
return f.api.SendEvent(f.t.Context(), roomserver.SendEventRequest{
RoomID: roomID,
Sender: sender,
Type: "m.room.message",
Content: json.RawMessage(`{"msgtype":"m.text","body":"` + body + `"}`),
Transaction: &storage.TransactionRef{
Localpart: localpartOf(sender), DeviceID: "DEV", TxnID: txnID,
},
})
}
func localpartOf(userID string) string {
for i := 1; i < len(userID); i++ {
if userID[i] == ':' {
return userID[1:i]
}
}
return userID
}
// TestCreateRoomBuildsAWorkingRoom is the whole of Phase 3 in one test: if
// the events createRoom sends are not authorised by the rules against each
// other, nothing else in this package can work.
func TestCreateRoomBuildsAWorkingRoom(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Name: "Test Room",
Topic: "A topic",
})
if resp.RoomID == "" || resp.RoomID[0] != '!' {
t.Fatalf("createRoom returned room ID %q", resp.RoomID)
}
st := f.state(resp.RoomID)
for _, want := range []struct{ eventType, stateKey string }{
{event.TypeCreate, ""},
{event.TypeMember, alice},
{event.TypePowerLevels, ""},
{event.TypeJoinRules, ""},
{event.TypeHistoryVisibility, ""},
{roomserver.TypeGuestAccess, ""},
{roomserver.TypeName, ""},
{roomserver.TypeTopic, ""},
} {
if st.Get(want.eventType, want.stateKey) == nil {
t.Errorf("the new room has no %s/%q in its state", want.eventType, want.stateKey)
}
}
if got := st.Membership(alice); got != event.MembershipJoin {
t.Errorf("the creator's membership is %q, want join", got)
}
if got := st.JoinRule(); got != auth.JoinRuleInvite {
t.Errorf("a private room's join rule is %q, want invite", got)
}
// The creator's membership event carries their profile, which is what
// lets a client render old messages with the name of the day.
member := st.Member(alice)
if member == nil {
t.Fatal("the creator has no membership event")
}
var content map[string]any
if err := json.Unmarshal(member.Content(), &content); err != nil {
t.Fatalf("the membership content is not an object: %v", err)
}
if content["displayname"] != "Alice" {
t.Errorf("the membership event carries displayname %v, want Alice", content["displayname"])
}
// The denormalised listing columns followed the state.
summary, err := f.store.RoomSummary(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomSummary: %v", err)
}
if summary.Name != "Test Room" || summary.Topic != "A topic" {
t.Errorf("the stored room profile is %+v", summary.RoomProfile)
}
if summary.JoinedMembers != 1 {
t.Errorf("the new room has %d joined members, want 1", summary.JoinedMembers)
}
}
// TestCreateRoomVersions checks every supported version end to end. Version
// 12 is the one worth the trouble: its room ID is the create event's own ID
// and its create event has no room_id at all, so a pipeline that assumed
// otherwise would fail here and nowhere else.
func TestCreateRoomVersions(t *testing.T) {
for _, version := range roomversion.Supported() {
t.Run(string(version), func(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Version: string(version)})
room, err := f.store.Room(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("Room: %v", err)
}
if room.Version != string(version) {
t.Errorf("the room was created at version %q, want %q", room.Version, version)
}
rules, err := roomversion.Get(version)
if err != nil {
t.Fatalf("roomversion.Get: %v", err)
}
if rules.RoomIDIsCreateEventID {
stored, err := f.store.CurrentStateEvent(t.Context(), resp.RoomID, event.TypeCreate, "")
if err != nil {
t.Fatalf("CurrentStateEvent: %v", err)
}
if want := "!" + stored.EventID[1:]; resp.RoomID != want {
t.Errorf("room ID %q is not derived from the create event %q",
resp.RoomID, stored.EventID)
}
}
// Whatever the version, a message has to be sendable: that is
// what proves the auth events the pipeline selected are the
// ones the rules want.
if _, err := f.sendMessage(resp.RoomID, alice, "hello", "m1"); err != nil {
t.Errorf("sending a message into a version %s room: %v", version, err)
}
})
}
}
func TestCreateRoomUnknownVersion(t *testing.T) {
f := newFixture(t, nil)
_, err := f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
Creator: alice,
Version: "99",
})
if !errors.Is(err, roomserver.ErrUnknownRoomVersion) {
t.Errorf("createRoom at version 99 = %v, want ErrUnknownRoomVersion", err)
}
// Versions 1 and 2 exist and are deliberately not supported: their
// event format is a separate piece of work that waits for federation.
_, err = f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
Creator: alice,
Version: "1",
})
if !errors.Is(err, roomserver.ErrUnsupportedRoomVersion) {
t.Errorf("createRoom at version 1 = %v, want ErrUnsupportedRoomVersion", err)
}
}
func TestCreateRoomPresets(t *testing.T) {
tests := []struct {
preset, visibility string
wantJoinRule string
wantGuestAccess string
}{
{preset: roomserver.PresetPublicChat, wantJoinRule: auth.JoinRulePublic,
wantGuestAccess: roomserver.GuestAccessForbidden},
{preset: roomserver.PresetPrivateChat, wantJoinRule: auth.JoinRuleInvite,
wantGuestAccess: roomserver.GuestAccessCanJoin},
{preset: roomserver.PresetTrustedPrivateChat, wantJoinRule: auth.JoinRuleInvite,
wantGuestAccess: roomserver.GuestAccessCanJoin},
// With no preset the directory visibility decides, as the spec says.
{visibility: roomserver.VisibilityPublic, wantJoinRule: auth.JoinRulePublic,
wantGuestAccess: roomserver.GuestAccessForbidden},
{visibility: roomserver.VisibilityPrivate, wantJoinRule: auth.JoinRuleInvite,
wantGuestAccess: roomserver.GuestAccessCanJoin},
}
for _, tc := range tests {
name := tc.preset
if name == "" {
name = "visibility=" + tc.visibility
}
t.Run(name, func(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Preset: tc.preset,
Visibility: tc.visibility,
})
st := f.state(resp.RoomID)
if got := st.JoinRule(); got != tc.wantJoinRule {
t.Errorf("join rule = %q, want %q", got, tc.wantJoinRule)
}
summary, err := f.store.RoomSummary(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomSummary: %v", err)
}
if summary.GuestAccess != tc.wantGuestAccess {
t.Errorf("guest access = %q, want %q", summary.GuestAccess, tc.wantGuestAccess)
}
if want := tc.visibility == roomserver.VisibilityPublic; summary.Published != want {
t.Errorf("published = %v, want %v", summary.Published, want)
}
})
}
}
// TestCreateRoomTrustedPrivateChatPromotesInvitees is the one thing the
// preset is for: everybody invited comes in as an equal.
func TestCreateRoomTrustedPrivateChatPromotesInvitees(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
Preset: roomserver.PresetTrustedPrivateChat,
Invite: []string{bob},
})
st := f.state(resp.RoomID)
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), st.Create(), st.Create().Rules())
if err != nil {
t.Fatalf("ResolvePowerLevels: %v", err)
}
if got := powerLevels.UserLevel(bob); got != auth.CreatorPowerLevel {
t.Errorf("an invitee's power level is %d, want %d", got, auth.CreatorPowerLevel)
}
if got := f.membership(resp.RoomID, bob); got != event.MembershipInvite {
t.Errorf("bob's membership is %q, want invite", got)
}
}
func TestCreateRoomPowerLevelOverride(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
PowerLevelContentOverride: json.RawMessage(`{"events_default": 25, "invite": 75}`),
})
st := f.state(resp.RoomID)
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), st.Create(), st.Create().Rules())
if err != nil {
t.Fatalf("ResolvePowerLevels: %v", err)
}
if powerLevels.EventsDefault != 25 || powerLevels.Invite != 75 {
t.Errorf("the override was not applied: events_default=%d invite=%d",
powerLevels.EventsDefault, powerLevels.Invite)
}
// The keys the override did not mention keep the defaults, which is
// what makes a shallow merge the right one.
if powerLevels.Ban != auth.DefaultBan {
t.Errorf("ban = %d, want the default %d", powerLevels.Ban, auth.DefaultBan)
}
}
func TestCreateRoomInitialStateAndAlias(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{
RoomAliasName: "general",
InitialState: []roomserver.StateEvent{{
Type: event.TypeHistoryVisibility,
Content: json.RawMessage(`{"history_visibility":"world_readable"}`),
}},
})
if want := "#general:" + testServerName; resp.Alias != want {
t.Errorf("createRoom returned alias %q, want %q", resp.Alias, want)
}
resolved, err := f.api.ResolveAlias(t.Context(), resp.Alias)
if err != nil {
t.Fatalf("ResolveAlias: %v", err)
}
if resolved.RoomID != resp.RoomID {
t.Errorf("the alias resolves to %q, want %q", resolved.RoomID, resp.RoomID)
}
// initial_state overwrites the server's own setup event, which is what
// makes a world-readable room creatable in one call.
summary, err := f.store.RoomSummary(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomSummary: %v", err)
}
if summary.HistoryVisibility != roomserver.HistoryVisibilityWorldReadable {
t.Errorf("history visibility = %q, want world_readable", summary.HistoryVisibility)
}
if summary.CanonicalAlias != resp.Alias {
t.Errorf("canonical alias = %q, want %q", summary.CanonicalAlias, resp.Alias)
}
}
// TestCreateRoomAliasAlreadyTaken checks the alias is claimed before the
// room is built, so that a clash does not leave a room behind.
func TestCreateRoomAliasAlreadyTaken(t *testing.T) {
f := newFixture(t, nil)
f.createRoom(roomserver.CreateRoomRequest{RoomAliasName: "general"})
before, _, err := f.store.ListRooms(t.Context(), storage.RoomFilter{})
if err != nil {
t.Fatalf("ListRooms: %v", err)
}
_, err = f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
Creator: bob,
RoomAliasName: "general",
})
if !errors.Is(err, roomserver.ErrAliasExists) {
t.Fatalf("createRoom with a taken alias = %v, want ErrAliasExists", err)
}
after, _, err := f.store.ListRooms(t.Context(), storage.RoomFilter{})
if err != nil {
t.Fatalf("ListRooms: %v", err)
}
if len(after) != len(before) {
t.Errorf("a failed createRoom left %d extra rooms behind", len(after)-len(before))
}
}
func TestSendEventAndStreamOrder(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
first, err := f.sendMessage(resp.RoomID, alice, "one", "m1")
if err != nil {
t.Fatalf("sending the first message: %v", err)
}
second, err := f.sendMessage(resp.RoomID, alice, "two", "m2")
if err != nil {
t.Fatalf("sending the second message: %v", err)
}
one, err := f.store.Event(t.Context(), resp.RoomID, first)
if err != nil {
t.Fatalf("Event: %v", err)
}
two, err := f.store.Event(t.Context(), resp.RoomID, second)
if err != nil {
t.Fatalf("Event: %v", err)
}
if two.StreamOrdering <= one.StreamOrdering {
t.Errorf("stream ordering did not advance: %d then %d",
one.StreamOrdering, two.StreamOrdering)
}
if two.Depth <= one.Depth {
t.Errorf("depth did not advance: %d then %d", one.Depth, two.Depth)
}
// The second event descends from the first, so the room stays a chain
// rather than forking.
extremities, err := f.store.Extremities(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("Extremities: %v", err)
}
if len(extremities) != 1 || extremities[0].EventID != second {
t.Errorf("extremities = %v, want just the last event", extremities)
}
}
// TestSendEventIsIdempotentPerTransaction is what a client relies on when
// its connection drops mid-send.
func TestSendEventIsIdempotentPerTransaction(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
first, err := f.sendMessage(resp.RoomID, alice, "hello", "same")
if err != nil {
t.Fatalf("first send: %v", err)
}
again, err := f.sendMessage(resp.RoomID, alice, "hello", "same")
if err != nil {
t.Fatalf("retried send: %v", err)
}
if again != first {
t.Errorf("a retry produced a second event: %q then %q", first, again)
}
}
func TestSendEventRefusals(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
// A non-member cannot send into the room: the rules say so, not a
// check in the handler.
_, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: bob,
Type: "m.room.message",
Content: json.RawMessage(`{"msgtype":"m.text","body":"hi"}`),
})
if !errors.Is(err, roomserver.ErrRejected) {
t.Errorf("a stranger sending a message = %v, want ErrRejected", err)
}
// A second create event is refused outright, with a message about the
// room rather than about a rule number.
_, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: event.TypeCreate,
StateKey: stringPtr(""),
Content: json.RawMessage(`{"room_version":"11"}`),
})
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("re-creating a room = %v, want ErrForbidden", err)
}
// A user of another server cannot be sent as: this server can only
// sign as itself.
_, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: "@eve:elsewhere.example",
Type: "m.room.message",
Content: json.RawMessage(`{"msgtype":"m.text","body":"hi"}`),
})
if !errors.Is(err, roomserver.ErrForbidden) {
t.Errorf("sending as a remote user = %v, want ErrForbidden", err)
}
// Content that is not an object is refused before anything is signed.
_, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: "m.room.message",
Content: json.RawMessage(`"not an object"`),
})
if !errors.Is(err, roomserver.ErrInvalidContent) {
t.Errorf("sending a string as content = %v, want ErrInvalidContent", err)
}
// A room this server does not have is not a room.
_, err = f.sendMessage("!nope:"+testServerName, alice, "hi", "x")
if !errors.Is(err, roomserver.ErrNoSuchRoom) {
t.Errorf("sending into an unknown room = %v, want ErrNoSuchRoom", err)
}
}
func TestSendStateEvent(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{})
if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: roomserver.TypeName,
StateKey: stringPtr(""),
Content: json.RawMessage(`{"name":"Renamed"}`),
}); err != nil {
t.Fatalf("setting the room name: %v", err)
}
content, err := f.api.StateEventContent(t.Context(), resp.RoomID, alice, roomserver.TypeName, "")
if err != nil {
t.Fatalf("StateEventContent: %v", err)
}
var body struct {
Name string `json:"name"`
}
if err := json.Unmarshal(content, &body); err != nil {
t.Fatalf("the state content is not an object: %v", err)
}
if body.Name != "Renamed" {
t.Errorf("the room name is %q, want Renamed", body.Name)
}
// The denormalised listing column followed, which is what the public
// room directory is searched by.
summary, err := f.store.RoomSummary(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("RoomSummary: %v", err)
}
if summary.Name != "Renamed" {
t.Errorf("the stored room name is %q, want Renamed", summary.Name)
}
// A member event through the state endpoint needs a real membership.
_, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: event.TypeMember,
StateKey: stringPtr(bob),
Content: json.RawMessage(`{"membership":"nonsense"}`),
})
if !errors.Is(err, roomserver.ErrInvalidContent) {
t.Errorf("an invented membership = %v, want ErrInvalidContent", err)
}
}
func stringPtr(s string) *string { return &s }
// TestClockIsUsedForTimestamps checks that origin_server_ts comes from the
// injected clock, which is what makes a test able to reason about ages.
func TestClockIsUsedForTimestamps(t *testing.T) {
at := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
f := newFixture(t, func(opts *roomserver.Options) {
opts.Now = func() time.Time { return at }
})
resp := f.createRoom(roomserver.CreateRoomRequest{})
stored, err := f.store.CurrentStateEvent(t.Context(), resp.RoomID, event.TypeCreate, "")
if err != nil {
t.Fatalf("CurrentStateEvent: %v", err)
}
if stored.OriginServerTS != at.UnixMilli() {
t.Errorf("origin_server_ts = %d, want %d", stored.OriginServerTS, at.UnixMilli())
}
}
// TestConcurrentSendsStayALinearChain is what the per-room lock exists for.
//
// Without it two events racing into one room would each read the same
// forward extremities, both become one, and fork the room — which would
// then need state resolution to put back together for no reason at all.
func TestConcurrentSendsStayALinearChain(t *testing.T) {
f := newFixture(t, nil)
resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat})
const senders = 8
var wg sync.WaitGroup
errs := make(chan error, senders)
for i := range senders {
wg.Add(1)
go func() {
defer wg.Done()
_, err := f.api.SendEvent(context.Background(), roomserver.SendEventRequest{
RoomID: resp.RoomID,
Sender: alice,
Type: "m.room.message",
Content: json.RawMessage(`{"msgtype":"m.text","body":"concurrent"}`),
Transaction: &storage.TransactionRef{
Localpart: "alice",
DeviceID: "DEV",
TxnID: "txn" + strconv.Itoa(i),
},
})
errs <- err
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("a concurrent send failed: %v", err)
}
}
extremities, err := f.store.Extremities(t.Context(), resp.RoomID)
if err != nil {
t.Fatalf("Extremities: %v", err)
}
if len(extremities) != 1 {
t.Errorf("the room has %d forward extremities, want 1: the sends forked it",
len(extremities))
}
// Every send produced an event: the lock serialises them, it does not
// drop any.
stats, err := f.store.RoomStats(t.Context())
if err != nil {
t.Fatalf("RoomStats: %v", err)
}
if stats.Events < senders {
t.Errorf("the room holds %d events, want at least the %d sent", stats.Events, senders)
}
}
+231
View File
@@ -0,0 +1,231 @@
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"
)
// SendEventRequest sends one event into a room on a user's behalf.
type SendEventRequest struct {
RoomID string
// Sender is the full user ID sending the event.
Sender string
Type string
// StateKey makes this a state event when it is non-nil.
StateKey *string
Content json.RawMessage
// Transaction, when set, makes the send idempotent: a retry with the
// same transaction ID returns the event the first attempt created.
// State events have no transaction ID in the Client-Server API, which
// is why this is optional.
Transaction *storage.TransactionRef
}
// SendEvent sends a message or state event into a room.
//
// It does no authorisation of its own, deliberately: whether a user may
// send a given type into a given room is a question about the room's power
// levels and membership, and the one place that is answered correctly is
// the authorisation rules inside the pipeline. A handler-level check here
// would be a second, worse copy of them.
//
// The one thing it does refuse up front is m.room.create. The rules would
// refuse it too — a second create event in a room is rejected — but the
// error a client gets for "you cannot re-create a room" is more use than
// the one for "rule 2 said no".
func (r *API) SendEvent(ctx context.Context, req SendEventRequest) (string, error) {
if req.Type == event.TypeCreate {
return "", fmt.Errorf("%w: a room's m.room.create event is sent by createRoom and cannot be replaced",
ErrForbidden)
}
if req.Type == event.TypeMember {
// A membership change through the state endpoint is legitimate and
// the rules handle it, but the membership value still has to be one
// the rules know: content that is not a membership at all would
// otherwise become an event nobody can interpret.
if err := checkMembershipContent(req.Content); err != nil {
return "", err
}
}
sent, err := r.send(ctx, sendRequest{
RoomID: req.RoomID,
Type: req.Type,
StateKey: req.StateKey,
Sender: req.Sender,
Content: req.Content,
Transaction: req.Transaction,
})
if err != nil {
return "", err
}
return sent.EventID(), nil
}
// checkMembershipContent rejects an m.room.member content whose membership
// is missing or not one of the five the spec defines.
func checkMembershipContent(content json.RawMessage) error {
var body struct {
Membership string `json:"membership"`
}
if err := json.Unmarshal(content, &body); err != nil {
return fmt.Errorf("%w: an m.room.member content needs a membership", ErrInvalidContent)
}
switch body.Membership {
case event.MembershipJoin, event.MembershipLeave, event.MembershipInvite,
event.MembershipBan, event.MembershipKnock:
return nil
case "":
return fmt.Errorf("%w: an m.room.member content needs a membership", ErrInvalidContent)
default:
return fmt.Errorf("%w: %q is not a membership", ErrInvalidContent, body.Membership)
}
}
// RedactRequest asks to redact an event.
type RedactRequest struct {
RoomID string
// Sender is the full user ID sending the redaction.
Sender string
// EventID is the event to redact.
EventID string
Reason string
Transaction *storage.TransactionRef
}
// Redact redacts an event.
//
// This is two decisions, not one, and the spec keeps them apart. The first
// is whether the m.room.redaction event may be in the room at all, which
// the authorisation rules answer and which is permissive: almost any member
// may send one, because a server receiving a redaction for an event it has
// never seen still has to be able to store it. The second is whether the
// redaction takes effect on its target, which needs both events in hand and
// is auth.ShouldApplyRedaction's job — either the sender has the redact
// level, or the redaction comes from the same server as the event, because
// a server may always take back its own users' events.
//
// Phase 3 only ever has both events, since every event in a room is local.
// A redaction whose target has not arrived is a federation case.
func (r *API) Redact(ctx context.Context, req RedactRequest) (string, error) {
// The room is read first so that a room this server does not have is a
// 404 before anything is built, and so that a room whose version is no
// longer supported says so.
room, err := r.store.Room(ctx, req.RoomID)
if err != nil {
return "", err
}
if _, err := r.rules(room); err != nil {
return "", err
}
// The target is read before the redaction is built so that redacting
// an event that does not exist is a 404 rather than an event in the
// room that refers to nothing.
if _, err := r.store.Event(ctx, req.RoomID, req.EventID); err != nil {
return "", err
}
content := map[string]any{}
if req.Reason != "" {
content["reason"] = req.Reason
}
redaction, err := r.send(ctx, sendRequest{
RoomID: req.RoomID,
Type: event.TypeRedaction,
Sender: req.Sender,
Content: mustMarshal(content),
Redacts: req.EventID,
Transaction: req.Transaction,
})
if err != nil {
return "", err
}
return redaction.EventID(), nil
}
// prepareRedaction decides whether a redaction takes effect and, if it
// does, puts the target's redacted form into the write.
//
// It runs inside persist, within the same transaction as the redaction
// event itself, so that a client which gets an event ID back can always
// read the stripped event.
func (r *API) prepareRedaction(ctx context.Context, write *storage.RoomWrite, req persistRequest) error {
storedTarget, err := r.store.Event(ctx, req.Room.ID, req.RedactsEventID)
if isNotFound(err) {
// Over federation a redaction can arrive before its target, and the
// right answer is to keep it and apply it later. Locally the target
// was read before the redaction was built, so this means it was
// purged in between.
r.log.Info("redaction target is not here",
"room_id", req.Room.ID, "redacts", req.RedactsEventID)
return nil
} else if err != nil {
return err
}
if storedTarget.RedactedBy != "" {
// Already redacted. The event stays as it is: a second redaction
// has nothing left to remove.
return nil
}
target, err := parseEvent(storedTarget, req.Rules)
if err != nil {
return err
}
applies, err := r.redactionApplies(ctx, req.Room, req.Rules, req.Event, target)
if err != nil {
return err
}
if !applies {
// The event is in the room and has no effect, which is what the
// spec asks for. It is worth a log line: from the client's side a
// redaction that returns an event ID and changes nothing looks
// exactly like one that worked.
r.log.Info("accepted a redaction that does not apply",
"room_id", req.Room.ID, "event_id", req.Event.EventID(),
"redacts", req.RedactsEventID, "sender", req.Event.Sender())
return nil
}
redacted, err := target.Redact()
if err != nil {
return fmt.Errorf("redacting %s: %w", req.RedactsEventID, err)
}
write.Redacts = req.RedactsEventID
write.RedactedJSON = redacted.JSON()
return nil
}
// redactionApplies runs the spec's "should this redaction be applied" test.
func (r *API) redactionApplies(ctx context.Context, room storage.Room, rules roomversion.Rules, redaction, target *event.Event) (bool, error) {
st, err := r.currentState(ctx, room, rules)
if err != nil {
return false, err
}
create := st.Create()
if create == nil {
return false, fmt.Errorf("%w: %s has no create event in its state", ErrRejected, room.ID)
}
powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), create, rules)
if err != nil {
return false, fmt.Errorf("%w: %v", ErrRejected, err)
}
return auth.ShouldApplyRedaction(redaction, target, powerLevels), nil
}
+40
View File
@@ -0,0 +1,40 @@
package roomserver
import (
"context"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// localVerifier checks an event's signature against this server's own key.
//
// One authorisation rule needs somebody else's signature verified: a join
// under the restricted join rule is authorised by a third party named in
// content.join_authorised_via_users_server, and the event must carry a
// signature from *that user's* server. While federation is off the only
// such server that can appear is this one, and its key is already in hand —
// so the rule can be applied in full, locally, without a key fetcher.
//
// Phase 5 replaces this with a verifier that can fetch and cache another
// server's keys. Until then a join authorised via a remote user is refused
// rather than waved through, which is the same answer the federation switch
// gives everywhere else.
type localVerifier struct {
serverName string
key *signing.KeyPair
}
// verifier returns the Verifier the authorisation rules are given.
func (r *API) verifier() *localVerifier {
return &localVerifier{serverName: r.serverName, key: r.signingKey}
}
// VerifyEventSignature implements auth.Verifier.
func (v *localVerifier) VerifyEventSignature(_ context.Context, serverName string, e *event.Event) error {
if serverName != v.serverName {
return fmt.Errorf("%w: cannot verify a signature from %s", ErrFederationDisabled, serverName)
}
return e.VerifySignature(v.serverName, v.key.KeyID(), v.key.Public)
}