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
@@ -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