Phase 1: accounts and sessions
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

Implements PLAN.md Phase 1. A client can now register, log in, manage its
devices and set its profile; an operator can create accounts and flip the
runtime switches. There are still no rooms and no real /sync.

userapi: accounts, argon2id passwords in PHC format, devices, access and
refresh tokens stored as SHA-256 hashes, account data and the admin flag.
Its storage interface is storage.UserStore, implemented once in sqlstore
with `?` placeholders; the postgres driver supplies a rebind function and
unique-violation recognition rather than keeping a second copy of thirty
statements. Timestamps in these tables are int64 milliseconds, the unit the
Client-Server API uses on the wire.

Client-Server API: register (user-interactive auth with m.login.dummy and
optional registration tokens), register/available, token validity, login
with m.login.password, logout, logout/all, refresh, account/whoami,
capabilities, devices/* with password-gated deletion, profile/*, global and
per-room account data.

GET /sync is a deliberate placeholder: it answers a valid token with an
empty but well-formed response and refuses a revoked one. Phase 1 has no
rooms, so that is the whole truth, and it is what makes session revocation
observable. Phase 4 replaces it with the syncapi.

Auth middleware resolves a bearer token (header or the legacy query
parameter) to a device and user, answering an expired token with the soft
logout the spec asks for. Rate limiting is a token bucket per endpoint class
keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms.

adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix
token, GET/PUT /federation and /registration driving internal/settings and
reporting a config-pinned switch as a 409, GET/POST /users, GET /stats.

warp create-account [--admin] creates the first account straight in the
database, so a new install with registration off has a way in.

Complement is wired up for the federation=off profile: an image meeting its
contract, an entrypoint that configures Warp entirely from WARP_*
variables, and a runner that builds, clones and executes the target set
(TestRegistration, TestLogin, TestDeviceManagement). It has not been run
yet -- there is no container runtime on the development machine -- so the
three tests' assertions were read from the Complement sources and pinned as
httptest tests instead, and CI gained a job that runs the real thing.

Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern,
meant for the built binary, also matched the directory server/cmd/warp and
silently excluded the entire command package from the repository. The
binary patterns are now anchored to the repository root, and cmd/warp is
committed for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

+644
View File
@@ -0,0 +1,644 @@
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Users is the shared SQL implementation of storage.UserStore.
//
// Unlike the settings store, whose four statements each driver writes in its
// own style, the user store has around thirty. Keeping two copies of them
// differing only in `?` versus `$1` would be a standing invitation to fix a
// bug in one and not the other, so the SQL is written once with `?` and the
// driver supplies a Rebind function. The statements themselves use only
// syntax both engines agree on: ON CONFLICT ... DO UPDATE with `excluded`,
// LIMIT/OFFSET, and comparisons against bound values rather than boolean
// literals.
type Users struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewUsers builds a user store. rebind translates the `?` placeholders into
// the driver's own style, and isUnique recognises its unique-constraint
// error; nil means "no translation" and "never", respectively.
func NewUsers(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Users {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Users{db: db, rebind: rebind, isUnique: isUnique}
}
// defaultAccountPageSize bounds an admin user listing that asks for no limit,
// so a forgotten ?limit= cannot stream the whole user table.
const defaultAccountPageSize = 100
// maxAccountPageSize caps what a caller may ask for.
const maxAccountPageSize = 1000
// q rebinds a statement for this driver.
func (u *Users) q(query string) string { return u.rebind(query) }
// tx runs fn in a transaction, rolling back on any error. The rollback after
// a successful commit is a no-op, which is why it can be deferred blindly.
func (u *Users) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := u.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
const insertAccountSQL = `
INSERT INTO user_accounts (localpart, password_hash, display_name, admin, created_at_ms)
VALUES (?, ?, ?, ?, ?)`
// CreateAccount implements storage.UserStore.
//
// Taken localparts are detected by inserting and letting the primary key
// complain, rather than by checking first: the check-then-insert version has
// a race that two simultaneous registrations of the same name would walk
// straight into.
func (u *Users) CreateAccount(ctx context.Context, acc storage.NewAccount) (storage.Account, error) {
_, err := u.db.ExecContext(ctx, u.q(insertAccountSQL),
acc.Localpart, acc.PasswordHash, acc.DisplayName, acc.Admin, acc.CreatedAtMS)
if err != nil {
if u.isUnique(err) {
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, storage.ErrUserExists)
}
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, err)
}
return storage.Account{
Localpart: acc.Localpart,
DisplayName: acc.DisplayName,
Admin: acc.Admin,
CreatedAtMS: acc.CreatedAtMS,
}, nil
}
const selectAccountSQL = `
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
FROM user_accounts WHERE localpart = ?`
// Account implements storage.UserStore.
func (u *Users) Account(ctx context.Context, localpart string) (storage.Account, error) {
var acc storage.Account
err := u.db.QueryRowContext(ctx, u.q(selectAccountSQL), localpart).Scan(
&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, storage.ErrNoSuchUser)
case err != nil:
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, err)
}
return acc, nil
}
const selectPasswordHashSQL = `SELECT password_hash FROM user_accounts WHERE localpart = ?`
// PasswordHash implements storage.UserStore.
func (u *Users) PasswordHash(ctx context.Context, localpart string) (string, error) {
var hash string
err := u.db.QueryRowContext(ctx, u.q(selectPasswordHashSQL), localpart).Scan(&hash)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", fmt.Errorf("password for %q: %w", localpart, storage.ErrNoSuchUser)
case err != nil:
return "", fmt.Errorf("password for %q: %w", localpart, err)
}
return hash, nil
}
// SetPasswordHash implements storage.UserStore.
func (u *Users) SetPasswordHash(ctx context.Context, localpart, hash string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET password_hash = ? WHERE localpart = ?`, hash)
}
// SetAdmin implements storage.UserStore.
func (u *Users) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET admin = ? WHERE localpart = ?`, admin)
}
// SetDisplayName implements storage.UserStore.
func (u *Users) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET display_name = ? WHERE localpart = ?`, displayName)
}
// SetAvatarURL implements storage.UserStore.
func (u *Users) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET avatar_url = ? WHERE localpart = ?`, avatarURL)
}
// updateAccount runs a single-column update and turns "nothing changed" into
// ErrNoSuchUser, so callers do not have to look the account up first.
func (u *Users) updateAccount(ctx context.Context, localpart, query string, value any) error {
res, err := u.db.ExecContext(ctx, u.q(query), value, localpart)
if err != nil {
return fmt.Errorf("update account %q: %w", localpart, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update account %q: %w", localpart, err)
}
if affected == 0 {
return fmt.Errorf("update account %q: %w", localpart, storage.ErrNoSuchUser)
}
return nil
}
// likeEscape is the escape character the search patterns use.
//
// It is deliberately not a backslash: a backslash in a SQL string literal
// means different things depending on PostgreSQL's standard_conforming_
// strings setting, and "!" means exactly one thing everywhere.
const likeEscape = '!'
const (
countAccountsSQL = `
SELECT COUNT(*) FROM user_accounts
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'`
listAccountsSQL = `
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
FROM user_accounts
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'
ORDER BY localpart
LIMIT ? OFFSET ?`
)
// ListAccounts implements storage.UserStore.
//
// The search is a LOWER(...) LIKE rather than ILIKE, which is PostgreSQL
// only, and the pattern is escaped so that searching for "_" does not
// quietly match every single-character name.
func (u *Users) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
pattern := likeContains(filter.Search)
limit := filter.Limit
switch {
case limit <= 0:
limit = defaultAccountPageSize
case limit > maxAccountPageSize:
limit = maxAccountPageSize
}
offset := max(filter.Offset, 0)
var total int
if err := u.db.QueryRowContext(ctx, u.q(countAccountsSQL), pattern, pattern).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count accounts: %w", err)
}
rows, err := u.db.QueryContext(ctx, u.q(listAccountsSQL), pattern, pattern, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list accounts: %w", err)
}
defer rows.Close()
accounts := []storage.Account{}
for rows.Next() {
var acc storage.Account
if err := rows.Scan(&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS); err != nil {
return nil, 0, fmt.Errorf("scan account: %w", err)
}
accounts = append(accounts, acc)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("list accounts: %w", err)
}
return accounts, total, nil
}
// likeContains turns a search term into a LIKE pattern matching it
// anywhere, escaping the two wildcards and the escape character itself. The
// statements using it declare ESCAPE explicitly, because SQLite has no
// default escape character at all.
func likeContains(search string) string {
search = strings.ToLower(strings.TrimSpace(search))
var b strings.Builder
b.Grow(len(search) + 2)
b.WriteByte('%')
for _, r := range search {
if r == '%' || r == '_' || r == likeEscape {
b.WriteByte(likeEscape)
}
b.WriteRune(r)
}
b.WriteByte('%')
return b.String()
}
const statsSQL = `
SELECT
(SELECT COUNT(*) FROM user_accounts),
(SELECT COUNT(*) FROM user_accounts WHERE admin = ?),
(SELECT COUNT(*) FROM user_accounts WHERE deactivated = ?),
(SELECT COUNT(*) FROM user_devices)`
// Stats implements storage.UserStore.
func (u *Users) Stats(ctx context.Context) (storage.Stats, error) {
var s storage.Stats
// The flags are compared against bound values rather than TRUE, which
// SQLite only learned in 3.23 and which PostgreSQL spells differently
// from an integer.
err := u.db.QueryRowContext(ctx, u.q(statsSQL), true, true).
Scan(&s.Users, &s.Admins, &s.Deactivated, &s.Devices)
if err != nil {
return storage.Stats{}, fmt.Errorf("user stats: %w", err)
}
return s, nil
}
const (
selectDeviceForUpdateSQL = `
SELECT display_name, created_at_ms FROM user_devices
WHERE localpart = ? AND device_id = ?`
insertDeviceSQL = `
INSERT INTO user_devices
(localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`
touchDeviceSQL = `
UPDATE user_devices SET last_seen_ms = ?, last_seen_ip = ?, user_agent = ?
WHERE localpart = ? AND device_id = ?`
deleteDeviceTokensSQL = `DELETE FROM user_access_tokens WHERE localpart = ? AND device_id = ?`
deleteDeviceRefreshTokensSQL = `DELETE FROM user_refresh_tokens WHERE localpart = ? AND device_id = ?`
insertAccessTokenSQL = `
INSERT INTO user_access_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
VALUES (?, ?, ?, ?, ?)`
insertRefreshTokenSQL = `
INSERT INTO user_refresh_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
VALUES (?, ?, ?, ?, ?)`
accountExistsSQL = `SELECT 1 FROM user_accounts WHERE localpart = ?`
)
// CreateSession implements storage.UserStore.
//
// Logging in on a device ID that already exists keeps the device — and its
// display name, because initial_device_display_name only applies to a device
// being created — but revokes the tokens it held, which is what the spec
// requires.
func (u *Users) CreateSession(ctx context.Context, s storage.NewSession) (storage.Device, error) {
device := storage.Device{
Localpart: s.Localpart,
ID: s.DeviceID,
DisplayName: s.DisplayName,
CreatedAtMS: s.CreatedAtMS,
LastSeenMS: s.CreatedAtMS,
LastSeenIP: s.LastSeenIP,
UserAgent: s.UserAgent,
}
err := u.tx(ctx, func(tx *sql.Tx) error {
var exists int
switch err := tx.QueryRowContext(ctx, u.q(accountExistsSQL), s.Localpart).Scan(&exists); {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("create session for %q: %w", s.Localpart, storage.ErrNoSuchUser)
case err != nil:
return fmt.Errorf("create session for %q: %w", s.Localpart, err)
}
var existingName string
var existingCreated int64
err := tx.QueryRowContext(ctx, u.q(selectDeviceForUpdateSQL), s.Localpart, s.DeviceID).
Scan(&existingName, &existingCreated)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx, u.q(insertDeviceSQL),
s.Localpart, s.DeviceID, s.DisplayName, s.CreatedAtMS,
s.CreatedAtMS, s.LastSeenIP, s.UserAgent); err != nil {
return fmt.Errorf("create device %q: %w", s.DeviceID, err)
}
case err != nil:
return fmt.Errorf("read device %q: %w", s.DeviceID, err)
default:
device.DisplayName = existingName
device.CreatedAtMS = existingCreated
if _, err := tx.ExecContext(ctx, u.q(touchDeviceSQL),
s.CreatedAtMS, s.LastSeenIP, s.UserAgent, s.Localpart, s.DeviceID); err != nil {
return fmt.Errorf("update device %q: %w", s.DeviceID, err)
}
for _, query := range []string{deleteDeviceTokensSQL, deleteDeviceRefreshTokensSQL} {
if _, err := tx.ExecContext(ctx, u.q(query), s.Localpart, s.DeviceID); err != nil {
return fmt.Errorf("revoke tokens of device %q: %w", s.DeviceID, err)
}
}
}
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
s.AccessTokenHash, s.Localpart, s.DeviceID,
s.CreatedAtMS, s.AccessTokenExpiresAtMS); err != nil {
return fmt.Errorf("store access token: %w", err)
}
if s.RefreshTokenHash != "" {
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
s.RefreshTokenHash, s.Localpart, s.DeviceID,
s.CreatedAtMS, s.RefreshTokenExpiresAtMS); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
}
return nil
})
if err != nil {
return storage.Device{}, err
}
return device, nil
}
const (
selectRefreshTokenSQL = `
SELECT localpart, device_id, expires_at_ms FROM user_refresh_tokens WHERE token_hash = ?`
deleteRefreshTokenSQL = `DELETE FROM user_refresh_tokens WHERE token_hash = ?`
selectAccountFlagsSQL = `SELECT admin, deactivated FROM user_accounts WHERE localpart = ?`
)
// RefreshSession implements storage.UserStore.
//
// The spent refresh token and the access token it had minted both go away
// immediately. The spec allows keeping the old access token alive for a grace
// period; refusing it at once is the stricter reading and means a stolen
// token stops working the moment the real client refreshes.
func (u *Users) RefreshSession(ctx context.Context, r storage.RefreshedSession) (storage.TokenInfo, error) {
var info storage.TokenInfo
err := u.tx(ctx, func(tx *sql.Tx) error {
var expiresAt int64
err := tx.QueryRowContext(ctx, u.q(selectRefreshTokenSQL), r.RefreshTokenHash).
Scan(&info.Localpart, &info.DeviceID, &expiresAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("refresh token: %w", storage.ErrNoSuchToken)
case err != nil:
return fmt.Errorf("refresh token: %w", err)
}
if expiresAt != 0 && expiresAt <= r.RefreshedAtMS {
// Clean the dead token up on the way past; nothing else sweeps.
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
return fmt.Errorf("delete expired refresh token: %w", err)
}
return fmt.Errorf("refresh token has expired: %w", storage.ErrNoSuchToken)
}
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
return fmt.Errorf("spend refresh token: %w", err)
}
if _, err := tx.ExecContext(ctx, u.q(deleteDeviceTokensSQL), info.Localpart, info.DeviceID); err != nil {
return fmt.Errorf("revoke previous access token: %w", err)
}
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
r.NewAccessTokenHash, info.Localpart, info.DeviceID,
r.RefreshedAtMS, r.AccessTokenExpiresAtMS); err != nil {
return fmt.Errorf("store access token: %w", err)
}
if r.NewRefreshTokenHash != "" {
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
r.NewRefreshTokenHash, info.Localpart, info.DeviceID,
r.RefreshedAtMS, r.RefreshTokenExpiresAtMS); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
}
if err := tx.QueryRowContext(ctx, u.q(selectAccountFlagsSQL), info.Localpart).
Scan(&info.Admin, &info.Deactivated); err != nil {
return fmt.Errorf("read account %q: %w", info.Localpart, err)
}
info.ExpiresAtMS = r.AccessTokenExpiresAtMS
return nil
})
if err != nil {
return storage.TokenInfo{}, err
}
return info, nil
}
const selectTokenInfoSQL = `
SELECT t.localpart, t.device_id, t.expires_at_ms, a.admin, a.deactivated
FROM user_access_tokens t
JOIN user_accounts a ON a.localpart = t.localpart
WHERE t.token_hash = ?`
// TokenInfo implements storage.UserStore.
func (u *Users) TokenInfo(ctx context.Context, tokenHash string) (storage.TokenInfo, error) {
var info storage.TokenInfo
err := u.db.QueryRowContext(ctx, u.q(selectTokenInfoSQL), tokenHash).Scan(
&info.Localpart, &info.DeviceID, &info.ExpiresAtMS, &info.Admin, &info.Deactivated)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.TokenInfo{}, storage.ErrNoSuchToken
case err != nil:
return storage.TokenInfo{}, fmt.Errorf("look up access token: %w", err)
}
return info, nil
}
// TouchDevice implements storage.UserStore. A device that has gone away
// between authenticating and recording the visit is not an error: the
// request is being answered either way.
func (u *Users) TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error {
if _, err := u.db.ExecContext(ctx, u.q(touchDeviceSQL),
seenAtMS, ip, userAgent, localpart, deviceID); err != nil {
return fmt.Errorf("touch device %q: %w", deviceID, err)
}
return nil
}
const (
selectDeviceSQL = `
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
FROM user_devices WHERE localpart = ? AND device_id = ?`
selectDevicesSQL = `
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
FROM user_devices WHERE localpart = ? ORDER BY device_id`
updateDeviceDisplayNameSQL = `
UPDATE user_devices SET display_name = ? WHERE localpart = ? AND device_id = ?`
)
// Device implements storage.UserStore.
func (u *Users) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
var d storage.Device
err := u.db.QueryRowContext(ctx, u.q(selectDeviceSQL), localpart, deviceID).Scan(
&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, storage.ErrNoSuchDevice)
case err != nil:
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, err)
}
return d, nil
}
// Devices implements storage.UserStore.
func (u *Users) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
rows, err := u.db.QueryContext(ctx, u.q(selectDevicesSQL), localpart)
if err != nil {
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
}
defer rows.Close()
devices := []storage.Device{}
for rows.Next() {
var d storage.Device
if err := rows.Scan(&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent); err != nil {
return nil, fmt.Errorf("scan device: %w", err)
}
devices = append(devices, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
}
return devices, nil
}
// SetDeviceDisplayName implements storage.UserStore.
func (u *Users) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
res, err := u.db.ExecContext(ctx, u.q(updateDeviceDisplayNameSQL), displayName, localpart, deviceID)
if err != nil {
return fmt.Errorf("rename device %q: %w", deviceID, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rename device %q: %w", deviceID, err)
}
if affected == 0 {
return fmt.Errorf("rename device %q: %w", deviceID, storage.ErrNoSuchDevice)
}
return nil
}
// DeleteDevices implements storage.UserStore.
//
// Tokens are deleted explicitly rather than left to ON DELETE CASCADE: SQLite
// only enforces foreign keys when the connection asks it to, so relying on
// the cascade would make revocation depend on a DSN pragma.
func (u *Users) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error) {
if len(deviceIDs) == 0 {
return 0, nil
}
args := make([]any, 0, len(deviceIDs)+1)
args = append(args, localpart)
for _, id := range deviceIDs {
args = append(args, id)
}
in := "(" + strings.TrimSuffix(strings.Repeat("?,", len(deviceIDs)), ",") + ")"
var deleted int
err := u.tx(ctx, func(tx *sql.Tx) error {
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
query := "DELETE FROM " + table + " WHERE localpart = ? AND device_id IN " + in
if _, err := tx.ExecContext(ctx, u.q(query), args...); err != nil {
return fmt.Errorf("revoke tokens: %w", err)
}
}
res, err := tx.ExecContext(ctx,
u.q("DELETE FROM user_devices WHERE localpart = ? AND device_id IN "+in), args...)
if err != nil {
return fmt.Errorf("delete devices: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete devices: %w", err)
}
deleted = int(affected)
return nil
})
return deleted, err
}
// DeleteAllDevices implements storage.UserStore.
func (u *Users) DeleteAllDevices(ctx context.Context, localpart string) (int, error) {
var deleted int
err := u.tx(ctx, func(tx *sql.Tx) error {
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
if _, err := tx.ExecContext(ctx,
u.q("DELETE FROM "+table+" WHERE localpart = ?"), localpart); err != nil {
return fmt.Errorf("revoke tokens of %q: %w", localpart, err)
}
}
res, err := tx.ExecContext(ctx,
u.q("DELETE FROM user_devices WHERE localpart = ?"), localpart)
if err != nil {
return fmt.Errorf("delete devices of %q: %w", localpart, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete devices of %q: %w", localpart, err)
}
deleted = int(affected)
return nil
})
return deleted, err
}
const (
selectAccountDataSQL = `
SELECT content FROM user_account_data
WHERE localpart = ? AND room_id = ? AND data_type = ?`
upsertAccountDataSQL = `
INSERT INTO user_account_data (localpart, room_id, data_type, content)
VALUES (?, ?, ?, ?)
ON CONFLICT (localpart, room_id, data_type)
DO UPDATE SET content = excluded.content`
)
// AccountData implements storage.UserStore.
func (u *Users) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
var content string
err := u.db.QueryRowContext(ctx, u.q(selectAccountDataSQL), localpart, roomID, dataType).Scan(&content)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, fmt.Errorf("account data %q: %w", dataType, storage.ErrNoSuchAccountData)
case err != nil:
return nil, fmt.Errorf("account data %q: %w", dataType, err)
}
return []byte(content), nil
}
// SetAccountData implements storage.UserStore. The content is stored as text
// rather than a JSON column type: the two engines disagree on what JSON
// normalisation does, and account data has to come back out byte for byte.
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
if _, err := u.db.ExecContext(ctx, u.q(upsertAccountDataSQL),
localpart, roomID, dataType, string(content)); err != nil {
return fmt.Errorf("set account data %q: %w", dataType, err)
}
return nil
}
// Compile-time proof that the shared implementation satisfies the interface.
var _ storage.UserStore = (*Users)(nil)