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 room. // // Joining a room this server is not in is a federated join: the room has // to be fetched from a server that is (see federated_membership.go). 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) } remote, err := r.joinNeedsFederation(ctx, req) if err != nil { return "", err } if remote { return r.joinRemote(ctx, req) } // 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. // // A room this server is not in — one it only holds an invite to — cannot // take an event from here, and the leave goes through a server that is in // it instead. func (r *API) LeaveRoom(ctx context.Context, roomID, userID, reason string) error { if r.isLocal(userID) { if _, err := r.store.Room(ctx, roomID); err != nil { return err } if _, err := r.store.CurrentStateEvent(ctx, roomID, event.TypeCreate, ""); isNotFound(err) { return r.leaveRemote(ctx, roomID, userID, reason) } else if err != nil { return err } in, err := r.participating(ctx, roomID) if err != nil { return err } if !in { if membership, err := r.store.Membership(ctx, roomID, userID); err == nil && membership.Membership == event.MembershipInvite && !r.isLocalEvent(ctx, roomID, membership.EventID) { return r.leaveRemote(ctx, roomID, userID, reason) } } } _, 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) { return r.inviteRemote(ctx, req) } _, 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 } // isLocalEvent reports whether an event of a room was sent by a user of // this server. func (r *API) isLocalEvent(ctx context.Context, roomID, eventID string) bool { stored, err := r.store.Event(ctx, roomID, eventID) return err == nil && r.isLocal(stored.Sender) }