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")
|
||||
}
|
||||
@@ -182,6 +182,39 @@ type ClientAPI struct {
|
||||
// TURN is what GET /voip/turnServer hands clients for voice and video
|
||||
// calls.
|
||||
TURN TURN `yaml:"turn"`
|
||||
|
||||
// RTC is the media backend for group calls (MatrixRTC), advertised in
|
||||
// /.well-known/matrix/client.
|
||||
RTC RTC `yaml:"rtc"`
|
||||
|
||||
// DelayedEvents bounds the delayed events (MSC4140) a client may
|
||||
// schedule. Group calls depend on them: a member's "I left" is
|
||||
// scheduled in advance, so a client that disappears still leaves.
|
||||
DelayedEvents DelayedEvents `yaml:"delayed_events"`
|
||||
}
|
||||
|
||||
// RTC configures group calls. Warp does not carry call media itself:
|
||||
// MatrixRTC clients publish to a LiveKit SFU, and get the token for it from
|
||||
// a LiveKit JWT service (lk-jwt-service) in exchange for an OpenID token
|
||||
// from this server. With no service URL no focus is advertised, and clients
|
||||
// that need one cannot start a group call.
|
||||
type RTC struct {
|
||||
// LiveKitServiceURL is the base URL of the LiveKit JWT service, e.g.
|
||||
// "https://livekit-jwt.example.org". It is advertised as an
|
||||
// org.matrix.msc4143.rtc_foci entry of type livekit.
|
||||
LiveKitServiceURL string `yaml:"livekit_service_url" env:"WARP_RTC_LIVEKIT_SERVICE_URL"`
|
||||
}
|
||||
|
||||
// DelayedEvents configures MSC4140 delayed events.
|
||||
type DelayedEvents struct {
|
||||
// MaxDelay is the longest delay a client may ask for. Zero turns
|
||||
// delayed events off: the delay parameter is refused and the feature
|
||||
// is not advertised.
|
||||
MaxDelay time.Duration `yaml:"max_delay" env:"WARP_DELAYED_EVENTS_MAX_DELAY"`
|
||||
|
||||
// MaxPerUser caps how many delayed events one user may have scheduled
|
||||
// at once.
|
||||
MaxPerUser int `yaml:"max_per_user" env:"WARP_DELAYED_EVENTS_MAX_PER_USER"`
|
||||
}
|
||||
|
||||
// TURN configures the relay servers clients use to set up calls. With no
|
||||
@@ -531,6 +564,10 @@ func Default() *Config {
|
||||
Media: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
||||
},
|
||||
TURN: TURN{UserLifetime: time.Hour},
|
||||
// A MatrixRTC client restarts its delayed leave every few
|
||||
// seconds; a day leaves room for other uses without letting a
|
||||
// forgotten event linger for weeks.
|
||||
DelayedEvents: DelayedEvents{MaxDelay: 24 * time.Hour, MaxPerUser: 100},
|
||||
},
|
||||
Sync: Sync{
|
||||
Presence: false,
|
||||
@@ -714,6 +751,17 @@ func (c *Config) Validate() error {
|
||||
if c.ClientAPI.TURN.SharedSecret != "" && c.ClientAPI.TURN.UserLifetime <= 0 {
|
||||
bad("client_api.turn.user_lifetime: must be positive")
|
||||
}
|
||||
if raw := c.ClientAPI.RTC.LiveKitServiceURL; raw != "" {
|
||||
if u, err := url.Parse(raw); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
bad("client_api.rtc.livekit_service_url: must be an http(s) URL, got %q", raw)
|
||||
}
|
||||
}
|
||||
if c.ClientAPI.DelayedEvents.MaxDelay < 0 {
|
||||
bad("client_api.delayed_events.max_delay: must not be negative")
|
||||
}
|
||||
if c.ClientAPI.DelayedEvents.MaxDelay > 0 && c.ClientAPI.DelayedEvents.MaxPerUser < 1 {
|
||||
bad("client_api.delayed_events.max_per_user: must be at least 1")
|
||||
}
|
||||
if c.Sync.TypingTimeout <= 0 {
|
||||
bad("sync.typing_timeout: must be positive")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// Package delayedevents implements MSC4140 delayed events: an event a client
|
||||
// hands the server now, to be sent later on the client's behalf unless the
|
||||
// client pushes it back or cancels it first.
|
||||
//
|
||||
// It is what makes MatrixRTC group calls robust. A member joining a call
|
||||
// sends its call.member state, and schedules an empty one to replace it a
|
||||
// few seconds out; while it is in the call it keeps restarting that delay.
|
||||
// A client that crashes, loses its network or is closed stops restarting
|
||||
// it, and the server sends the "left" for it.
|
||||
//
|
||||
// Events are kept in the database, so a restart of the server does not
|
||||
// lose them, and are sent by a single scheduler goroutine that sleeps until
|
||||
// the next one is due.
|
||||
package delayedevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Outcomes and reasons a finalised event is reported with, as MSC4140
|
||||
// names them.
|
||||
const (
|
||||
OutcomeSend = "send"
|
||||
OutcomeCancel = "cancel"
|
||||
|
||||
ReasonDelay = "delay"
|
||||
ReasonAction = "action"
|
||||
ReasonError = "error"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDisabled is returned when delayed events are switched off.
|
||||
ErrDisabled = errors.New("delayed events are disabled")
|
||||
|
||||
// ErrMaxDelayExceeded is returned for a delay longer than the
|
||||
// configured maximum.
|
||||
ErrMaxDelayExceeded = errors.New("the requested delay exceeds the allowed maximum")
|
||||
|
||||
// ErrTooMany is returned when the user already has as many delayed
|
||||
// events scheduled as they may.
|
||||
ErrTooMany = errors.New("too many delayed events are scheduled")
|
||||
|
||||
// ErrNoSuchDelayedEvent is returned for a delay ID that is unknown or
|
||||
// no longer scheduled.
|
||||
ErrNoSuchDelayedEvent = storage.ErrNoSuchDelayedEvent
|
||||
)
|
||||
|
||||
// finalisedRetention is how long a sent or cancelled event is kept for a
|
||||
// client to look up.
|
||||
const finalisedRetention = 24 * time.Hour
|
||||
|
||||
// idleCheck is how long the scheduler sleeps with nothing scheduled. It
|
||||
// also paces the purge of finalised events.
|
||||
const idleCheck = time.Hour
|
||||
|
||||
// dueBatch is how many due events are read at once.
|
||||
const dueBatch = 100
|
||||
|
||||
// Sender sends an event into a room as a user: the roomserver.
|
||||
type Sender interface {
|
||||
SendEvent(ctx context.Context, req roomserver.SendEventRequest) (string, error)
|
||||
}
|
||||
|
||||
// Options configure the service.
|
||||
type Options struct {
|
||||
Store storage.DelayedEventStore
|
||||
Rooms Sender
|
||||
|
||||
// MaxDelay is the longest delay allowed. Zero disables the service.
|
||||
MaxDelay time.Duration
|
||||
|
||||
// MaxPerUser caps the events one user may have scheduled.
|
||||
MaxPerUser int
|
||||
|
||||
// DescribeError turns the error a send failed with into the Matrix
|
||||
// error a client is shown. Nil reports every failure as M_UNKNOWN.
|
||||
DescribeError func(error) json.RawMessage
|
||||
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Service schedules and sends delayed events.
|
||||
type Service struct {
|
||||
store storage.DelayedEventStore
|
||||
rooms Sender
|
||||
maxDelay time.Duration
|
||||
maxPerUser int
|
||||
describeError func(error) json.RawMessage
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
// mu serialises everything that finalises or moves an event, so the
|
||||
// scheduler sending an event and its owner restarting or cancelling
|
||||
// it cannot both win.
|
||||
mu sync.Mutex
|
||||
|
||||
wake chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
started bool
|
||||
closing sync.Once
|
||||
|
||||
// cancels tracks the background cancellations the emit stage starts,
|
||||
// so Close can wait for them.
|
||||
cancels sync.WaitGroup
|
||||
}
|
||||
|
||||
// New builds the service. It does not start the scheduler.
|
||||
func New(opts Options) (*Service, error) {
|
||||
if opts.Store == nil {
|
||||
return nil, errors.New("delayedevents: Store must not be nil")
|
||||
}
|
||||
if opts.Rooms == nil {
|
||||
return nil, errors.New("delayedevents: Rooms must not be nil")
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.DescribeError == nil {
|
||||
opts.DescribeError = func(err error) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]string{"errcode": "M_UNKNOWN", "error": err.Error()})
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return &Service{
|
||||
store: opts.Store,
|
||||
rooms: opts.Rooms,
|
||||
maxDelay: opts.MaxDelay,
|
||||
maxPerUser: opts.MaxPerUser,
|
||||
describeError: opts.DescribeError,
|
||||
log: opts.Logger.With("component", "delayedevents"),
|
||||
now: opts.Now,
|
||||
wake: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether clients may schedule delayed events.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.maxDelay > 0 }
|
||||
|
||||
// MaxDelay is the longest delay a client may ask for.
|
||||
func (s *Service) MaxDelay() time.Duration { return s.maxDelay }
|
||||
|
||||
// ScheduleRequest is an event to send later.
|
||||
type ScheduleRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
RoomID string
|
||||
Type string
|
||||
|
||||
// StateKey makes this a state event when it is non-nil.
|
||||
StateKey *string
|
||||
Content json.RawMessage
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
// Schedule stores an event to be sent after req.Delay and returns its
|
||||
// delay ID.
|
||||
func (s *Service) Schedule(ctx context.Context, req ScheduleRequest) (string, error) {
|
||||
if !s.Enabled() {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
if req.Delay < 0 {
|
||||
return "", fmt.Errorf("%w: the delay must not be negative", roomserver.ErrInvalidContent)
|
||||
}
|
||||
if req.Delay > s.maxDelay {
|
||||
return "", ErrMaxDelayExceeded
|
||||
}
|
||||
n, err := s.store.CountScheduledDelayedEvents(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n >= s.maxPerUser {
|
||||
return "", ErrTooMany
|
||||
}
|
||||
delayID, err := newDelayID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
delayMS := req.Delay.Milliseconds()
|
||||
if err := s.store.InsertDelayedEvent(ctx, storage.DelayedEvent{
|
||||
DelayID: delayID,
|
||||
UserID: req.UserID,
|
||||
DeviceID: req.DeviceID,
|
||||
RoomID: req.RoomID,
|
||||
Type: req.Type,
|
||||
StateKey: req.StateKey,
|
||||
Content: req.Content,
|
||||
DelayMS: delayMS,
|
||||
RunningSinceMS: nowMS,
|
||||
SendAtMS: nowMS + delayMS,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.poke()
|
||||
return delayID, nil
|
||||
}
|
||||
|
||||
// Get returns one of a user's delayed events, scheduled or finalised.
|
||||
func (s *Service) Get(ctx context.Context, userID, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.store.DelayedEvent(ctx, userID, delayID)
|
||||
}
|
||||
|
||||
// List returns a user's delayed events.
|
||||
func (s *Service) List(ctx context.Context, userID string) ([]storage.DelayedEvent, error) {
|
||||
return s.store.DelayedEvents(ctx, userID)
|
||||
}
|
||||
|
||||
// Restart starts a scheduled event's delay again from now.
|
||||
//
|
||||
// The action endpoints may be called without an access token: the delay
|
||||
// ID is the capability. That is what lets a MatrixRTC client hand its
|
||||
// delayed leave to a service that keeps it alive on its behalf.
|
||||
func (s *Service) Restart(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.store.RestartDelayedEvent(ctx, delayID, s.now().UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.poke()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel drops a scheduled event without sending it.
|
||||
func (s *Service) Cancel(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.finalise(ctx, e, OutcomeCancel, ReasonAction, "", nil)
|
||||
}
|
||||
|
||||
// Send sends a scheduled event now, without waiting for its delay. The
|
||||
// error of a failed send is returned as well as recorded.
|
||||
func (s *Service) Send(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendLocked(ctx, e, ReasonAction)
|
||||
}
|
||||
|
||||
// sendLocked sends e and records the outcome. s.mu must be held.
|
||||
func (s *Service) sendLocked(ctx context.Context, e storage.DelayedEvent, reason string) error {
|
||||
eventID, sendErr := s.rooms.SendEvent(ctx, roomserver.SendEventRequest{
|
||||
RoomID: e.RoomID,
|
||||
Sender: e.UserID,
|
||||
Type: e.Type,
|
||||
StateKey: e.StateKey,
|
||||
Content: e.Content,
|
||||
})
|
||||
if sendErr != nil {
|
||||
if err := s.finalise(ctx, e, OutcomeSend, ReasonError, "", s.describeError(sendErr)); err != nil {
|
||||
return err
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
return s.finalise(ctx, e, OutcomeSend, reason, eventID, nil)
|
||||
}
|
||||
|
||||
func (s *Service) finalise(ctx context.Context, e storage.DelayedEvent, outcome, reason, eventID string, errBody json.RawMessage) error {
|
||||
e.FinalisedAtMS = s.now().UnixMilli()
|
||||
e.Outcome = outcome
|
||||
e.Reason = reason
|
||||
e.EventID = eventID
|
||||
e.Error = errBody
|
||||
return s.store.FinaliseDelayedEvent(ctx, e)
|
||||
}
|
||||
|
||||
// EventPersisted implements roomserver.Notifier.
|
||||
//
|
||||
// When someone else sets a piece of state, the delayed state events that
|
||||
// would overwrite it are cancelled: a delayed event stands for "put this
|
||||
// back if I stop answering", and must not clobber a change another user
|
||||
// made in the meantime. The owner's own updates leave them alone, which is
|
||||
// what lets a call member refresh its membership while its delayed leave
|
||||
// keeps running.
|
||||
//
|
||||
// The emit stage must not block, so the lookup runs in the background.
|
||||
func (s *Service) EventPersisted(_ context.Context, n roomserver.Notification) {
|
||||
if !s.Enabled() || n.Event == nil || !n.StateChanged {
|
||||
return
|
||||
}
|
||||
key, isState := n.Event.StateKey()
|
||||
if !isState {
|
||||
return
|
||||
}
|
||||
roomID, eventType, sender := n.RoomID, n.Event.Type(), n.Event.Sender()
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.cancels.Add(1)
|
||||
go func() {
|
||||
defer s.cancels.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.cancelOverwritten(ctx, roomID, eventType, key, sender)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) cancelOverwritten(ctx context.Context, roomID, eventType, stateKey, sender string) {
|
||||
pending, err := s.store.ScheduledDelayedStateEvents(ctx, roomID, eventType, stateKey)
|
||||
if err != nil {
|
||||
s.log.Warn("could not look up delayed state events", "room_id", roomID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, e := range pending {
|
||||
if e.UserID == sender {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
err := s.finalise(ctx, e, OutcomeCancel, ReasonAction, "", nil)
|
||||
s.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
s.log.Warn("could not cancel an overwritten delayed event", "delay_id", e.DelayID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the scheduler until ctx is cancelled or Close is called.
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
if !s.Enabled() || s.started {
|
||||
return
|
||||
}
|
||||
s.started = true
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Close stops the scheduler and waits for it.
|
||||
func (s *Service) Close() {
|
||||
s.closing.Do(func() { close(s.stop) })
|
||||
if s.started {
|
||||
<-s.done
|
||||
}
|
||||
s.cancels.Wait()
|
||||
}
|
||||
|
||||
// poke wakes the scheduler to look at the schedule again.
|
||||
func (s *Service) poke() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) run(ctx context.Context) {
|
||||
defer close(s.done)
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
lastPurge := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-s.wake:
|
||||
}
|
||||
|
||||
s.SendDue(ctx)
|
||||
if now := s.now(); now.Sub(lastPurge) >= idleCheck {
|
||||
lastPurge = now
|
||||
if err := s.store.PurgeFinalisedDelayedEvents(ctx, now.Add(-finalisedRetention).UnixMilli()); err != nil {
|
||||
s.log.Warn("could not purge finalised delayed events", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
wait := idleCheck
|
||||
next, ok, err := s.store.NextDelayedEventDue(ctx)
|
||||
switch {
|
||||
case err != nil:
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn("could not read the delayed event schedule", "error", err)
|
||||
wait = time.Second
|
||||
case ok:
|
||||
wait = min(max(time.Duration(next-s.now().UnixMilli())*time.Millisecond, 0), idleCheck)
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// SendDue sends every event whose delay has run out. The scheduler calls
|
||||
// it whenever the next event comes due; tests call it after moving the
|
||||
// clock.
|
||||
func (s *Service) SendDue(ctx context.Context) {
|
||||
for {
|
||||
due, err := s.store.DueDelayedEvents(ctx, s.now().UnixMilli(), dueBatch)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
s.log.Warn("could not read due delayed events", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, e := range due {
|
||||
s.sendIfStillDue(ctx, e.DelayID)
|
||||
}
|
||||
if len(due) < dueBatch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendIfStillDue re-reads an event under the lock, because its owner may
|
||||
// have restarted or cancelled it since the batch was read.
|
||||
func (s *Service) sendIfStillDue(ctx context.Context, delayID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if e.SendAtMS > s.now().UnixMilli() {
|
||||
return
|
||||
}
|
||||
if err := s.sendLocked(ctx, e, ReasonDelay); err != nil {
|
||||
s.log.Info("a delayed event could not be sent",
|
||||
"delay_id", e.DelayID, "room_id", e.RoomID, "type", e.Type, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newDelayID makes a delay ID. It is a capability — anyone holding it may
|
||||
// restart, cancel or send the event — so it is as long as an access token's
|
||||
// random part.
|
||||
func newDelayID() (string, error) {
|
||||
raw := make([]byte, 24)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate delay ID: %w", err)
|
||||
}
|
||||
return "warp_de_" + base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
@@ -81,6 +81,11 @@ type MatrixError struct {
|
||||
RemoteStatus int `json:"status,omitempty"`
|
||||
RemoteBody string `json:"body,omitempty"`
|
||||
|
||||
// DelayedErrCode and MaxDelayMS accompany a delayed event whose delay
|
||||
// is longer than the server allows (MSC4140).
|
||||
DelayedErrCode string `json:"org.matrix.msc4140.errcode,omitempty"`
|
||||
MaxDelayMS int64 `json:"org.matrix.msc4140.max_delay,omitempty"`
|
||||
|
||||
// status is the HTTP status to send; not part of the JSON body.
|
||||
status int
|
||||
}
|
||||
|
||||
@@ -432,6 +432,10 @@ func (r *API) powerLevelsContent(req CreateRoomRequest, preset string, rules roo
|
||||
"m.room.avatar": auth.DefaultStateDefault,
|
||||
TypeEncryption: 100,
|
||||
"m.room.server_acl": 100,
|
||||
// Joining a group call is a state event of the member's own
|
||||
// (MatrixRTC, MSC3401), so anyone who may talk in the room
|
||||
// may also join its call. Starting one is not a separate act.
|
||||
TypeCallMember: 0,
|
||||
},
|
||||
"notifications": map[string]any{"room": auth.DefaultNotification},
|
||||
}
|
||||
@@ -538,3 +542,7 @@ func mergeJSON(base, override json.RawMessage) (json.RawMessage, error) {
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
// TypeCallMember is a member's state in a room's group call (MatrixRTC).
|
||||
// It is still under its MSC3401 name, which is what clients send.
|
||||
const TypeCallMember = "org.matrix.msc3401.call.member"
|
||||
@@ -1,10 +1,12 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/tracing"
|
||||
)
|
||||
@@ -32,10 +34,19 @@ type versionsResponse struct {
|
||||
UnstableFeatures map[string]bool `json:"unstable_features,omitempty"`
|
||||
}
|
||||
|
||||
// unstableDelayedEvents is the MSC4140 feature flag. It is advertised only
|
||||
// while delayed events are switched on, because a MatrixRTC client checks
|
||||
// it before scheduling its delayed leave.
|
||||
const unstableDelayedEvents = "org.matrix.msc4140"
|
||||
|
||||
// wellKnownClient is the /.well-known/matrix/client document.
|
||||
type wellKnownClient struct {
|
||||
Homeserver wellKnownBaseURL `json:"m.homeserver"`
|
||||
IdentityServer *wellKnownBaseURL `json:"m.identity_server,omitempty"`
|
||||
|
||||
// RTCFoci are the MatrixRTC backends (MSC4143) a client may use for a
|
||||
// group call it starts.
|
||||
RTCFoci []clientapi.RTCTransport `json:"org.matrix.msc4143.rtc_foci,omitempty"`
|
||||
}
|
||||
|
||||
type wellKnownBaseURL struct {
|
||||
@@ -84,10 +95,14 @@ func (s *Server) routes() *httputil.Router {
|
||||
|
||||
// handleVersions implements GET /_matrix/client/versions.
|
||||
func (s *Server) handleVersions(w http.ResponseWriter, _ *http.Request) error {
|
||||
features := maps.Clone(UnstableFeatures)
|
||||
if s.delayed.Enabled() {
|
||||
features[unstableDelayedEvents] = true
|
||||
}
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, versionsResponse{
|
||||
Versions: SpecVersions,
|
||||
UnstableFeatures: UnstableFeatures,
|
||||
UnstableFeatures: features,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -101,6 +116,7 @@ func (s *Server) handleWellKnownClient(w http.ResponseWriter, _ *http.Request) e
|
||||
if url := s.cfg.Global.WellKnown.IdentityServerBaseURL; url != "" {
|
||||
doc.IdentityServer = &wellKnownBaseURL{BaseURL: url}
|
||||
}
|
||||
doc.RTCFoci = clientapi.RTCTransports(s.cfg.ClientAPI.RTC.LiveKitServiceURL)
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package setup_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
)
|
||||
|
||||
// TestWellKnownClientAdvertisesLiveKit: group call clients find the SFU
|
||||
// they publish to in the discovery document (MSC4143).
|
||||
func TestWellKnownClientAdvertisesLiveKit(t *testing.T) {
|
||||
srv := newTestServer(t, func(c *config.Config) {
|
||||
c.ClientAPI.RTC.LiveKitServiceURL = "https://livekit-jwt.warp.example/"
|
||||
})
|
||||
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := decode[struct {
|
||||
Foci []map[string]string `json:"org.matrix.msc4143.rtc_foci"`
|
||||
}](t, rec)
|
||||
if len(body.Foci) != 1 || body.Foci[0]["type"] != "livekit" ||
|
||||
body.Foci[0]["livekit_service_url"] != "https://livekit-jwt.warp.example" {
|
||||
t.Errorf("rtc_foci = %v", body.Foci)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellKnownClientOmitsRTCFociByDefault(t *testing.T) {
|
||||
srv := newTestServer(t, nil)
|
||||
body := decode[map[string]any](t, get(t, srv, http.MethodGet, "/.well-known/matrix/client"))
|
||||
if _, present := body["org.matrix.msc4143.rtc_foci"]; present {
|
||||
t.Error("rtc_foci must be omitted when no LiveKit service is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTCConfigValidation(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Global.ServerName = "warp.example"
|
||||
cfg.ClientAPI.RTC.LiveKitServiceURL = "livekit-jwt.warp.example"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("a LiveKit service URL without a scheme was accepted")
|
||||
}
|
||||
cfg.ClientAPI.RTC.LiveKitServiceURL = ""
|
||||
cfg.ClientAPI.DelayedEvents.MaxPerUser = 0
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("delayed events with no room for any were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupCallRoutesAreWiredUp: with a signing key (and so rooms) the
|
||||
// server advertises delayed events, serves their endpoints, and hands out
|
||||
// OpenID tokens the userinfo endpoint accepts.
|
||||
func TestGroupCallRoutesAreWiredUp(t *testing.T) {
|
||||
srv := newSigningServer(t, func(c *config.Config) {
|
||||
c.Registration.Enabled = true
|
||||
c.ClientAPI.RateLimit.Enabled = false
|
||||
})
|
||||
features := decode[map[string]any](t, get(t, srv, http.MethodGet, "/_matrix/client/versions"))["unstable_features"].(map[string]any)
|
||||
if features["org.matrix.msc4140"] != true {
|
||||
t.Errorf("unstable_features = %v", features)
|
||||
}
|
||||
|
||||
token := registerThrough(t, srv, "alice")
|
||||
if rec := getWithToken(t, srv, "/_matrix/client/unstable/org.matrix.msc4140/delayed_events", token); rec.Code != http.StatusOK {
|
||||
t.Errorf("delayed_events: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := post(t, srv, "/_matrix/client/v3/user/@alice:warp.example/openid/request_token", map[string]any{}, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("request_token: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
openID := decode[map[string]any](t, rec)["access_token"].(string)
|
||||
rec = get(t, srv, http.MethodGet, "/_matrix/federation/v1/openid/userinfo?access_token="+openID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("userinfo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedEventsCanBeSwitchedOff(t *testing.T) {
|
||||
srv := newSigningServer(t, func(c *config.Config) {
|
||||
c.ClientAPI.DelayedEvents.MaxDelay = 0
|
||||
})
|
||||
features := decode[map[string]any](t, get(t, srv, http.MethodGet, "/_matrix/client/versions"))["unstable_features"].(map[string]any)
|
||||
if _, present := features["org.matrix.msc4140"]; present {
|
||||
t.Errorf("unstable_features = %v", features)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"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/federationapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
@@ -61,6 +62,7 @@ type Server struct {
|
||||
clientAPI *clientapi.ClientAPI
|
||||
|
||||
appservices *appservice.API
|
||||
delayed *delayedevents.Service
|
||||
notices *servernotices.API
|
||||
regTokens *userapi.RegistrationTokens
|
||||
|
||||
@@ -407,6 +409,24 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
}
|
||||
emit.add(s.appservices)
|
||||
|
||||
// Delayed events are sent by the roomserver as the user who
|
||||
// scheduled them, and hear about state changes on the emit stage:
|
||||
// someone else setting a piece of state cancels the delayed
|
||||
// events that would overwrite it.
|
||||
s.delayed, err = delayedevents.New(delayedevents.Options{
|
||||
Store: s.db.DelayedEvents(),
|
||||
Rooms: s.rooms,
|
||||
MaxDelay: cfg.ClientAPI.DelayedEvents.MaxDelay,
|
||||
MaxPerUser: cfg.ClientAPI.DelayedEvents.MaxPerUser,
|
||||
DescribeError: clientapi.DescribeRoomError,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
emit.add(s.delayed)
|
||||
|
||||
// Deactivating an account leaves its rooms, which only the
|
||||
// roomserver can do.
|
||||
s.users.SetAccountObserver(roomLeaver{rooms: s.rooms, log: log})
|
||||
@@ -490,6 +510,9 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
AppServices: s.appservices,
|
||||
RateLimit: cfg.ClientAPI.RateLimit,
|
||||
TURN: cfg.ClientAPI.TURN,
|
||||
RTC: cfg.ClientAPI.RTC,
|
||||
DelayedEvents: s.delayed,
|
||||
OpenIDTokens: s.db.OpenIDTokens(),
|
||||
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
|
||||
Logger: log,
|
||||
})
|
||||
@@ -522,6 +545,11 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
// talks to the services, and tests that drive the handler directly
|
||||
// need it running.
|
||||
s.appservices.Start(ctx)
|
||||
// So do delayed events: a call member who drops off has to be removed
|
||||
// whether or not anything is listening yet.
|
||||
if s.delayed != nil {
|
||||
s.delayed.Start(ctx)
|
||||
}
|
||||
|
||||
s.handler = httputil.Chain(s.routes(),
|
||||
httputil.Recoverer,
|
||||
@@ -749,6 +777,10 @@ func (s *Server) Close() error {
|
||||
// cursors.
|
||||
s.appservices.Close()
|
||||
}
|
||||
if s.delayed != nil {
|
||||
// And the delayed event scheduler, which sends events.
|
||||
s.delayed.Close()
|
||||
}
|
||||
if s.stopTracing != nil {
|
||||
// Spans still in the batch are sent rather than lost.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNoSuchDelayedEvent is returned for a delay ID that is unknown, belongs
|
||||
// to someone else, or is no longer scheduled.
|
||||
var ErrNoSuchDelayedEvent = errors.New("no such delayed event")
|
||||
|
||||
// DelayedEvent is an event a client asked to have sent later (MSC4140).
|
||||
//
|
||||
// While it is scheduled FinalisedAtMS is zero. Once it has been sent or
|
||||
// cancelled it is kept for a while with its outcome, so a client that comes
|
||||
// back can find out what became of it.
|
||||
type DelayedEvent struct {
|
||||
DelayID string
|
||||
UserID string
|
||||
DeviceID string
|
||||
RoomID string
|
||||
Type string
|
||||
|
||||
// StateKey is nil for a timeline event.
|
||||
StateKey *string
|
||||
Content json.RawMessage
|
||||
|
||||
// DelayMS is the delay the client asked for. A restart counts it again
|
||||
// from now.
|
||||
DelayMS int64
|
||||
|
||||
// RunningSinceMS is when the delay was last (re)started, and SendAtMS
|
||||
// when it runs out: RunningSinceMS + DelayMS.
|
||||
RunningSinceMS int64
|
||||
SendAtMS int64
|
||||
|
||||
// The outcome, once finalised: Outcome is "send" or "cancel", Reason
|
||||
// "delay", "action" or "error". EventID is the event that was sent,
|
||||
// and Error the Matrix error that stopped it.
|
||||
FinalisedAtMS int64
|
||||
Outcome string
|
||||
Reason string
|
||||
EventID string
|
||||
Error json.RawMessage
|
||||
}
|
||||
|
||||
// DelayedEventStore keeps the delayed events of every user.
|
||||
type DelayedEventStore interface {
|
||||
// InsertDelayedEvent schedules an event.
|
||||
InsertDelayedEvent(ctx context.Context, e DelayedEvent) error
|
||||
|
||||
// DelayedEvent returns one of userID's events, scheduled or finalised.
|
||||
DelayedEvent(ctx context.Context, userID, delayID string) (DelayedEvent, error)
|
||||
|
||||
// ScheduledDelayedEvent returns a scheduled event whoever owns it: the
|
||||
// action endpoints are addressed by the delay ID alone.
|
||||
ScheduledDelayedEvent(ctx context.Context, delayID string) (DelayedEvent, error)
|
||||
|
||||
// DelayedEvents lists userID's events, scheduled ones first by when
|
||||
// they are due, then finalised ones newest first.
|
||||
DelayedEvents(ctx context.Context, userID string) ([]DelayedEvent, error)
|
||||
|
||||
// CountScheduledDelayedEvents counts userID's scheduled events.
|
||||
CountScheduledDelayedEvents(ctx context.Context, userID string) (int, error)
|
||||
|
||||
// RestartDelayedEvent starts a scheduled event's delay again from nowMS.
|
||||
// It returns ErrNoSuchDelayedEvent if the event is not scheduled.
|
||||
RestartDelayedEvent(ctx context.Context, delayID string, nowMS int64) error
|
||||
|
||||
// FinaliseDelayedEvent records what became of a scheduled event. It
|
||||
// returns ErrNoSuchDelayedEvent if the event was already finalised,
|
||||
// which is how two racing finalisers are told apart: only one of them
|
||||
// gets to send.
|
||||
FinaliseDelayedEvent(ctx context.Context, e DelayedEvent) error
|
||||
|
||||
// DueDelayedEvents returns up to limit scheduled events whose delay
|
||||
// has run out at nowMS, the earliest first.
|
||||
DueDelayedEvents(ctx context.Context, nowMS int64, limit int) ([]DelayedEvent, error)
|
||||
|
||||
// NextDelayedEventDue reports when the next scheduled event is due,
|
||||
// and false when nothing is scheduled.
|
||||
NextDelayedEventDue(ctx context.Context) (int64, bool, error)
|
||||
|
||||
// ScheduledDelayedStateEvents lists the scheduled state events for one
|
||||
// piece of room state, of every user.
|
||||
ScheduledDelayedStateEvents(ctx context.Context, roomID, eventType, stateKey string) ([]DelayedEvent, error)
|
||||
|
||||
// PurgeFinalisedDelayedEvents forgets the events finalised before
|
||||
// beforeMS.
|
||||
PurgeFinalisedDelayedEvents(ctx context.Context, beforeMS int64) error
|
||||
}
|
||||
|
||||
// ErrNoSuchOpenIDToken is returned for an OpenID token that is unknown or
|
||||
// has expired.
|
||||
var ErrNoSuchOpenIDToken = errors.New("no such OpenID token")
|
||||
|
||||
// OpenIDTokenStore keeps the OpenID tokens users hand to third parties, so
|
||||
// those can find out who the user is (and nothing more) from this server.
|
||||
type OpenIDTokenStore interface {
|
||||
// InsertOpenIDToken stores a token by its hash.
|
||||
InsertOpenIDToken(ctx context.Context, tokenHash, userID string, expiresAtMS int64) error
|
||||
|
||||
// OpenIDTokenUser returns the user a token was issued to, or
|
||||
// ErrNoSuchOpenIDToken once it has expired at nowMS.
|
||||
OpenIDTokenUser(ctx context.Context, tokenHash string, nowMS int64) (string, error)
|
||||
|
||||
// PurgeOpenIDTokens forgets tokens that expired before beforeMS.
|
||||
PurgeOpenIDTokens(ctx context.Context, beforeMS int64) error
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// migratedDelayedDB opens a migrated database with empty delayed event and
|
||||
// OpenID tables.
|
||||
func migratedDelayedDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
db := openTestDB(t, driver)
|
||||
if err := db.Migrate(t.Context()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
if driver == storage.DriverPostgres {
|
||||
pool, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
defer func() { _ = pool.Close() }()
|
||||
if _, err := pool.ExecContext(t.Context(), `TRUNCATE delayed_events, openid_tokens`); err != nil {
|
||||
t.Fatalf("reset the tables: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDelayedEvents(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
store := migratedDelayedDB(t, driver).DelayedEvents()
|
||||
|
||||
if _, ok, err := store.NextDelayedEventDue(ctx); err != nil || ok {
|
||||
t.Fatalf("NextDelayedEventDue on an empty table = %v, %v", ok, err)
|
||||
}
|
||||
|
||||
key := "_@alice:warp.example_DEV_m.call"
|
||||
state := storage.DelayedEvent{
|
||||
DelayID: "d1", UserID: "@alice:warp.example", DeviceID: "DEV",
|
||||
RoomID: "!r:warp.example", Type: "org.matrix.msc3401.call.member",
|
||||
StateKey: &key, Content: []byte(`{}`),
|
||||
DelayMS: 5000, RunningSinceMS: 1000, SendAtMS: 6000,
|
||||
}
|
||||
timeline := storage.DelayedEvent{
|
||||
DelayID: "d2", UserID: "@alice:warp.example", RoomID: "!r:warp.example",
|
||||
Type: "m.room.message", Content: []byte(`{"body":"hi"}`),
|
||||
DelayMS: 1000, RunningSinceMS: 1000, SendAtMS: 2000,
|
||||
}
|
||||
for _, e := range []storage.DelayedEvent{state, timeline} {
|
||||
if err := store.InsertDelayedEvent(ctx, e); err != nil {
|
||||
t.Fatalf("InsertDelayedEvent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := store.CountScheduledDelayedEvents(ctx, "@alice:warp.example"); err != nil || n != 2 {
|
||||
t.Errorf("count = %d, %v", n, err)
|
||||
}
|
||||
if next, ok, err := store.NextDelayedEventDue(ctx); err != nil || !ok || next != 2000 {
|
||||
t.Errorf("next due = %d, %v, %v", next, ok, err)
|
||||
}
|
||||
|
||||
got, err := store.DelayedEvent(ctx, "@alice:warp.example", "d1")
|
||||
if err != nil || got.StateKey == nil || *got.StateKey != key || string(got.Content) != `{}` {
|
||||
t.Errorf("DelayedEvent = %+v, %v", got, err)
|
||||
}
|
||||
if got, err := store.DelayedEvent(ctx, "@alice:warp.example", "d2"); err != nil || got.StateKey != nil {
|
||||
t.Errorf("a timeline event has a state key: %+v, %v", got, err)
|
||||
}
|
||||
if _, err := store.DelayedEvent(ctx, "@bob:warp.example", "d1"); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("bob read alice's event: %v", err)
|
||||
}
|
||||
|
||||
due, err := store.DueDelayedEvents(ctx, 3000, 10)
|
||||
if err != nil || len(due) != 1 || due[0].DelayID != "d2" {
|
||||
t.Errorf("due at 3000 = %+v, %v", due, err)
|
||||
}
|
||||
|
||||
if err := store.RestartDelayedEvent(ctx, "d1", 4000); err != nil {
|
||||
t.Fatalf("RestartDelayedEvent: %v", err)
|
||||
}
|
||||
got, _ = store.ScheduledDelayedEvent(ctx, "d1")
|
||||
if got.RunningSinceMS != 4000 || got.SendAtMS != 9000 {
|
||||
t.Errorf("after restart = %+v", got)
|
||||
}
|
||||
|
||||
pending, err := store.ScheduledDelayedStateEvents(ctx, "!r:warp.example", "org.matrix.msc3401.call.member", key)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Errorf("ScheduledDelayedStateEvents = %+v, %v", pending, err)
|
||||
}
|
||||
|
||||
sent := timeline
|
||||
sent.FinalisedAtMS, sent.Outcome, sent.Reason, sent.EventID = 2500, "send", "delay", "$e"
|
||||
if err := store.FinaliseDelayedEvent(ctx, sent); err != nil {
|
||||
t.Fatalf("FinaliseDelayedEvent: %v", err)
|
||||
}
|
||||
if err := store.FinaliseDelayedEvent(ctx, sent); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("finalising twice = %v", err)
|
||||
}
|
||||
if err := store.RestartDelayedEvent(ctx, "d2", 3000); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("restarting a finalised event = %v", err)
|
||||
}
|
||||
|
||||
all, err := store.DelayedEvents(ctx, "@alice:warp.example")
|
||||
if err != nil || len(all) != 2 || all[0].DelayID != "d1" || all[1].EventID != "$e" {
|
||||
t.Errorf("DelayedEvents = %+v, %v", all, err)
|
||||
}
|
||||
|
||||
if err := store.PurgeFinalisedDelayedEvents(ctx, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all, _ := store.DelayedEvents(ctx, "@alice:warp.example"); len(all) != 1 {
|
||||
t.Errorf("after purge = %+v", all)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenIDTokens(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
store := migratedDelayedDB(t, driver).OpenIDTokens()
|
||||
|
||||
if err := store.InsertOpenIDToken(ctx, "hash", "@alice:warp.example", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user, err := store.OpenIDTokenUser(ctx, "hash", 4000); err != nil || user != "@alice:warp.example" {
|
||||
t.Errorf("OpenIDTokenUser = %q, %v", user, err)
|
||||
}
|
||||
if _, err := store.OpenIDTokenUser(ctx, "hash", 6000); !errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
t.Errorf("an expired token = %v", err)
|
||||
}
|
||||
if err := store.PurgeOpenIDTokens(ctx, 6000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.OpenIDTokenUser(ctx, "hash", 0); !errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
t.Errorf("a purged token = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Group calls: delayed events (MSC4140) and OpenID tokens.
|
||||
|
||||
-- An event a client asked to have sent later. A MatrixRTC client schedules
|
||||
-- its own "left the call" this way and keeps pushing it back while it is
|
||||
-- in the call, so a client that crashes or loses its connection still
|
||||
-- leaves.
|
||||
--
|
||||
-- state_key is NULL for a timeline event. finalised_at_ms is 0 while the
|
||||
-- event is scheduled; once it has been sent or cancelled the row is kept
|
||||
-- for a while with its outcome, so a client can find out what happened.
|
||||
CREATE TABLE IF NOT EXISTS delayed_events (
|
||||
delay_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
delay_ms BIGINT NOT NULL,
|
||||
running_since_ms BIGINT NOT NULL,
|
||||
send_at_ms BIGINT NOT NULL,
|
||||
finalised_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_user_idx
|
||||
ON delayed_events (user_id, finalised_at_ms);
|
||||
|
||||
-- The scheduler asks for what is due next among the scheduled events.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_due_idx
|
||||
ON delayed_events (finalised_at_ms, send_at_ms);
|
||||
|
||||
-- Someone else setting a piece of state cancels the delayed events that
|
||||
-- would have overwritten it.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_state_idx
|
||||
ON delayed_events (room_id, event_type, state_key, finalised_at_ms);
|
||||
|
||||
-- OpenID tokens: what a user hands a third party (the LiveKit JWT service)
|
||||
-- so it can ask this server who they are. Stored by hash, like access
|
||||
-- tokens.
|
||||
CREATE TABLE IF NOT EXISTS openid_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS openid_tokens_expiry_idx ON openid_tokens (expires_at_ms);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Group calls: delayed events (MSC4140) and OpenID tokens.
|
||||
|
||||
-- An event a client asked to have sent later. A MatrixRTC client schedules
|
||||
-- its own "left the call" this way and keeps pushing it back while it is
|
||||
-- in the call, so a client that crashes or loses its connection still
|
||||
-- leaves.
|
||||
--
|
||||
-- state_key is NULL for a timeline event. finalised_at_ms is 0 while the
|
||||
-- event is scheduled; once it has been sent or cancelled the row is kept
|
||||
-- for a while with its outcome, so a client can find out what happened.
|
||||
CREATE TABLE IF NOT EXISTS delayed_events (
|
||||
delay_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
delay_ms BIGINT NOT NULL,
|
||||
running_since_ms BIGINT NOT NULL,
|
||||
send_at_ms BIGINT NOT NULL,
|
||||
finalised_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_user_idx
|
||||
ON delayed_events (user_id, finalised_at_ms);
|
||||
|
||||
-- The scheduler asks for what is due next among the scheduled events.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_due_idx
|
||||
ON delayed_events (finalised_at_ms, send_at_ms);
|
||||
|
||||
-- Someone else setting a piece of state cancels the delayed events that
|
||||
-- would have overwritten it.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_state_idx
|
||||
ON delayed_events (room_id, event_type, state_key, finalised_at_ms);
|
||||
|
||||
-- OpenID tokens: what a user hands a third party (the LiveKit JWT service)
|
||||
-- so it can ask this server who they are. Stored by hash, like access
|
||||
-- tokens.
|
||||
CREATE TABLE IF NOT EXISTS openid_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS openid_tokens_expiry_idx ON openid_tokens (expires_at_ms);
|
||||
@@ -0,0 +1,273 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// DelayedEvents is the shared implementation of storage.DelayedEventStore.
|
||||
type DelayedEvents struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
}
|
||||
|
||||
// NewDelayedEvents builds a delayed event store.
|
||||
func NewDelayedEvents(db *sql.DB, rebind func(string) string) *DelayedEvents {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
return &DelayedEvents{db: db, rebind: rebind}
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) q(query string) string { return s.rebind(query) }
|
||||
|
||||
const delayedEventColumns = `
|
||||
delay_id, user_id, device_id, room_id, event_type, state_key, content,
|
||||
delay_ms, running_since_ms, send_at_ms,
|
||||
finalised_at_ms, outcome, reason, event_id, error`
|
||||
|
||||
const (
|
||||
insertDelayedEventSQL = `
|
||||
INSERT INTO delayed_events (
|
||||
delay_id, user_id, device_id, room_id, event_type, state_key, content,
|
||||
delay_ms, running_since_ms, send_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
selectDelayedEventSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE user_id = ? AND delay_id = ?`
|
||||
|
||||
selectScheduledDelayedEventSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
selectUserDelayedEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE user_id = ?
|
||||
ORDER BY finalised_at_ms = 0 DESC, send_at_ms ASC, finalised_at_ms DESC`
|
||||
|
||||
countScheduledDelayedEventsSQL = `
|
||||
SELECT COUNT(*) FROM delayed_events WHERE user_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
restartDelayedEventSQL = `
|
||||
UPDATE delayed_events SET running_since_ms = ?, send_at_ms = ? + delay_ms
|
||||
WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
finaliseDelayedEventSQL = `
|
||||
UPDATE delayed_events
|
||||
SET finalised_at_ms = ?, outcome = ?, reason = ?, event_id = ?, error = ?
|
||||
WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
selectDueDelayedEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE finalised_at_ms = 0 AND send_at_ms <= ?
|
||||
ORDER BY send_at_ms LIMIT ?`
|
||||
|
||||
selectNextDelayedEventDueSQL = `
|
||||
SELECT MIN(send_at_ms) FROM delayed_events WHERE finalised_at_ms = 0`
|
||||
|
||||
selectScheduledStateEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events
|
||||
WHERE room_id = ? AND event_type = ? AND state_key = ? AND finalised_at_ms = 0`
|
||||
|
||||
purgeFinalisedDelayedEventsSQL = `
|
||||
DELETE FROM delayed_events WHERE finalised_at_ms <> 0 AND finalised_at_ms < ?`
|
||||
)
|
||||
|
||||
// InsertDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) InsertDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
||||
var stateKey sql.NullString
|
||||
if e.StateKey != nil {
|
||||
stateKey = sql.NullString{String: *e.StateKey, Valid: true}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, s.q(insertDelayedEventSQL),
|
||||
e.DelayID, e.UserID, e.DeviceID, e.RoomID, e.Type, stateKey, string(e.Content),
|
||||
e.DelayMS, e.RunningSinceMS, e.SendAtMS,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert delayed event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DelayedEvent(ctx context.Context, userID, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.one(ctx, selectDelayedEventSQL, userID, delayID)
|
||||
}
|
||||
|
||||
// ScheduledDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) ScheduledDelayedEvent(ctx context.Context, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.one(ctx, selectScheduledDelayedEventSQL, delayID)
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) one(ctx context.Context, query string, args ...any) (storage.DelayedEvent, error) {
|
||||
events, err := s.list(ctx, query, args...)
|
||||
if err != nil {
|
||||
return storage.DelayedEvent{}, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return storage.DelayedEvent{}, storage.ErrNoSuchDelayedEvent
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// DelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DelayedEvents(ctx context.Context, userID string) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectUserDelayedEventsSQL, userID)
|
||||
}
|
||||
|
||||
// CountScheduledDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) CountScheduledDelayedEvents(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, s.q(countScheduledDelayedEventsSQL), userID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count delayed events: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RestartDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) RestartDelayedEvent(ctx context.Context, delayID string, nowMS int64) error {
|
||||
res, err := s.db.ExecContext(ctx, s.q(restartDelayedEventSQL), nowMS, nowMS, delayID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restart delayed event: %w", err)
|
||||
}
|
||||
return oneRow(res)
|
||||
}
|
||||
|
||||
// FinaliseDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) FinaliseDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
||||
res, err := s.db.ExecContext(ctx, s.q(finaliseDelayedEventSQL),
|
||||
e.FinalisedAtMS, e.Outcome, e.Reason, e.EventID, string(e.Error), e.DelayID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finalise delayed event: %w", err)
|
||||
}
|
||||
return oneRow(res)
|
||||
}
|
||||
|
||||
func oneRow(res sql.Result) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delayed event: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return storage.ErrNoSuchDelayedEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DueDelayedEvents(ctx context.Context, nowMS int64, limit int) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectDueDelayedEventsSQL, nowMS, limit)
|
||||
}
|
||||
|
||||
// NextDelayedEventDue implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) NextDelayedEventDue(ctx context.Context) (int64, bool, error) {
|
||||
var next sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx, s.q(selectNextDelayedEventDueSQL)).Scan(&next); err != nil {
|
||||
return 0, false, fmt.Errorf("next delayed event: %w", err)
|
||||
}
|
||||
return next.Int64, next.Valid, nil
|
||||
}
|
||||
|
||||
// ScheduledDelayedStateEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) ScheduledDelayedStateEvents(ctx context.Context, roomID, eventType, stateKey string) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectScheduledStateEventsSQL, roomID, eventType, stateKey)
|
||||
}
|
||||
|
||||
// PurgeFinalisedDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) PurgeFinalisedDelayedEvents(ctx context.Context, beforeMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q(purgeFinalisedDelayedEventsSQL), beforeMS); err != nil {
|
||||
return fmt.Errorf("purge delayed events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) list(ctx context.Context, query string, args ...any) ([]storage.DelayedEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read delayed events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []storage.DelayedEvent
|
||||
for rows.Next() {
|
||||
var (
|
||||
e storage.DelayedEvent
|
||||
stateKey sql.NullString
|
||||
content, errorContent string
|
||||
)
|
||||
if err := rows.Scan(&e.DelayID, &e.UserID, &e.DeviceID, &e.RoomID, &e.Type, &stateKey, &content,
|
||||
&e.DelayMS, &e.RunningSinceMS, &e.SendAtMS,
|
||||
&e.FinalisedAtMS, &e.Outcome, &e.Reason, &e.EventID, &errorContent,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("read delayed event: %w", err)
|
||||
}
|
||||
if stateKey.Valid {
|
||||
e.StateKey = &stateKey.String
|
||||
}
|
||||
e.Content = []byte(content)
|
||||
if errorContent != "" {
|
||||
e.Error = []byte(errorContent)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OpenIDTokens is the shared implementation of storage.OpenIDTokenStore.
|
||||
type OpenIDTokens struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
}
|
||||
|
||||
// NewOpenIDTokens builds an OpenID token store.
|
||||
func NewOpenIDTokens(db *sql.DB, rebind func(string) string) *OpenIDTokens {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
return &OpenIDTokens{db: db, rebind: rebind}
|
||||
}
|
||||
|
||||
const (
|
||||
insertOpenIDTokenSQL = `
|
||||
INSERT INTO openid_tokens (token_hash, user_id, expires_at_ms) VALUES (?, ?, ?)`
|
||||
|
||||
selectOpenIDTokenSQL = `
|
||||
SELECT user_id FROM openid_tokens WHERE token_hash = ? AND expires_at_ms > ?`
|
||||
|
||||
purgeOpenIDTokensSQL = `DELETE FROM openid_tokens WHERE expires_at_ms < ?`
|
||||
)
|
||||
|
||||
// InsertOpenIDToken implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) InsertOpenIDToken(ctx context.Context, tokenHash, userID string, expiresAtMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.rebind(insertOpenIDTokenSQL), tokenHash, userID, expiresAtMS); err != nil {
|
||||
return fmt.Errorf("insert OpenID token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenIDTokenUser implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) OpenIDTokenUser(ctx context.Context, tokenHash string, nowMS int64) (string, error) {
|
||||
var userID string
|
||||
err := s.db.QueryRowContext(ctx, s.rebind(selectOpenIDTokenSQL), tokenHash, nowMS).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", storage.ErrNoSuchOpenIDToken
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read OpenID token: %w", err)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// PurgeOpenIDTokens implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) PurgeOpenIDTokens(ctx context.Context, beforeMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.rebind(purgeOpenIDTokensSQL), beforeMS); err != nil {
|
||||
return fmt.Errorf("purge OpenID tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time proof that the shared implementations satisfy the interfaces.
|
||||
var (
|
||||
_ storage.DelayedEventStore = (*DelayedEvents)(nil)
|
||||
_ storage.OpenIDTokenStore = (*OpenIDTokens)(nil)
|
||||
)
|
||||
@@ -69,6 +69,8 @@ type Store struct {
|
||||
push *Push
|
||||
admin *Admin
|
||||
apps *AppServices
|
||||
delayed *DelayedEvents
|
||||
openID *OpenIDTokens
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
@@ -93,6 +95,8 @@ func New(opts Options) (*Store, error) {
|
||||
push: NewPush(opts.DB, opts.Rebind),
|
||||
admin: NewAdmin(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
apps: NewAppServices(opts.DB, opts.Rebind),
|
||||
delayed: NewDelayedEvents(opts.DB, opts.Rebind),
|
||||
openID: NewOpenIDTokens(opts.DB, opts.Rebind),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +142,12 @@ func (s *Store) Admin() storage.AdminStore { return s.admin }
|
||||
// AppServices implements storage.Database.
|
||||
func (s *Store) AppServices() storage.AppServiceStore { return s.apps }
|
||||
|
||||
// DelayedEvents implements storage.Database.
|
||||
func (s *Store) DelayedEvents() storage.DelayedEventStore { return s.delayed }
|
||||
|
||||
// OpenIDTokens implements storage.Database.
|
||||
func (s *Store) OpenIDTokens() storage.OpenIDTokenStore { return s.openID }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
|
||||
@@ -111,6 +111,14 @@ type Database interface {
|
||||
// stream.
|
||||
AppServices() AppServiceStore
|
||||
|
||||
// DelayedEvents is where the delayed events clients scheduled wait
|
||||
// to be sent (MSC4140).
|
||||
DelayedEvents() DelayedEventStore
|
||||
|
||||
// OpenIDTokens is where the OpenID tokens users hand to third parties
|
||||
// are kept.
|
||||
OpenIDTokens() OpenIDTokenStore
|
||||
|
||||
// Close releases the connection pool.
|
||||
Close() error
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user