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>
576 lines
18 KiB
Go
576 lines
18 KiB
Go
// Package userapi owns accounts, passwords, devices, tokens and account
|
|
// data: everything that identifies a local user and their sessions.
|
|
//
|
|
// It is the component the Client-Server API handlers call for anything
|
|
// to do with who is making a request. The HTTP shapes live in clientapi;
|
|
// what is here is the part that would be the same behind any transport —
|
|
// which is also what makes `warp create-account` able to create an account
|
|
// without starting a server.
|
|
//
|
|
// Nothing in this package knows about rooms. A device's encryption keys
|
|
// are the keyserver's and its pushers the push server's, and both hear
|
|
// about a device going away through the DeviceObserver. Push rules are
|
|
// account data, but the push server is their only writer.
|
|
package userapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Errors callers have to tell apart. The storage sentinels are re-exported
|
|
// rather than translated: there is one error value with two names, so
|
|
// errors.Is works whichever package a caller reaches for.
|
|
var (
|
|
ErrUserExists = storage.ErrUserExists
|
|
ErrNoSuchUser = storage.ErrNoSuchUser
|
|
ErrNoSuchDevice = storage.ErrNoSuchDevice
|
|
ErrNoSuchToken = storage.ErrNoSuchToken
|
|
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
|
|
|
|
// ErrTokenExpired is an access token that was valid and has timed out.
|
|
// It is distinct from ErrNoSuchToken because the spec answers it with a
|
|
// soft logout: the client may keep its encryption state and log in
|
|
// again.
|
|
ErrTokenExpired = errors.New("access token has expired")
|
|
|
|
// ErrAccountDeactivated is a token belonging to an account that has
|
|
// been deactivated.
|
|
ErrAccountDeactivated = errors.New("account has been deactivated")
|
|
|
|
// ErrWrongPassword is a failed password check. It is deliberately the
|
|
// same error whether the account exists or not, so that callers cannot
|
|
// turn it into a user-enumeration oracle by accident.
|
|
ErrWrongPassword = errors.New("invalid password")
|
|
|
|
// ErrNoPassword is an account that has no password set and so cannot
|
|
// use m.login.password.
|
|
ErrNoPassword = errors.New("account has no password")
|
|
|
|
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
|
|
// long or contains control characters.
|
|
ErrInvalidDeviceID = errors.New("invalid device ID")
|
|
)
|
|
|
|
// API is the userapi component.
|
|
type API struct {
|
|
serverName string
|
|
store storage.UserStore
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
accessTokenLifetime time.Duration
|
|
refreshTokenLifetime time.Duration
|
|
|
|
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
|
|
lastSeen sync.Map
|
|
|
|
// observer hears about deleted and renamed devices; nil hears nothing.
|
|
observer DeviceObserver
|
|
|
|
// accounts hears about deactivated accounts; nil hears nothing.
|
|
accounts AccountObserver
|
|
}
|
|
|
|
// DeviceObserver is told about changes to devices that something else
|
|
// keeps state for: the keyserver, whose keys for a deleted device have to
|
|
// go with it. *keyserver.API satisfies it.
|
|
//
|
|
// It is called after the change has been made, on the request path, and
|
|
// must not fail it: the device is already gone.
|
|
type DeviceObserver interface {
|
|
// DevicesDeleted is called with the deleted device IDs, or with none
|
|
// when every device of the user was deleted.
|
|
DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string)
|
|
|
|
// DeviceRenamed is called when a device's display name changes.
|
|
DeviceRenamed(ctx context.Context, localpart, deviceID string)
|
|
}
|
|
|
|
// SetDeviceObserver connects the observer. It is a setter because the
|
|
// keyserver is built after the userapi it reads devices from.
|
|
func (a *API) SetDeviceObserver(o DeviceObserver) { a.observer = o }
|
|
|
|
// Options configure the component.
|
|
type Options struct {
|
|
// ServerName is the Matrix server name; it becomes the domain part of
|
|
// every user ID this component hands out.
|
|
ServerName string
|
|
|
|
// Store is the persistence layer.
|
|
Store storage.UserStore
|
|
|
|
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
|
|
// that never expire.
|
|
AccessTokenLifetime time.Duration
|
|
|
|
// RefreshTokenLifetime bounds how long a refresh token stays usable.
|
|
// Zero means no expiry.
|
|
RefreshTokenLifetime time.Duration
|
|
|
|
Logger *slog.Logger
|
|
|
|
// Now is the clock, so tests can control token expiry. Nil means
|
|
// time.Now.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// New builds the component.
|
|
func New(opts Options) (*API, error) {
|
|
if opts.ServerName == "" {
|
|
return nil, errors.New("userapi: ServerName must not be empty")
|
|
}
|
|
if opts.Store == nil {
|
|
return nil, errors.New("userapi: Store must not be nil")
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.Default()
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
return &API{
|
|
serverName: opts.ServerName,
|
|
store: opts.Store,
|
|
log: opts.Logger,
|
|
now: opts.Now,
|
|
accessTokenLifetime: opts.AccessTokenLifetime,
|
|
refreshTokenLifetime: opts.RefreshTokenLifetime,
|
|
}, nil
|
|
}
|
|
|
|
// ServerName returns the server name user IDs are built with.
|
|
func (a *API) ServerName() string { return a.serverName }
|
|
|
|
// nowMS is the current time in the unit the store uses.
|
|
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
|
|
|
|
// NewAccountRequest describes an account to create.
|
|
type NewAccountRequest struct {
|
|
// Localpart is normalised and validated by CreateAccount.
|
|
Localpart string
|
|
|
|
// Password may be empty, which creates an account that cannot log in
|
|
// with a password. `warp create-account` uses that for a placeholder
|
|
// account; the admin API does not offer it.
|
|
Password string
|
|
|
|
DisplayName string
|
|
Admin bool
|
|
}
|
|
|
|
// CreateAccount validates the request, hashes the password and stores the
|
|
// account. It returns ErrUserExists if the name is taken and
|
|
// ErrInvalidLocalpart if the name is not usable.
|
|
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
|
|
localpart := NormaliseLocalpart(req.Localpart)
|
|
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
|
|
var hash string
|
|
if req.Password != "" {
|
|
var err error
|
|
if hash, err = HashPassword(req.Password); err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
}
|
|
|
|
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
|
|
Localpart: localpart,
|
|
PasswordHash: hash,
|
|
DisplayName: req.DisplayName,
|
|
Admin: req.Admin,
|
|
CreatedAtMS: a.nowMS(),
|
|
})
|
|
if err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
|
|
return acc, nil
|
|
}
|
|
|
|
// Account reads an account by normalised localpart.
|
|
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
|
|
return a.store.Account(ctx, localpart)
|
|
}
|
|
|
|
// AccountExists reports whether a localpart is taken. It is the answer
|
|
// behind /register/available, and deliberately does not distinguish a
|
|
// deactivated account: the name stays reserved.
|
|
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
|
|
switch _, err := a.store.Account(ctx, localpart); {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.Is(err, ErrNoSuchUser):
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// SetAdmin grants or revokes the admin flag.
|
|
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
|
|
return a.store.SetAdmin(ctx, localpart, admin)
|
|
}
|
|
|
|
// SetPassword replaces an account's password.
|
|
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
|
|
hash, err := HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.store.SetPasswordHash(ctx, localpart, hash)
|
|
}
|
|
|
|
// CheckPassword verifies a password against an account.
|
|
//
|
|
// Every failure path costs one argon2id hash, including an account that does
|
|
// not exist or has no password, so the time a rejection takes says nothing
|
|
// about which user names are real.
|
|
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
|
|
hash, err := a.store.PasswordHash(ctx, localpart)
|
|
switch {
|
|
case errors.Is(err, ErrNoSuchUser):
|
|
_, _ = VerifyPassword(dummyPasswordHash, password)
|
|
return ErrWrongPassword
|
|
case err != nil:
|
|
return err
|
|
}
|
|
if hash == "" {
|
|
_, _ = VerifyPassword(dummyPasswordHash, password)
|
|
return ErrNoPassword
|
|
}
|
|
|
|
ok, err := VerifyPassword(hash, password)
|
|
switch {
|
|
case errors.Is(err, ErrPasswordTooLong):
|
|
return ErrWrongPassword
|
|
case err != nil:
|
|
// A malformed hash is an operational problem, not a wrong password.
|
|
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
|
|
return err
|
|
case !ok:
|
|
return ErrWrongPassword
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SessionRequest asks for a new device and its tokens.
|
|
type SessionRequest struct {
|
|
// Localpart must already be normalised and must exist.
|
|
Localpart string
|
|
|
|
// DeviceID is the client's choice. Empty means generate one. Naming a
|
|
// device that already exists re-uses it and revokes its old tokens.
|
|
DeviceID string
|
|
|
|
// DisplayName is initial_device_display_name: used only when the device
|
|
// is created.
|
|
DisplayName string
|
|
|
|
// WithRefreshToken issues a refresh token alongside the access token.
|
|
WithRefreshToken bool
|
|
|
|
IP string
|
|
UserAgent string
|
|
}
|
|
|
|
// Session is the result of a login, a registration or a token refresh: what
|
|
// the client needs to start making requests.
|
|
type Session struct {
|
|
UserID string
|
|
Localpart string
|
|
DeviceID string
|
|
|
|
AccessToken string
|
|
|
|
// RefreshToken is empty unless the client asked for one.
|
|
RefreshToken string
|
|
|
|
// ExpiresInMS is how long the access token is good for, or 0 when it
|
|
// does not expire.
|
|
ExpiresInMS int64
|
|
}
|
|
|
|
// CreateSession issues a device and tokens for an existing account.
|
|
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
|
|
deviceID := req.DeviceID
|
|
if deviceID == "" {
|
|
var err error
|
|
if deviceID, err = GenerateDeviceID(); err != nil {
|
|
return Session{}, err
|
|
}
|
|
} else if err := ValidateDeviceID(deviceID); err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
accessToken, err := GenerateAccessToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
var refreshToken string
|
|
if req.WithRefreshToken {
|
|
if refreshToken, err = GenerateRefreshToken(); err != nil {
|
|
return Session{}, err
|
|
}
|
|
}
|
|
|
|
now := a.nowMS()
|
|
session := storage.NewSession{
|
|
Localpart: req.Localpart,
|
|
DeviceID: deviceID,
|
|
DisplayName: req.DisplayName,
|
|
CreatedAtMS: now,
|
|
AccessTokenHash: HashToken(accessToken),
|
|
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
|
|
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
|
|
LastSeenIP: req.IP,
|
|
UserAgent: req.UserAgent,
|
|
}
|
|
if refreshToken != "" {
|
|
session.RefreshTokenHash = HashToken(refreshToken)
|
|
}
|
|
|
|
device, err := a.store.CreateSession(ctx, session)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{
|
|
UserID: a.UserID(req.Localpart),
|
|
Localpart: req.Localpart,
|
|
DeviceID: device.ID,
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
|
|
}, nil
|
|
}
|
|
|
|
// RefreshSession spends a refresh token and issues a new pair. It returns
|
|
// ErrNoSuchToken if the token is unknown or already spent.
|
|
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
|
|
accessToken, err := GenerateAccessToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
newRefreshToken, err := GenerateRefreshToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
now := a.nowMS()
|
|
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
|
|
RefreshTokenHash: HashToken(refreshToken),
|
|
NewAccessTokenHash: HashToken(accessToken),
|
|
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
|
|
NewRefreshTokenHash: HashToken(newRefreshToken),
|
|
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
|
|
RefreshedAtMS: now,
|
|
})
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
if info.Deactivated {
|
|
return Session{}, ErrAccountDeactivated
|
|
}
|
|
return Session{
|
|
UserID: a.UserID(info.Localpart),
|
|
Localpart: info.Localpart,
|
|
DeviceID: info.DeviceID,
|
|
AccessToken: accessToken,
|
|
RefreshToken: newRefreshToken,
|
|
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
|
|
}, nil
|
|
}
|
|
|
|
// Authenticated is the identity behind a request: what the auth middleware
|
|
// resolves an access token into and what every authenticated handler is
|
|
// given.
|
|
type Authenticated struct {
|
|
UserID string
|
|
Localpart string
|
|
DeviceID string
|
|
Admin bool
|
|
|
|
// AppService is the ID of the application service making the request,
|
|
// acting as UserID, or empty for an ordinary access token. A request
|
|
// made with an application service token has no device: DeviceID is
|
|
// empty.
|
|
AppService string
|
|
}
|
|
|
|
// Authenticate resolves an access token.
|
|
//
|
|
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
|
|
// that has timed out, and ErrAccountDeactivated for a token whose account is
|
|
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
|
|
// M_USER_DEACTIVATED respectively.
|
|
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
|
|
if accessToken == "" {
|
|
return Authenticated{}, ErrNoSuchToken
|
|
}
|
|
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
|
|
if err != nil {
|
|
return Authenticated{}, err
|
|
}
|
|
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
|
|
return Authenticated{}, ErrTokenExpired
|
|
}
|
|
if info.Deactivated {
|
|
return Authenticated{}, ErrAccountDeactivated
|
|
}
|
|
return Authenticated{
|
|
UserID: a.UserID(info.Localpart),
|
|
Localpart: info.Localpart,
|
|
DeviceID: info.DeviceID,
|
|
Admin: info.Admin,
|
|
}, nil
|
|
}
|
|
|
|
// lastSeenInterval is how stale the last-seen record of a device may get.
|
|
// Recording it on every request would double the write load of the whole C-S
|
|
// API to keep a field nothing reads more often than a user opens their
|
|
// device list.
|
|
const lastSeenInterval = time.Minute
|
|
|
|
// RecordActivity updates a device's last-seen timestamp, address and user
|
|
// agent, at most once per lastSeenInterval per device. A failure is logged
|
|
// and swallowed: the request it belongs to has already been authorised, and
|
|
// bookkeeping must not turn into a 500.
|
|
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
|
|
key := auth.Localpart + "\x00" + auth.DeviceID
|
|
now := a.now()
|
|
|
|
if previous, ok := a.lastSeen.Load(key); ok {
|
|
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
|
|
return
|
|
}
|
|
}
|
|
a.lastSeen.Store(key, now)
|
|
|
|
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
|
|
a.log.Warn("recording device activity failed",
|
|
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Device reads one of a user's devices.
|
|
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
|
|
return a.store.Device(ctx, localpart, deviceID)
|
|
}
|
|
|
|
// Devices lists a user's devices.
|
|
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
|
|
return a.store.Devices(ctx, localpart)
|
|
}
|
|
|
|
// SetDeviceDisplayName renames a device.
|
|
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
|
|
if err := a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName); err != nil {
|
|
return err
|
|
}
|
|
if a.observer != nil {
|
|
a.observer.DeviceRenamed(ctx, localpart, deviceID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteDevices revokes devices and their tokens. It is what /logout,
|
|
// /delete_devices and DELETE /devices/{id} all end up calling.
|
|
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
|
|
if len(deviceIDs) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
|
|
return err
|
|
}
|
|
a.forgetLastSeen(localpart, deviceIDs...)
|
|
if a.observer != nil {
|
|
a.observer.DevicesDeleted(ctx, localpart, deviceIDs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteAllDevices revokes every device of a user: /logout/all.
|
|
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
|
|
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
|
|
return err
|
|
}
|
|
a.forgetLastSeen(localpart)
|
|
if a.observer != nil {
|
|
a.observer.DevicesDeleted(ctx, localpart, nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// forgetLastSeen drops the throttle entries for devices that no longer
|
|
// exist, so the map does not grow with every logout. With no device IDs it
|
|
// clears every entry of the user.
|
|
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
|
|
if len(deviceIDs) > 0 {
|
|
for _, id := range deviceIDs {
|
|
a.lastSeen.Delete(localpart + "\x00" + id)
|
|
}
|
|
return
|
|
}
|
|
prefix := localpart + "\x00"
|
|
a.lastSeen.Range(func(key, _ any) bool {
|
|
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
|
|
a.lastSeen.Delete(key)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// SetDisplayName updates a user's profile display name.
|
|
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
|
|
return a.store.SetDisplayName(ctx, localpart, displayName)
|
|
}
|
|
|
|
// SetAvatarURL updates a user's profile avatar.
|
|
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
|
|
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
|
|
}
|
|
|
|
// AccountData reads one account data blob; roomID is empty for global data.
|
|
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
|
|
return a.store.AccountData(ctx, localpart, roomID, dataType)
|
|
}
|
|
|
|
// SetAccountData writes one account data blob.
|
|
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
|
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
|
|
}
|
|
|
|
// ListAccounts returns a page of accounts for the admin API.
|
|
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
|
|
return a.store.ListAccounts(ctx, filter)
|
|
}
|
|
|
|
// Stats returns the aggregate user counts for the admin API.
|
|
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
|
|
return a.store.Stats(ctx)
|
|
}
|
|
|
|
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
|
|
// or 0 for "never".
|
|
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
|
|
if lifetime <= 0 {
|
|
return 0
|
|
}
|
|
return nowMS + lifetime.Milliseconds()
|
|
}
|
|
|
|
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
|
|
func lifetimeMS(lifetime time.Duration) int64 {
|
|
if lifetime <= 0 {
|
|
return 0
|
|
}
|
|
return lifetime.Milliseconds()
|
|
}
|