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
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>
459 lines
15 KiB
Go
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
|
|
}
|