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 9. internal/appservice reads registration files (appservice.config_files), matches namespaces the way Synapse does (anchored at the start), and delivers events to each service as transactions read from the roomserver's event stream at a cursor kept in appservice_state. A transaction in flight is recorded, so after a restart it is resent under the same ID; failures back off from 2s to 5m, and a successful ping cuts the backoff short. In the Client-Server API an as_token acts as the service's sender or, with ?user_id=, as any existing user in its namespace. Services register and log in their users with m.login.application_service; exclusive user and alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown aliases and users in a namespace are asked of the service first. The ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when rate_limited is false, skip the rate limiter. Users can change their password (POST /account/password) and deactivate their account (POST /account/deactivate), both behind the password. Deactivation revokes tokens, deletes devices, optionally erases the profile and leaves every room through a new userapi.AccountObserver. The admin API is complete: user detail, the admin flag, deactivation and reactivation, password resets, devices, media quotas (media.max_user_storage_bytes plus per-user overrides), registration tokens with use limits and expiry (registration.require_token), server notices (internal/servernotices, server_notices.*), an editor for the federation allow/deny lists persisted in server_settings, a delivery view of the application services, and an audit log of every change. Migration 0011 adds the registration token, server notice room, media quota, audit log and appservice cursor tables. Policy lists are now returned sorted. The Complement entrypoint loads the registrations Complement mounts in /complement/appservice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
668 lines
24 KiB
Go
668 lines
24 KiB
Go
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)
|
|
}
|
|
|
|
// SetDeactivated implements storage.UserStore.
|
|
func (u *Users) SetDeactivated(ctx context.Context, localpart string, deactivated bool) error {
|
|
return u.updateAccount(ctx, localpart,
|
|
`UPDATE user_accounts SET deactivated = ? WHERE localpart = ?`, deactivated)
|
|
}
|
|
|
|
// 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, stream_pos)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (localpart, room_id, data_type)
|
|
DO UPDATE SET content = excluded.content, stream_pos = excluded.stream_pos`
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// The write also stamps the row with a position in the account data stream,
|
|
// taken inside the same transaction as the write itself. Account data is
|
|
// one of the streams /sync reports, and a position assigned separately
|
|
// would either be visible before the content it describes or after it — in
|
|
// the second case a client would be told the stream had moved and then find
|
|
// the old value.
|
|
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
|
err := u.tx(ctx, func(tx *sql.Tx) error {
|
|
pos, err := nextSequence(ctx, tx, u.q, syncSequences, seqAccountData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, u.q(upsertAccountDataSQL),
|
|
localpart, roomID, dataType, string(content), pos); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if 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)
|