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

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:
tajniak81andClaude Opus 5.5 committed 2026-10-18 19:33:29 +02:00
1 parent 325431a5be
commit 76dfc98a86
49 files changed
+3849 -432

No files matched your search

+110
View File
@@ -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
}
+147
View File
@@ -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);
+273
View File
@@ -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()
+8
View File
@@ -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
}