Group voice and video calls with MatrixRTC and LiveKit
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
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>
This commit is contained in:
1 parent
325431a5be
commit
76dfc98a86
49 files changed
+3849
-432
No files matched your search
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/appservice"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/keyserver"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/syncapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
@@ -48,6 +50,9 @@ type ClientAPI struct {
|
||||
regTokens *userapi.RegistrationTokens
|
||||
appservices *appservice.API
|
||||
turn config.TURN
|
||||
delayed *delayedevents.Service
|
||||
rtcTransports []RTCTransport
|
||||
openID storage.OpenIDTokenStore
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
@@ -145,6 +150,20 @@ type Options struct {
|
||||
// TURN is the relay handed to clients for calls.
|
||||
TURN config.TURN
|
||||
|
||||
// RTC is the group call backend, listed at the MSC4143 transports
|
||||
// endpoint.
|
||||
RTC config.RTC
|
||||
|
||||
// DelayedEvents schedules MSC4140 delayed events. Nil, or a service
|
||||
// with delayed events switched off, leaves the endpoints unregistered
|
||||
// and refuses the delay parameter.
|
||||
DelayedEvents *delayedevents.Service
|
||||
|
||||
// OpenIDTokens keeps the OpenID tokens a user hands third parties,
|
||||
// such as the LiveKit JWT service of a group call. Nil leaves the
|
||||
// OpenID endpoints unregistered.
|
||||
OpenIDTokens storage.OpenIDTokenStore
|
||||
|
||||
// TrustXForwardedFor takes the client address from the proxy header.
|
||||
TrustXForwardedFor bool
|
||||
|
||||
@@ -205,6 +224,9 @@ func New(opts Options) (*ClientAPI, error) {
|
||||
regTokens: opts.RegistrationTokens,
|
||||
appservices: opts.AppServices,
|
||||
turn: opts.TURN,
|
||||
delayed: opts.DelayedEvents,
|
||||
rtcTransports: RTCTransports(opts.RTC.LiveKitServiceURL),
|
||||
openID: opts.OpenIDTokens,
|
||||
log: opts.Logger,
|
||||
now: opts.Now,
|
||||
uia: newUIAStore(uiaSessionTTL, opts.Now),
|
||||
@@ -274,6 +296,9 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
|
||||
|
||||
// Calls: the relay a client sets its peer connection up with.
|
||||
c.authed(r, "GET "+v3+"/voip/turnServer", c.limitDefault, c.handleTurnServer)
|
||||
c.registerOpenIDRoutes(r)
|
||||
c.registerDelayedEventRoutes(r)
|
||||
c.registerRTCRoutes(r)
|
||||
|
||||
// Devices.
|
||||
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
|
||||
|
||||
@@ -2,16 +2,20 @@ package clientapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
@@ -39,6 +43,44 @@ type testServer struct {
|
||||
settings *settings.Service
|
||||
fedGate *federation.Gate
|
||||
db storage.Database
|
||||
|
||||
// delayed is the delayed event service, on a clock of its own that
|
||||
// tests move with advance.
|
||||
delayed *delayedevents.Service
|
||||
clock *testClock
|
||||
}
|
||||
|
||||
// testClock is a clock a test moves by hand.
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *testClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *testClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// teeNotifier passes the roomserver's emit stage on to the syncapi and,
|
||||
// once it is built, to the delayed event service, which needs the
|
||||
// roomserver to exist first.
|
||||
type teeNotifier struct {
|
||||
first roomserver.Notifier
|
||||
then roomserver.Notifier
|
||||
}
|
||||
|
||||
func (n *teeNotifier) EventPersisted(ctx context.Context, note roomserver.Notification) {
|
||||
n.first.EventPersisted(ctx, note)
|
||||
if n.then != nil {
|
||||
n.then.EventPersisted(ctx, note)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServer builds a server. tweak adjusts the options before the
|
||||
@@ -91,13 +133,14 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
// stage and the syncapi's wake-up are the same object, exactly as in
|
||||
// a real server.
|
||||
notifier := syncapi.NewNotifier()
|
||||
emit := &teeNotifier{first: notifier}
|
||||
rooms, err := roomserver.New(roomserver.Options{
|
||||
ServerName: testServerName,
|
||||
Store: db.Rooms(),
|
||||
Profiles: users,
|
||||
SigningKey: key,
|
||||
FederationEnabled: settingsService.FederationEnabled,
|
||||
Notifier: notifier,
|
||||
Notifier: emit,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -121,14 +164,34 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
}
|
||||
t.Cleanup(sync.Close)
|
||||
|
||||
// Delayed events are on, with the scheduler left stopped: a test
|
||||
// moves the clock and calls SendDue itself.
|
||||
clock := &testClock{now: time.Now()}
|
||||
delayed, err := delayedevents.New(delayedevents.Options{
|
||||
Store: db.DelayedEvents(),
|
||||
Rooms: rooms,
|
||||
MaxDelay: time.Hour,
|
||||
MaxPerUser: 5,
|
||||
DescribeError: clientapi.DescribeRoomError,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
Now: clock.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delayedevents.New: %v", err)
|
||||
}
|
||||
t.Cleanup(delayed.Close)
|
||||
emit.then = delayed
|
||||
|
||||
opts := clientapi.Options{
|
||||
ServerName: testServerName,
|
||||
Users: users,
|
||||
Rooms: rooms,
|
||||
Sync: sync,
|
||||
Settings: settingsService,
|
||||
Federation: gate,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
ServerName: testServerName,
|
||||
Users: users,
|
||||
Rooms: rooms,
|
||||
Sync: sync,
|
||||
Settings: settingsService,
|
||||
Federation: gate,
|
||||
DelayedEvents: delayed,
|
||||
OpenIDTokens: db.OpenIDTokens(),
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
if tweak != nil {
|
||||
tweak(&opts)
|
||||
@@ -150,6 +213,8 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
settings: settingsService,
|
||||
fedGate: gate,
|
||||
db: db,
|
||||
delayed: delayed,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// MSC4140 is still unstable, so its endpoints and parameters carry its
|
||||
// prefix.
|
||||
const (
|
||||
delayedEventsPrefix = "/_matrix/client/unstable/org.matrix.msc4140"
|
||||
delayParam = "org.matrix.msc4140.delay"
|
||||
parentDelayParam = "org.matrix.msc4140.parent_delay_id"
|
||||
)
|
||||
|
||||
// registerDelayedEventRoutes adds the MSC4140 endpoints, if delayed events
|
||||
// are switched on.
|
||||
//
|
||||
// The action endpoints are public: the delay ID is the capability, which is
|
||||
// what lets a client hand its delayed leave to something else to keep alive.
|
||||
// Both spellings are served — the action in the path, which is the current
|
||||
// draft, and in the body, which clients fall back to.
|
||||
func (c *ClientAPI) registerDelayedEventRoutes(r *httputil.Router) {
|
||||
if !c.delayed.Enabled() || c.rooms == nil {
|
||||
return
|
||||
}
|
||||
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events", c.limitDefault, c.handleGetDelayedEvents)
|
||||
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleGetDelayedEvent)
|
||||
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleUpdateDelayedEvent)
|
||||
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}/{action}", c.limitDefault, c.handleUpdateDelayedEvent)
|
||||
}
|
||||
|
||||
// delayRequested reads the delay parameter of a send or state PUT. ok is
|
||||
// false when there is none, and the event is sent at once as usual.
|
||||
func (c *ClientAPI) delayRequested(r *http.Request) (time.Duration, bool, error) {
|
||||
q := r.URL.Query()
|
||||
if q.Has(parentDelayParam) {
|
||||
return 0, false, httputil.InvalidParam("%s is not supported", parentDelayParam)
|
||||
}
|
||||
if !q.Has(delayParam) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if !c.delayed.Enabled() {
|
||||
// Refused rather than ignored: sending a delayed "left the call"
|
||||
// at once would be exactly the wrong thing.
|
||||
return 0, false, httputil.InvalidParam("Delayed events are not enabled on this server")
|
||||
}
|
||||
ms, err := strconv.ParseInt(q.Get(delayParam), 10, 64)
|
||||
if err != nil || ms < 0 {
|
||||
return 0, false, httputil.InvalidParam("%s must be a non-negative number of milliseconds", delayParam)
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond, true, nil
|
||||
}
|
||||
|
||||
// delayIDResponse answers a delayed send.
|
||||
type delayIDResponse struct {
|
||||
DelayID string `json:"delay_id"`
|
||||
}
|
||||
|
||||
// scheduleDelayed schedules what a send or state PUT asked to delay.
|
||||
func (c *ClientAPI) scheduleDelayed(w http.ResponseWriter, r *http.Request, req delayedevents.ScheduleRequest) error {
|
||||
delayID, err := c.delayed.Schedule(r.Context(), req)
|
||||
if err != nil {
|
||||
return delayedError(err, c.delayed.MaxDelay())
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, delayIDResponse{DelayID: delayID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// delayedError maps the service's errors onto Matrix errors. An overlong
|
||||
// delay is reported the way Synapse does: M_UNKNOWN for a client that knows
|
||||
// nothing of MSC4140, with the MSC's own code and the maximum alongside.
|
||||
func delayedError(err error, maxDelay time.Duration) error {
|
||||
switch {
|
||||
case errors.Is(err, delayedevents.ErrMaxDelayExceeded):
|
||||
merr := httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnknown, "%s", err.Error())
|
||||
merr.DelayedErrCode = "M_MAX_DELAY_EXCEEDED"
|
||||
merr.MaxDelayMS = maxDelay.Milliseconds()
|
||||
return merr
|
||||
case errors.Is(err, delayedevents.ErrTooMany):
|
||||
return httputil.NewError(http.StatusBadRequest, "M_LIMIT_EXCEEDED", "%s", err.Error())
|
||||
case errors.Is(err, delayedevents.ErrDisabled):
|
||||
return httputil.Unrecognized()
|
||||
case errors.Is(err, storage.ErrNoSuchDelayedEvent):
|
||||
return httputil.NotFound("No such scheduled delayed event")
|
||||
default:
|
||||
return roomError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// delayedEventJSON is one delayed event as MSC4140 lists it.
|
||||
type delayedEventJSON struct {
|
||||
DelayID string `json:"delay_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
Type string `json:"type"`
|
||||
StateKey *string `json:"state_key,omitempty"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Delay int64 `json:"delay"`
|
||||
RunningSince int64 `json:"running_since"`
|
||||
}
|
||||
|
||||
type finalisedDelayedEventJSON struct {
|
||||
DelayedEvent delayedEventJSON `json:"delayed_event"`
|
||||
Outcome string `json:"outcome"`
|
||||
Reason string `json:"reason"`
|
||||
Error json.RawMessage `json:"error,omitempty"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OriginServerTS int64 `json:"origin_server_ts,omitempty"`
|
||||
}
|
||||
|
||||
type delayedEventsResponse struct {
|
||||
Scheduled []delayedEventJSON `json:"scheduled"`
|
||||
Finalised []finalisedDelayedEventJSON `json:"finalised"`
|
||||
|
||||
// DelayedEvents is the scheduled list under the name an earlier draft
|
||||
// of MSC4140 used.
|
||||
DelayedEvents []delayedEventJSON `json:"delayed_events"`
|
||||
}
|
||||
|
||||
func toDelayedEventJSON(e storage.DelayedEvent) delayedEventJSON {
|
||||
return delayedEventJSON{
|
||||
DelayID: e.DelayID,
|
||||
RoomID: e.RoomID,
|
||||
Type: e.Type,
|
||||
StateKey: e.StateKey,
|
||||
Content: e.Content,
|
||||
Delay: e.DelayMS,
|
||||
RunningSince: e.RunningSinceMS,
|
||||
}
|
||||
}
|
||||
|
||||
func toFinalisedJSON(e storage.DelayedEvent) finalisedDelayedEventJSON {
|
||||
out := finalisedDelayedEventJSON{
|
||||
DelayedEvent: toDelayedEventJSON(e),
|
||||
Outcome: e.Outcome,
|
||||
Reason: e.Reason,
|
||||
Error: e.Error,
|
||||
EventID: e.EventID,
|
||||
}
|
||||
if e.EventID != "" {
|
||||
out.OriginServerTS = e.FinalisedAtMS
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleGetDelayedEvents implements
|
||||
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events.
|
||||
//
|
||||
// status narrows the answer to scheduled or finalised events, and delay_id,
|
||||
// which may repeat, to particular ones. Nobody has enough delayed events
|
||||
// for paging to matter, so there is never a next_batch.
|
||||
func (c *ClientAPI) handleGetDelayedEvents(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
q := r.URL.Query()
|
||||
status := q.Get("status")
|
||||
if status != "" && status != "scheduled" && status != "finalised" {
|
||||
return httputil.InvalidParam("status must be scheduled or finalised")
|
||||
}
|
||||
ids := q["delay_id"]
|
||||
|
||||
events, err := c.delayed.List(r.Context(), auth.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := delayedEventsResponse{
|
||||
Scheduled: []delayedEventJSON{},
|
||||
Finalised: []finalisedDelayedEventJSON{},
|
||||
}
|
||||
for _, e := range events {
|
||||
if len(ids) > 0 && !slices.Contains(ids, e.DelayID) {
|
||||
continue
|
||||
}
|
||||
if e.FinalisedAtMS == 0 {
|
||||
if status != "finalised" {
|
||||
resp.Scheduled = append(resp.Scheduled, toDelayedEventJSON(e))
|
||||
}
|
||||
} else if status != "scheduled" {
|
||||
resp.Finalised = append(resp.Finalised, toFinalisedJSON(e))
|
||||
}
|
||||
}
|
||||
resp.DelayedEvents = resp.Scheduled
|
||||
httputil.WriteJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetDelayedEvent implements
|
||||
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}.
|
||||
func (c *ClientAPI) handleGetDelayedEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
e, err := c.delayed.Get(r.Context(), auth.UserID, r.PathValue("delayID"))
|
||||
if err != nil {
|
||||
return delayedError(err, 0)
|
||||
}
|
||||
if e.FinalisedAtMS == 0 {
|
||||
httputil.WriteJSON(w, http.StatusOK, toDelayedEventJSON(e))
|
||||
} else {
|
||||
httputil.WriteJSON(w, http.StatusOK, toFinalisedJSON(e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type updateDelayedEventRequest struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// handleUpdateDelayedEvent implements
|
||||
// POST /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}[/{action}].
|
||||
func (c *ClientAPI) handleUpdateDelayedEvent(w http.ResponseWriter, r *http.Request) error {
|
||||
delayID := r.PathValue("delayID")
|
||||
action := r.PathValue("action")
|
||||
if action == "" {
|
||||
var req updateDelayedEventRequest
|
||||
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
action = req.Action
|
||||
}
|
||||
|
||||
var err error
|
||||
switch action {
|
||||
case "restart":
|
||||
err = c.delayed.Restart(r.Context(), delayID)
|
||||
case "cancel":
|
||||
err = c.delayed.Cancel(r.Context(), delayID)
|
||||
case "send":
|
||||
err = c.delayed.Send(r.Context(), delayID)
|
||||
case "":
|
||||
return httputil.MissingParam("An action is required")
|
||||
default:
|
||||
return httputil.InvalidParam("action must be restart, cancel or send")
|
||||
}
|
||||
if err != nil {
|
||||
return delayedError(err, 0)
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DescribeRoomError is the Matrix error a failed delayed send is recorded
|
||||
// with: the error the client would have got had it sent the event itself.
|
||||
func DescribeRoomError(err error) json.RawMessage {
|
||||
var matrixErr *httputil.MatrixError
|
||||
if !errors.As(roomError(err), &matrixErr) {
|
||||
matrixErr = httputil.NewError(http.StatusInternalServerError, httputil.ErrCodeUnknown, "%s", err.Error())
|
||||
}
|
||||
raw, _ := json.Marshal(matrixErr)
|
||||
return raw
|
||||
}
|
||||
|
||||
// Compile-time check that the roomserver can be the service's sender.
|
||||
var _ delayedevents.Sender = (*roomserver.API)(nil)
|
||||
@@ -0,0 +1,300 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
const delayedEventsPath = "/_matrix/client/unstable/org.matrix.msc4140/delayed_events"
|
||||
|
||||
// callMemberPath is where a member of a room's group call puts its state.
|
||||
func callMemberPath(roomID, stateKey string) string {
|
||||
return "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) +
|
||||
"/state/org.matrix.msc3401.call.member/" + url.PathEscape(stateKey)
|
||||
}
|
||||
|
||||
// scheduleDelayed PUTs a delayed state event and returns its delay ID.
|
||||
func (ts *testServer) scheduleDelayed(path string, delayMS int, acc account, content map[string]any) string {
|
||||
ts.t.Helper()
|
||||
rec := ts.put(path+"?org.matrix.msc4140.delay="+strconv.Itoa(delayMS), acc.AccessToken, content)
|
||||
expectStatus(ts.t, rec, http.StatusOK)
|
||||
delayID, _ := jsonBody(ts.t, rec)["delay_id"].(string)
|
||||
if delayID == "" {
|
||||
ts.t.Fatalf("a delayed send returned no delay_id: %s", rec.Body.String())
|
||||
}
|
||||
return delayID
|
||||
}
|
||||
|
||||
// tick moves the clock and sends whatever has come due.
|
||||
func (ts *testServer) tick(d time.Duration) {
|
||||
ts.t.Helper()
|
||||
ts.clock.advance(d)
|
||||
ts.delayed.SendDue(ts.t.Context())
|
||||
}
|
||||
|
||||
type delayedList struct {
|
||||
Scheduled []struct {
|
||||
DelayID string `json:"delay_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
Type string `json:"type"`
|
||||
StateKey *string `json:"state_key"`
|
||||
Content map[string]any `json:"content"`
|
||||
Delay int64 `json:"delay"`
|
||||
RunningSince int64 `json:"running_since"`
|
||||
} `json:"scheduled"`
|
||||
Finalised []struct {
|
||||
DelayedEvent struct {
|
||||
DelayID string `json:"delay_id"`
|
||||
} `json:"delayed_event"`
|
||||
Outcome string `json:"outcome"`
|
||||
Reason string `json:"reason"`
|
||||
EventID string `json:"event_id"`
|
||||
Error map[string]any `json:"error"`
|
||||
} `json:"finalised"`
|
||||
}
|
||||
|
||||
func (ts *testServer) delayedList(acc account) delayedList {
|
||||
ts.t.Helper()
|
||||
rec := ts.get(delayedEventsPath, acc.AccessToken)
|
||||
expectStatus(ts.t, rec, http.StatusOK)
|
||||
return decode[delayedList](ts.t, rec)
|
||||
}
|
||||
|
||||
// TestDelayedStateEventIsSentWhenDue is the MatrixRTC case: a member's
|
||||
// "left the call" is scheduled when it joins, and arrives on its own once
|
||||
// the delay runs out.
|
||||
func TestDelayedStateEventIsSentWhenDue(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
stateKey := "_" + alice.UserID + "_" + alice.DeviceID + "_m.call"
|
||||
path := callMemberPath(roomID, stateKey)
|
||||
|
||||
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
|
||||
|
||||
// Nothing is in the room yet.
|
||||
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
|
||||
list := ts.delayedList(alice)
|
||||
if len(list.Scheduled) != 1 || list.Scheduled[0].DelayID != delayID {
|
||||
t.Fatalf("scheduled = %+v", list.Scheduled)
|
||||
}
|
||||
got := list.Scheduled[0]
|
||||
if got.RoomID != roomID || got.Type != "org.matrix.msc3401.call.member" ||
|
||||
got.StateKey == nil || *got.StateKey != stateKey || got.Delay != 5000 {
|
||||
t.Errorf("the scheduled event is %+v", got)
|
||||
}
|
||||
|
||||
ts.tick(4 * time.Second)
|
||||
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
|
||||
ts.tick(2 * time.Second)
|
||||
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
|
||||
|
||||
list = ts.delayedList(alice)
|
||||
if len(list.Scheduled) != 0 {
|
||||
t.Errorf("still scheduled after sending: %+v", list.Scheduled)
|
||||
}
|
||||
if len(list.Finalised) != 1 || list.Finalised[0].Outcome != "send" ||
|
||||
list.Finalised[0].Reason != "delay" || list.Finalised[0].EventID == "" {
|
||||
t.Errorf("finalised = %+v", list.Finalised)
|
||||
}
|
||||
|
||||
// Looked up on its own, it is the same.
|
||||
rec := ts.get(delayedEventsPath+"/"+delayID, alice.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if jsonBody(t, rec)["outcome"] != "send" {
|
||||
t.Errorf("GET by ID = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelayedEventRestart: a client still in the call keeps pushing its
|
||||
// leave back, and it never arrives.
|
||||
func TestDelayedEventRestart(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
|
||||
|
||||
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
|
||||
|
||||
for range 3 {
|
||||
ts.tick(4 * time.Second)
|
||||
// The action endpoints need no token: the delay ID is the
|
||||
// capability.
|
||||
expectStatus(t, ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil), http.StatusOK)
|
||||
}
|
||||
ts.tick(4 * time.Second)
|
||||
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
|
||||
ts.tick(2 * time.Second)
|
||||
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
|
||||
|
||||
// Once it has been sent it cannot be restarted.
|
||||
rec := ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil)
|
||||
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
}
|
||||
|
||||
// TestDelayedEventActions covers cancel and send, in both the path and the
|
||||
// body spelling.
|
||||
func TestDelayedEventActions(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
escaped := url.PathEscape(roomID)
|
||||
|
||||
// Cancelled with the action in the body: never sent.
|
||||
topic := "/_matrix/client/v3/rooms/" + escaped + "/state/m.room.topic"
|
||||
cancelled := ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "never"})
|
||||
expectStatus(t, ts.post(delayedEventsPath+"/"+cancelled, alice.AccessToken,
|
||||
map[string]any{"action": "cancel"}), http.StatusOK)
|
||||
ts.tick(2 * time.Second)
|
||||
expectError(t, ts.get(topic, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
|
||||
// Sent at once with the action in the path: a timeline event, which
|
||||
// is how a client sends a message it held back.
|
||||
send := "/_matrix/client/v3/rooms/" + escaped + "/send/m.room.message/txn1"
|
||||
sent := ts.scheduleDelayed(send, 60000, alice, map[string]any{"msgtype": "m.text", "body": "later"})
|
||||
expectStatus(t, ts.post(delayedEventsPath+"/"+sent+"/send", "", nil), http.StatusOK)
|
||||
|
||||
list := ts.delayedList(alice)
|
||||
if len(list.Scheduled) != 0 || len(list.Finalised) != 2 {
|
||||
t.Fatalf("list = %+v", list)
|
||||
}
|
||||
outcomes := map[string]string{}
|
||||
for _, f := range list.Finalised {
|
||||
outcomes[f.DelayedEvent.DelayID] = f.Outcome + "/" + f.Reason
|
||||
}
|
||||
if outcomes[cancelled] != "cancel/action" || outcomes[sent] != "send/action" {
|
||||
t.Errorf("outcomes = %v", outcomes)
|
||||
}
|
||||
|
||||
// status narrows the list.
|
||||
rec := ts.get(delayedEventsPath+"?status=scheduled", alice.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if l := decode[delayedList](t, rec); len(l.Finalised) != 0 {
|
||||
t.Errorf("status=scheduled listed finalised events: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
expectError(t, ts.post(delayedEventsPath+"/nope/cancel", "", nil), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
expectError(t, ts.post(delayedEventsPath+"/"+sent+"/explode", "", nil),
|
||||
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
||||
}
|
||||
|
||||
// TestDelayedEventFailureIsRecorded: an event that can no longer be sent
|
||||
// is finalised with the error the client would have got.
|
||||
func TestDelayedEventFailureIsRecorded(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
|
||||
|
||||
ts.scheduleDelayed(path, 1000, alice, map[string]any{})
|
||||
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave",
|
||||
alice.AccessToken, map[string]any{}), http.StatusOK)
|
||||
ts.tick(2 * time.Second)
|
||||
|
||||
list := ts.delayedList(alice)
|
||||
if len(list.Finalised) != 1 || list.Finalised[0].Reason != "error" ||
|
||||
list.Finalised[0].Error["errcode"] != httputil.ErrCodeForbidden {
|
||||
t.Errorf("finalised = %+v", list.Finalised)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelayedStateIsCancelledByOthers: someone else setting the same state
|
||||
// cancels a delayed event that would have overwritten it, and the owner's
|
||||
// own updates do not. It also proves an ordinary member may join a call.
|
||||
func TestDelayedStateIsCancelledByOthers(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
bob := ts.register("bob", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
|
||||
bob.AccessToken, map[string]any{}), http.StatusOK)
|
||||
|
||||
aliceKey := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
|
||||
ts.scheduleDelayed(aliceKey, 5000, alice, map[string]any{})
|
||||
|
||||
// Alice refreshing her own membership leaves the delayed leave alone.
|
||||
expectStatus(t, ts.put(aliceKey, alice.AccessToken, map[string]any{"application": "m.call"}), http.StatusOK)
|
||||
// Bob, an ordinary member, may put call state of his own.
|
||||
expectStatus(t, ts.put(callMemberPath(roomID, "_"+bob.UserID+"_B_m.call"), bob.AccessToken,
|
||||
map[string]any{"application": "m.call"}), http.StatusOK)
|
||||
// The cancellation runs off the emit stage, so give it a moment.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if l := ts.delayedList(alice); len(l.Scheduled) != 1 {
|
||||
t.Fatalf("alice's own update cancelled her delayed event: %+v", l)
|
||||
}
|
||||
|
||||
// Bob writing the same piece of state cancels it.
|
||||
expectStatus(t, ts.put(aliceKey, bob.AccessToken, map[string]any{}), http.StatusOK)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
l := ts.delayedList(alice)
|
||||
if len(l.Scheduled) == 0 {
|
||||
if len(l.Finalised) != 1 || l.Finalised[0].Outcome != "cancel" {
|
||||
t.Errorf("finalised = %+v", l.Finalised)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the overwritten delayed event was never cancelled")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedEventLimits(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
topic := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.topic"
|
||||
|
||||
// Longer than the server allows: the MSC's own code and the maximum
|
||||
// come with the error.
|
||||
rec := ts.put(topic+"?org.matrix.msc4140.delay=7200000", alice.AccessToken, map[string]any{"topic": "x"})
|
||||
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown)
|
||||
body := jsonBody(t, rec)
|
||||
if body["org.matrix.msc4140.errcode"] != "M_MAX_DELAY_EXCEEDED" || body["org.matrix.msc4140.max_delay"] != float64(3600000) {
|
||||
t.Errorf("error = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
expectError(t, ts.put(topic+"?org.matrix.msc4140.delay=soon", alice.AccessToken, map[string]any{"topic": "x"}),
|
||||
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
||||
expectError(t, ts.put(topic+"?org.matrix.msc4140.parent_delay_id=x", alice.AccessToken, map[string]any{"topic": "x"}),
|
||||
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
||||
|
||||
// Five at once is the test server's limit.
|
||||
for range 5 {
|
||||
ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "x"})
|
||||
}
|
||||
rec = ts.put(topic+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{"topic": "x"})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_LIMIT_EXCEEDED")
|
||||
|
||||
// Nobody else can read them.
|
||||
bob := ts.register("bob", "correct-horse-battery-staple")
|
||||
if l := ts.delayedList(bob); len(l.Scheduled) != 0 {
|
||||
t.Errorf("bob sees alice's delayed events: %+v", l)
|
||||
}
|
||||
expectError(t, ts.get(delayedEventsPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
}
|
||||
|
||||
// TestDelayedEventsDisabled: without the service the parameter is refused,
|
||||
// rather than the event being sent at once, and the endpoints are absent.
|
||||
func TestDelayedEventsDisabled(t *testing.T) {
|
||||
ts := newTestServer(t, func(o *clientapi.Options) { o.DelayedEvents = nil })
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
||||
path := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
|
||||
|
||||
expectError(t, ts.put(path+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{}),
|
||||
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
||||
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
||||
expectError(t, ts.get(delayedEventsPath, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeUnrecognized)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// openIDTokenLifetime is how long an OpenID token is good for. The LiveKit
|
||||
// JWT service checks it once, when a call is joined, so an hour is plenty.
|
||||
const openIDTokenLifetime = time.Hour
|
||||
|
||||
// openIDTokenPrefix marks OpenID tokens the way the userapi marks access
|
||||
// tokens, so one handed to the wrong endpoint is recognisable.
|
||||
const openIDTokenPrefix = "warp_oi_"
|
||||
|
||||
// openIDUserInfoPath is where a third party exchanges an OpenID token for
|
||||
// the user's ID. It is a federation endpoint by name, but needs neither
|
||||
// federation nor a signing key: Synapse serves it as a resource of its own
|
||||
// for the same reason. It is registered here so that a server with
|
||||
// federation off can still let its users into a group call.
|
||||
const openIDUserInfoPath = "/_matrix/federation/v1/openid/userinfo"
|
||||
|
||||
// registerOpenIDRoutes adds the OpenID endpoints, if there is a store for
|
||||
// the tokens.
|
||||
func (c *ClientAPI) registerOpenIDRoutes(r *httputil.Router) {
|
||||
if c.openID == nil {
|
||||
return
|
||||
}
|
||||
c.authed(r, "POST "+v3+"/user/{userID}/openid/request_token", c.limitDefault, c.handleRequestOpenIDToken)
|
||||
c.public(r, "GET "+openIDUserInfoPath, c.limitDefault, c.handleOpenIDUserInfo)
|
||||
}
|
||||
|
||||
type openIDTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
MatrixServerName string `json:"matrix_server_name"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// handleRequestOpenIDToken implements
|
||||
// POST /_matrix/client/v3/user/{userID}/openid/request_token.
|
||||
//
|
||||
// The token proves who the user is to whoever they hand it to, and nothing
|
||||
// else: it is not an access token and opens no other endpoint.
|
||||
func (c *ClientAPI) handleRequestOpenIDToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
if r.PathValue("userID") != auth.UserID {
|
||||
return httputil.Forbidden("You cannot request an OpenID token for another user")
|
||||
}
|
||||
// The body is an empty object; anything in it is ignored.
|
||||
var ignored map[string]any
|
||||
if _, err := httputil.DecodeOptionalJSON(w, r, &ignored); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("generate OpenID token: %w", err)
|
||||
}
|
||||
token := openIDTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
||||
now := c.now()
|
||||
// Expired tokens go as new ones are made; nothing else reads them.
|
||||
if err := c.openID.PurgeOpenIDTokens(r.Context(), now.UnixMilli()); err != nil {
|
||||
c.log.Warn("could not purge expired OpenID tokens", "error", err)
|
||||
}
|
||||
if err := c.openID.InsertOpenIDToken(r.Context(), userapi.HashToken(token), auth.UserID,
|
||||
now.Add(openIDTokenLifetime).UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, openIDTokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
MatrixServerName: c.serverName,
|
||||
ExpiresIn: int64(openIDTokenLifetime.Seconds()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type openIDUserInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// handleOpenIDUserInfo implements
|
||||
// GET /_matrix/federation/v1/openid/userinfo.
|
||||
func (c *ClientAPI) handleOpenIDUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
token := r.URL.Query().Get("access_token")
|
||||
if token == "" {
|
||||
return httputil.MissingToken()
|
||||
}
|
||||
userID, err := c.openID.OpenIDTokenUser(r.Context(), userapi.HashToken(token), c.now().UnixMilli())
|
||||
if errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
return httputil.UnknownToken(false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, openIDUserInfoResponse{Sub: userID})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
// TestOpenIDToken is the exchange the LiveKit JWT service relies on: a user
|
||||
// asks for a token, hands it over, and the service asks this server who it
|
||||
// belongs to.
|
||||
func TestOpenIDToken(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
bob := ts.register("bob", "correct-horse-battery-staple")
|
||||
path := "/_matrix/client/v3/user/" + url.PathEscape(alice.UserID) + "/openid/request_token"
|
||||
|
||||
rec := ts.post(path, alice.AccessToken, map[string]any{})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
body := jsonBody(t, rec)
|
||||
token, _ := body["access_token"].(string)
|
||||
if token == "" || body["token_type"] != "Bearer" || body["matrix_server_name"] != testServerName ||
|
||||
body["expires_in"] != float64(3600) {
|
||||
t.Fatalf("request_token = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// The token is no access token.
|
||||
expectError(t, ts.get("/_matrix/client/v3/account/whoami", token), http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
|
||||
|
||||
rec = ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), "")
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["sub"]; got != alice.UserID {
|
||||
t.Errorf("sub = %v, want %s", got, alice.UserID)
|
||||
}
|
||||
|
||||
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token=nope", ""),
|
||||
http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
|
||||
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo", ""),
|
||||
http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
|
||||
// Nobody gets a token in someone else's name.
|
||||
expectError(t, ts.post(path, bob.AccessToken, map[string]any{}), http.StatusForbidden, httputil.ErrCodeForbidden)
|
||||
}
|
||||
|
||||
// TestOpenIDUserInfoIgnoresFederationSwitch: the userinfo endpoint answers
|
||||
// with federation off, so a server that does not federate can still let its
|
||||
// users into group calls.
|
||||
func TestOpenIDUserInfoIgnoresFederationSwitch(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
if ts.settings.FederationEnabled() {
|
||||
t.Fatal("the test server is expected to start with federation off")
|
||||
}
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
rec := ts.post("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/openid/request_token",
|
||||
alice.AccessToken, nil)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
token, _ := jsonBody(t, rec)["access_token"].(string)
|
||||
expectStatus(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), ""), http.StatusOK)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"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"
|
||||
@@ -155,10 +156,20 @@ func (c *ClientAPI) handleSendEvent(w http.ResponseWriter, r *http.Request, auth
|
||||
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,
|
||||
@@ -191,10 +202,20 @@ func (c *ClientAPI) handlePutState(w http.ResponseWriter, r *http.Request, auth
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// rtcTransportsPath is MSC4143's endpoint for the group call backends a
|
||||
// client may use: the same list /.well-known/matrix/client carries as
|
||||
// org.matrix.msc4143.rtc_foci, behind authentication and without needing
|
||||
// the discovery document.
|
||||
const rtcTransportsPath = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
|
||||
|
||||
// RTCTransport is one group call backend: a LiveKit SFU, named by the JWT
|
||||
// service that hands out tokens for it.
|
||||
type RTCTransport struct {
|
||||
Type string `json:"type"`
|
||||
LiveKitServiceURL string `json:"livekit_service_url"`
|
||||
}
|
||||
|
||||
// RTCTransports lists the configured group call backends, or nil for none.
|
||||
func RTCTransports(liveKitServiceURL string) []RTCTransport {
|
||||
if liveKitServiceURL == "" {
|
||||
return nil
|
||||
}
|
||||
return []RTCTransport{{Type: "livekit", LiveKitServiceURL: strings.TrimRight(liveKitServiceURL, "/")}}
|
||||
}
|
||||
|
||||
type rtcTransportsResponse struct {
|
||||
Transports []RTCTransport `json:"rtc_transports"`
|
||||
}
|
||||
|
||||
// registerRTCRoutes adds the MSC4143 endpoint, if a backend is configured.
|
||||
// Without one it is left unregistered: the 404 is how a client learns there
|
||||
// is none.
|
||||
func (c *ClientAPI) registerRTCRoutes(r *httputil.Router) {
|
||||
if len(c.rtcTransports) == 0 {
|
||||
return
|
||||
}
|
||||
c.authed(r, "GET "+rtcTransportsPath, c.limitDefault, c.handleRTCTransports)
|
||||
}
|
||||
|
||||
// handleRTCTransports implements
|
||||
// GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports.
|
||||
func (c *ClientAPI) handleRTCTransports(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
|
||||
httputil.WriteJSON(w, http.StatusOK, rtcTransportsResponse{Transports: c.rtcTransports})
|
||||
return nil
|
||||
}
|
||||
@@ -87,3 +87,27 @@ func TestTurnServerStatic(t *testing.T) {
|
||||
t.Errorf("response = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTCTransports(t *testing.T) {
|
||||
const path = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.RTC = config.RTC{LiveKitServiceURL: "https://livekit-jwt.warp.example/"}
|
||||
})
|
||||
acc := ts.register("alice", "correct horse battery")
|
||||
expectError(t, ts.get(path, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
|
||||
rec := ts.get(path, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
got := decode[struct {
|
||||
Transports []map[string]string `json:"rtc_transports"`
|
||||
}](t, rec)
|
||||
if len(got.Transports) != 1 || got.Transports[0]["type"] != "livekit" ||
|
||||
got.Transports[0]["livekit_service_url"] != "https://livekit-jwt.warp.example" {
|
||||
t.Errorf("rtc_transports = %v", got.Transports)
|
||||
}
|
||||
|
||||
// Without a backend there is no endpoint, which is how a client knows.
|
||||
ts = newTestServer(t, nil)
|
||||
acc = ts.register("alice", "correct horse battery")
|
||||
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_UNRECOGNIZED")
|
||||
}
|
||||
Reference in new issue
Block a user