Files
Warp/server/internal/clientapi/rooms.go
T
tajniak81andClaude Opus 5.5 76dfc98a86
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Group voice and video calls with MatrixRTC and LiveKit
Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.

Server:
- MSC4140 delayed events (send/state with a delay, plus the
  delayed_events list/restart/cancel/send API), stored and scheduled, so
  a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
  hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
  under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.

Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:33:29 +02:00

459 lines
15 KiB
Go

package clientapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// roomError maps a roomserver error onto the Matrix error the spec answers
// it with.
//
// The roomserver's refusals carry their reason in the message — which rule
// said no, which membership was missing — and the message is passed through
// to the client. That is deliberate: "M_FORBIDDEN" on its own sends a user
// to their operator and the operator to the logs, and nothing in these
// messages is anything the caller could not work out by reading the room's
// state, which they are entitled to do.
func roomError(err error) error {
var matrixErr *httputil.MatrixError
switch {
case err == nil:
return nil
case errors.As(err, &matrixErr):
return err
case errors.Is(err, roomserver.ErrNoSuchRoom):
return httputil.NotFound("Unknown room")
case errors.Is(err, roomserver.ErrNoSuchEvent):
return httputil.NotFound("Unknown event")
case errors.Is(err, roomserver.ErrNoSuchAlias):
return httputil.NotFound("Unknown room alias")
case errors.Is(err, roomserver.ErrAliasExists):
return httputil.NewError(http.StatusConflict, httputil.ErrCodeRoomInUse,
"That room alias is already taken")
case errors.Is(err, roomserver.ErrUnknownRoomVersion),
errors.Is(err, roomserver.ErrUnsupportedRoomVersion):
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnsupportedVer, "%s", err.Error())
case errors.Is(err, roomserver.ErrFederationDisabled):
return httputil.Forbidden("%s", err.Error())
case errors.Is(err, roomserver.ErrIncompatibleRoomVersion):
return httputil.NewError(http.StatusBadRequest, "M_INCOMPATIBLE_ROOM_VERSION", "%s", err.Error())
case errors.Is(err, roomserver.ErrNotInRoom):
return httputil.Forbidden("%s", err.Error())
case errors.Is(err, roomserver.ErrRemote), errors.Is(err, roomserver.ErrBadEvent):
// Another server failed us; the client can do nothing about it but
// try again, or try another server.
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "%s", err.Error())
case errors.Is(err, roomserver.ErrRejected), errors.Is(err, roomserver.ErrForbidden):
return httputil.Forbidden("%s", err.Error())
case errors.Is(err, roomserver.ErrInvalidContent):
return httputil.BadJSON("%s", err.Error())
default:
return err
}
}
// createRoomRequest is the POST /createRoom body.
type createRoomRequest struct {
RoomVersion string `json:"room_version"`
Preset string `json:"preset"`
Visibility string `json:"visibility"`
RoomAliasName string `json:"room_alias_name"`
Name string `json:"name"`
Topic string `json:"topic"`
Invite []string `json:"invite"`
CreationContent json.RawMessage `json:"creation_content"`
InitialState []roomserver.StateEvent `json:"initial_state"`
PowerLevelContentOverride json.RawMessage `json:"power_level_content_override"`
IsDirect bool `json:"is_direct"`
Invite3PID []map[string]json.RawMessage `json:"invite_3pid"`
}
type createRoomResponse struct {
RoomID string `json:"room_id"`
RoomAlias string `json:"room_alias,omitempty"`
}
// handleCreateRoom implements POST /_matrix/client/v3/createRoom.
func (c *ClientAPI) handleCreateRoom(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req createRoomRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
// Third-party invites go through an identity server, which Warp does
// not talk to (PLAN.md phase 9). Saying so is better than creating the
// room and quietly dropping the invites.
if len(req.Invite3PID) > 0 {
return httputil.Unknown(http.StatusBadRequest,
"Third-party invites are not supported by this server")
}
if req.RoomAliasName != "" {
if err := c.checkExclusiveAlias("#"+req.RoomAliasName+":"+c.serverName, auth); err != nil {
return err
}
}
resp, err := c.rooms.CreateRoom(r.Context(), roomserver.CreateRoomRequest{
Creator: auth.UserID,
Version: req.RoomVersion,
Preset: req.Preset,
Visibility: req.Visibility,
RoomAliasName: req.RoomAliasName,
Name: req.Name,
Topic: req.Topic,
Invite: req.Invite,
CreationContent: req.CreationContent,
InitialState: req.InitialState,
PowerLevelContentOverride: req.PowerLevelContentOverride,
IsDirect: req.IsDirect,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, createRoomResponse{
RoomID: resp.RoomID,
RoomAlias: resp.Alias,
})
return nil
}
// eventIDResponse is what every endpoint that sends an event returns.
type eventIDResponse struct {
EventID string `json:"event_id"`
}
// sendEventRequest is the body of a send or state PUT: any JSON object.
type sendEventRequest = json.RawMessage
// handleSendEvent implements
// PUT /_matrix/client/v3/rooms/{roomID}/send/{eventType}/{txnID}.
func (c *ClientAPI) handleSendEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
txnID := r.PathValue("txnID")
if txnID == "" {
return httputil.MissingParam("A transaction ID is required")
}
delay, delayed, err := c.delayRequested(r)
if err != nil {
return err
}
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if delayed {
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
Type: eventType, Content: content, Delay: delay,
})
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
Sender: auth.UserID,
Type: eventType,
Content: content,
Transaction: transactionRef(auth, txnID),
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: eventID})
return nil
}
// handlePutState implements
// PUT /_matrix/client/v3/rooms/{roomID}/state/{eventType}[/{stateKey}].
//
// State events carry no transaction ID: the spec gives none, because a
// state event is idempotent in a way a message is not — sending the same
// state twice leaves the room in the state you asked for either way.
func (c *ClientAPI) handlePutState(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
stateKey := r.PathValue("stateKey")
delay, delayed, err := c.delayRequested(r)
if err != nil {
return err
}
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if delayed {
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
Type: eventType, StateKey: &stateKey, Content: content, Delay: delay,
})
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
Sender: auth.UserID,
Type: eventType,
StateKey: &stateKey,
Content: content,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: eventID})
return nil
}
// redactRequest is the PUT /rooms/{roomID}/redact/{eventID}/{txnID} body.
type redactRequest struct {
Reason string `json:"reason"`
}
// handleRedact implements
// PUT /_matrix/client/v3/rooms/{roomID}/redact/{eventID}/{txnID}.
func (c *ClientAPI) handleRedact(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventID := r.PathValue("eventID")
if err := id.ValidateEventID(eventID); err != nil {
return httputil.InvalidParam("%s is not a valid event ID", eventID)
}
txnID := r.PathValue("txnID")
if txnID == "" {
return httputil.MissingParam("A transaction ID is required")
}
var req redactRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
redactionID, err := c.rooms.Redact(r.Context(), roomserver.RedactRequest{
RoomID: roomID,
Sender: auth.UserID,
EventID: eventID,
Reason: req.Reason,
Transaction: transactionRef(auth, txnID),
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: redactionID})
return nil
}
// handleGetEvent implements
// GET /_matrix/client/v3/rooms/{roomID}/event/{eventID}.
func (c *ClientAPI) handleGetEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventID := r.PathValue("eventID")
if err := id.ValidateEventID(eventID); err != nil {
return httputil.InvalidParam("%s is not a valid event ID", eventID)
}
clientEvent, err := c.rooms.Event(r.Context(), roomID, auth.UserID, eventID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, clientEvent)
return nil
}
// handleGetState implements GET /_matrix/client/v3/rooms/{roomID}/state,
// which returns the room's whole current state as an array.
func (c *ClientAPI) handleGetState(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
state, err := c.rooms.RoomState(r.Context(), roomID, auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, state)
return nil
}
// handleGetStateEvent implements
// GET /_matrix/client/v3/rooms/{roomID}/state/{eventType}[/{stateKey}].
//
// It returns the event's content rather than the event: an inconsistency in
// the Client-Server API that predates most of it, and one every client
// relies on. The ?format=event query parameter asks for the whole event
// instead, which is how a client that wants the sender gets it.
func (c *ClientAPI) handleGetStateEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
stateKey := r.PathValue("stateKey")
if r.URL.Query().Get("format") == "event" {
state, err := c.rooms.StateEvent(r.Context(), roomID, auth.UserID, eventType, stateKey)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, state)
return nil
}
content, err := c.rooms.StateEventContent(r.Context(), roomID, auth.UserID, eventType, stateKey)
if err != nil {
return roomError(err)
}
httputil.WriteRawJSON(w, http.StatusOK, content)
return nil
}
// membersResponse is the GET /rooms/{roomID}/members body.
type membersResponse struct {
Chunk []roomserver.ClientEvent `json:"chunk"`
}
// handleGetMembers implements GET /_matrix/client/v3/rooms/{roomID}/members.
//
// The ?at= parameter is a sync token, and names the point in the event
// stream to report the membership as of. A client rendering an older part
// of a room's timeline needs it: the names and avatars to show against
// those messages are the ones their senders had then, not now.
func (c *ClientAPI) handleGetMembers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
query := r.URL.Query()
membership, notMembership := query.Get("membership"), query.Get("not_membership")
for _, value := range []string{membership, notMembership} {
if err := roomserver.ValidateMembershipFilter(value); err != nil {
return roomError(err)
}
}
// ?at= only has a meaning once there is a stream, so it is read here
// rather than in the roomserver: the token format is the syncapi's.
at, err := ParseSyncPosition(query.Get("at"))
if err != nil {
return err
}
members, err := c.rooms.Members(r.Context(), roomserver.MembersRequest{
RoomID: roomID,
User: auth.UserID,
Membership: membership,
NotMembership: notMembership,
At: at,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, membersResponse{Chunk: members})
return nil
}
// joinedMembersResponse is the GET /rooms/{roomID}/joined_members body.
type joinedMembersResponse struct {
Joined map[string]roomserver.RoomMember `json:"joined"`
}
// handleGetJoinedMembers implements
// GET /_matrix/client/v3/rooms/{roomID}/joined_members.
func (c *ClientAPI) handleGetJoinedMembers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
joined, err := c.rooms.JoinedMembers(r.Context(), roomID, auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, joinedMembersResponse{Joined: joined})
return nil
}
// joinedRoomsResponse is the GET /joined_rooms body.
type joinedRoomsResponse struct {
JoinedRooms []string `json:"joined_rooms"`
}
// handleGetJoinedRooms implements GET /_matrix/client/v3/joined_rooms.
func (c *ClientAPI) handleGetJoinedRooms(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
rooms, err := c.rooms.JoinedRooms(r.Context(), auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, joinedRoomsResponse{JoinedRooms: rooms})
return nil
}
// pathRoomID reads and validates the {roomID} path value.
func pathRoomID(r *http.Request) (string, error) {
roomID := r.PathValue("roomID")
if err := id.ValidateRoomID(roomID); err != nil {
return "", httputil.InvalidParam("%s is not a valid room ID", roomID)
}
return roomID, nil
}
// transactionRef builds the idempotency key for a send.
//
// It is keyed on the device rather than the user: the spec scopes a
// transaction ID to the access token, and two of a user's clients each
// counting from "m1" must not collide.
func transactionRef(auth userapi.Authenticated, txnID string) *storage.TransactionRef {
return &storage.TransactionRef{
Localpart: auth.Localpart,
DeviceID: auth.DeviceID,
TxnID: txnID,
}
}
// aliasFromPath reads the {roomAlias} path value, which arrives percent-
// encoded because it contains a '#' and a ':'.
func aliasFromPath(r *http.Request) (string, error) {
alias := r.PathValue("roomAlias")
if alias == "" {
return "", httputil.MissingParam("A room alias is required")
}
if !strings.HasPrefix(alias, "#") {
return "", httputil.InvalidParam("%s is not a valid room alias", alias)
}
return alias, nil
}