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>
419 lines
17 KiB
Go
419 lines
17 KiB
Go
// Package clientapi implements the Client-Server API: the HTTP surface
|
|
// clients talk to.
|
|
//
|
|
// Phase 1 covers everything about identity — registration, login, sessions,
|
|
// devices, profiles, account data and capabilities. Rooms, messages and
|
|
// /sync arrive in Phases 3 and 4; the route table here is the place they
|
|
// will be added.
|
|
//
|
|
// Handlers are thin. They decode a request, map it onto a userapi call and
|
|
// shape the response; the rules live in userapi, and the storage in
|
|
// internal/storage. A handler that needs more than that is a sign something
|
|
// belongs further down.
|
|
package clientapi
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/appservice"
|
|
"github.com/dariuszjaniak/warp/server/internal/config"
|
|
"github.com/dariuszjaniak/warp/server/internal/federation"
|
|
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
|
"github.com/dariuszjaniak/warp/server/internal/keyserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
|
|
"github.com/dariuszjaniak/warp/server/internal/pushserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/settings"
|
|
"github.com/dariuszjaniak/warp/server/internal/syncapi"
|
|
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
|
)
|
|
|
|
// ClientAPI serves the Client-Server API.
|
|
type ClientAPI struct {
|
|
serverName string
|
|
clientBaseURL string
|
|
users *userapi.API
|
|
rooms *roomserver.API
|
|
sync *syncapi.API
|
|
keys *keyserver.API
|
|
media *mediaapi.API
|
|
push *pushserver.API
|
|
legacyMedia bool
|
|
settings *settings.Service
|
|
federation *federation.Gate
|
|
remoteProfile RemoteProfiles
|
|
regTokens *userapi.RegistrationTokens
|
|
appservices *appservice.API
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
uia *uiaStore
|
|
clientIP ratelimit.KeyFunc
|
|
|
|
// One limiter per endpoint class, so a client hammering /login cannot
|
|
// spend the budget of /profile.
|
|
limitDefault *ratelimit.Limiter
|
|
limitLogin *ratelimit.Limiter
|
|
limitRegister *ratelimit.Limiter
|
|
limitRoomCreate *ratelimit.Limiter
|
|
limitJoin *ratelimit.Limiter
|
|
|
|
// limitSync is deliberately generous. A client is in /sync all the
|
|
// time, and a long poll that is refused leaves it reconnecting in a
|
|
// loop — the one place where rate limiting makes the load worse rather
|
|
// than better.
|
|
limitSync *ratelimit.Limiter
|
|
|
|
// limitMedia covers downloads and thumbnails, which a client asks for
|
|
// in bursts: every avatar in a room list at once.
|
|
limitMedia *ratelimit.Limiter
|
|
}
|
|
|
|
// Options configure the component.
|
|
type Options struct {
|
|
// ServerName is the Matrix server name.
|
|
ServerName string
|
|
|
|
// ClientBaseURL is the URL advertised to clients, the same value
|
|
// /.well-known/matrix/client carries. A successful login repeats it so
|
|
// that a client which only knew the server name does not have to fetch
|
|
// the discovery document separately.
|
|
ClientBaseURL string
|
|
|
|
// Users is the userapi component.
|
|
Users *userapi.API
|
|
|
|
// Rooms is the roomserver component. Without it the room endpoints are
|
|
// not registered at all, which is what the Phase 1 and Phase 2 tests
|
|
// relied on and what keeps a half-wired server from offering a
|
|
// createRoom it cannot honour.
|
|
Rooms *roomserver.API
|
|
|
|
// Sync is the syncapi component. Like the roomserver it is optional,
|
|
// and for the same reason: without it /sync, /messages, /context and
|
|
// the ephemeral endpoints answer M_UNRECOGNIZED rather than
|
|
// pretending there is a stream to read.
|
|
Sync *syncapi.API
|
|
|
|
// Keys is the keyserver. Without it the end-to-end encryption
|
|
// endpoints answer M_UNRECOGNIZED, which is how a client learns it
|
|
// cannot encrypt here.
|
|
Keys *keyserver.API
|
|
|
|
// Media is the media repository. Without it the media endpoints
|
|
// answer M_UNRECOGNIZED.
|
|
Media *mediaapi.API
|
|
|
|
// Push is the push server. Without it the push rule, pusher and
|
|
// notification endpoints answer M_UNRECOGNIZED.
|
|
Push *pushserver.API
|
|
|
|
// LegacyMediaDownloads serves the deprecated unauthenticated download
|
|
// and thumbnail endpoints too.
|
|
LegacyMediaDownloads bool
|
|
|
|
// Settings is the runtime-settings service; the registration switch is
|
|
// read from it on every registration attempt.
|
|
Settings *settings.Service
|
|
|
|
// Federation gates the endpoints that would need a remote server.
|
|
Federation *federation.Gate
|
|
|
|
// RemoteProfiles asks another server for one of its users' profile.
|
|
// Nil means remote profiles cannot be looked up at all.
|
|
RemoteProfiles RemoteProfiles
|
|
|
|
// Registration is the file configuration of the registration flow.
|
|
Registration config.Registration
|
|
|
|
// RegistrationTokens answers the m.login.registration_token stage.
|
|
// Nil builds one from Registration's static tokens alone.
|
|
RegistrationTokens *userapi.RegistrationTokens
|
|
|
|
// AppServices is the application service component: who may act as
|
|
// whom, the exclusive namespaces, and the queries. Nil means no
|
|
// application services are registered.
|
|
AppServices *appservice.API
|
|
|
|
// RateLimit configures the per-endpoint buckets.
|
|
RateLimit config.RateLimit
|
|
|
|
// TrustXForwardedFor takes the client address from the proxy header.
|
|
TrustXForwardedFor bool
|
|
|
|
Logger *slog.Logger
|
|
|
|
// Now is the clock, so tests can expire sessions without waiting.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// New builds the component.
|
|
func New(opts Options) (*ClientAPI, error) {
|
|
if opts.ServerName == "" {
|
|
return nil, errors.New("clientapi: ServerName must not be empty")
|
|
}
|
|
if opts.Users == nil {
|
|
return nil, errors.New("clientapi: Users must not be nil")
|
|
}
|
|
if opts.Settings == nil {
|
|
return nil, errors.New("clientapi: Settings must not be nil")
|
|
}
|
|
if opts.Federation == nil {
|
|
opts.Federation = federation.NewGate(nil, nil)
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.Default()
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
if opts.RegistrationTokens == nil {
|
|
now := opts.Now
|
|
opts.RegistrationTokens = userapi.NewRegistrationTokens(userapi.RegistrationTokensOptions{
|
|
Static: opts.Registration.Tokens,
|
|
Required: opts.Registration.RequireToken,
|
|
NowMS: func() int64 { return now().UnixMilli() },
|
|
})
|
|
}
|
|
if opts.AppServices == nil {
|
|
var err error
|
|
if opts.AppServices, err = appservice.New(appservice.Options{Logger: opts.Logger}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
c := &ClientAPI{
|
|
serverName: opts.ServerName,
|
|
clientBaseURL: opts.ClientBaseURL,
|
|
users: opts.Users,
|
|
rooms: opts.Rooms,
|
|
sync: opts.Sync,
|
|
keys: opts.Keys,
|
|
media: opts.Media,
|
|
push: opts.Push,
|
|
legacyMedia: opts.LegacyMediaDownloads,
|
|
settings: opts.Settings,
|
|
federation: opts.Federation,
|
|
remoteProfile: opts.RemoteProfiles,
|
|
regTokens: opts.RegistrationTokens,
|
|
appservices: opts.AppServices,
|
|
log: opts.Logger,
|
|
now: opts.Now,
|
|
uia: newUIAStore(uiaSessionTTL, opts.Now),
|
|
clientIP: ratelimit.ClientIP(opts.TrustXForwardedFor),
|
|
}
|
|
|
|
if opts.RateLimit.Enabled {
|
|
c.limitDefault = ratelimit.New(bucket(opts.RateLimit.Default))
|
|
c.limitLogin = ratelimit.New(bucket(opts.RateLimit.Login))
|
|
c.limitRegister = ratelimit.New(bucket(opts.RateLimit.Register))
|
|
// Creating a room writes a dozen events and joining one writes a
|
|
// membership event into a room everybody in it then sees, so both
|
|
// get their own, tighter bucket: the default one is sized for
|
|
// reads.
|
|
c.limitRoomCreate = ratelimit.New(bucket(opts.RateLimit.RoomCreate))
|
|
c.limitJoin = ratelimit.New(bucket(opts.RateLimit.Join))
|
|
c.limitSync = ratelimit.New(bucket(opts.RateLimit.Sync))
|
|
c.limitMedia = ratelimit.New(bucket(opts.RateLimit.Media))
|
|
} else {
|
|
c.limitDefault, c.limitLogin, c.limitRegister =
|
|
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
|
|
c.limitRoomCreate, c.limitJoin = ratelimit.Disabled(), ratelimit.Disabled()
|
|
c.limitSync, c.limitMedia = ratelimit.Disabled(), ratelimit.Disabled()
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func bucket(b config.RateLimitBucket) ratelimit.Policy {
|
|
return ratelimit.Policy{Burst: b.Burst, Interval: b.Interval}
|
|
}
|
|
|
|
// Client-Server API path prefixes. Warp serves the versioned paths only:
|
|
// the r0 prefix was deprecated in Matrix 1.4, and every client that still
|
|
// sends it also understands a 404 and falls back to version negotiation
|
|
// through /_matrix/client/versions.
|
|
const (
|
|
v3 = "/_matrix/client/v3"
|
|
v1 = "/_matrix/client/v1"
|
|
)
|
|
|
|
// RegisterRoutes adds the Client-Server endpoints to the router.
|
|
func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
|
|
// Registration. The token-validity endpoint is v1 because that is the
|
|
// version the spec introduced it at.
|
|
c.public(r, "POST "+v3+"/register", c.limitRegister, c.handleRegister)
|
|
c.public(r, "GET "+v3+"/register/available", c.limitRegister, c.handleRegisterAvailable)
|
|
c.public(r, "GET "+v1+"/register/m.login.registration_token/validity",
|
|
c.limitRegister, c.handleRegistrationTokenValidity)
|
|
|
|
// Sessions.
|
|
c.public(r, "GET "+v3+"/login", c.limitDefault, c.handleLoginFlows)
|
|
c.public(r, "POST "+v3+"/login", c.limitLogin, c.handleLogin)
|
|
c.public(r, "POST "+v3+"/refresh", c.limitLogin, c.handleRefresh)
|
|
c.authed(r, "POST "+v3+"/logout", c.limitDefault, c.handleLogout)
|
|
c.authed(r, "POST "+v3+"/logout/all", c.limitDefault, c.handleLogoutAll)
|
|
|
|
// Account.
|
|
c.authed(r, "GET "+v3+"/account/whoami", c.limitDefault, c.handleWhoami)
|
|
c.authed(r, "POST "+v3+"/account/password", c.limitLogin, c.handleChangePassword)
|
|
c.authed(r, "POST "+v3+"/account/deactivate", c.limitLogin, c.handleDeactivate)
|
|
|
|
// Application services. The ping is the service checking that this
|
|
// server can reach it, so it is authenticated with the service's own
|
|
// token.
|
|
c.authed(r, "POST "+v1+"/appservice/{appserviceID}/ping", c.limitDefault, c.handleAppServicePing)
|
|
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
|
|
|
|
// Devices.
|
|
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
|
|
c.authed(r, "GET "+v3+"/devices/{deviceID}", c.limitDefault, c.handleGetDevice)
|
|
c.authed(r, "PUT "+v3+"/devices/{deviceID}", c.limitDefault, c.handlePutDevice)
|
|
c.authed(r, "DELETE "+v3+"/devices/{deviceID}", c.limitDefault, c.handleDeleteDevice)
|
|
c.authed(r, "POST "+v3+"/delete_devices", c.limitDefault, c.handleDeleteDevices)
|
|
|
|
// Profiles are world-readable: a client showing a room member's name
|
|
// has no token for that member's server, and the spec makes the GETs
|
|
// unauthenticated for exactly that reason.
|
|
c.public(r, "GET "+v3+"/profile/{userID}", c.limitDefault, c.handleGetProfile)
|
|
c.public(r, "GET "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handleGetDisplayName)
|
|
c.public(r, "GET "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handleGetAvatarURL)
|
|
c.authed(r, "PUT "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handlePutDisplayName)
|
|
c.authed(r, "PUT "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handlePutAvatarURL)
|
|
|
|
// Account data.
|
|
c.authed(r, "GET "+v3+"/user/{userID}/account_data/{dataType}",
|
|
c.limitDefault, c.handleGetAccountData)
|
|
c.authed(r, "PUT "+v3+"/user/{userID}/account_data/{dataType}",
|
|
c.limitDefault, c.handlePutAccountData)
|
|
c.authed(r, "GET "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
|
|
c.limitDefault, c.handleGetRoomAccountData)
|
|
c.authed(r, "PUT "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
|
|
c.limitDefault, c.handlePutRoomAccountData)
|
|
|
|
c.registerRoomRoutes(r)
|
|
c.registerSyncRoutes(r)
|
|
c.registerKeyRoutes(r)
|
|
c.registerMediaRoutes(r)
|
|
c.registerPushRoutes(r)
|
|
}
|
|
|
|
// registerSyncRoutes adds the endpoints the syncapi serves, if there is
|
|
// one.
|
|
//
|
|
// Like the room routes they are left unregistered rather than stubbed in a
|
|
// build without the component: a client that gets M_UNRECOGNIZED from
|
|
// /sync knows the server does not have it, where an empty success would
|
|
// have it believe nothing was happening.
|
|
func (c *ClientAPI) registerSyncRoutes(r *httputil.Router) {
|
|
if c.sync == nil {
|
|
return
|
|
}
|
|
|
|
c.authed(r, "GET "+v3+"/sync", c.limitSync, c.handleSync)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/messages", c.limitDefault, c.handleMessages)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/context/{eventID}", c.limitDefault, c.handleContext)
|
|
|
|
// Filters.
|
|
c.authed(r, "POST "+v3+"/user/{userID}/filter", c.limitDefault, c.handlePostFilter)
|
|
c.authed(r, "GET "+v3+"/user/{userID}/filter/{filterID}", c.limitDefault, c.handleGetFilter)
|
|
|
|
// The ephemeral streams. Typing goes in the sync limiter's class
|
|
// rather than the default one: a client sends one per keystroke-burst,
|
|
// and it is the chattiest write in the API.
|
|
c.authed(r, "PUT "+v3+"/rooms/{roomID}/typing/{userID}", c.limitSync, c.handlePutTyping)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/receipt/{receiptType}/{eventID}",
|
|
c.limitSync, c.handlePostReceipt)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/read_markers", c.limitDefault, c.handlePostReadMarkers)
|
|
|
|
// To-device messaging. A client encrypting for a busy room sends one
|
|
// of these per device, so it shares the sync budget too.
|
|
c.authed(r, "PUT "+v3+"/sendToDevice/{eventType}/{txnID}", c.limitSync, c.handleSendToDevice)
|
|
|
|
// Presence.
|
|
c.authed(r, "GET "+v3+"/presence/{userID}/status", c.limitDefault, c.handleGetPresence)
|
|
c.authed(r, "PUT "+v3+"/presence/{userID}/status", c.limitSync, c.handlePutPresence)
|
|
}
|
|
|
|
// registerRoomRoutes adds the room endpoints, if there is a roomserver.
|
|
//
|
|
// A build without one answers them with M_UNRECOGNIZED, which is the right
|
|
// answer: a server that cannot store a room should say it has no createRoom
|
|
// rather than fail every call to it.
|
|
func (c *ClientAPI) registerRoomRoutes(r *httputil.Router) {
|
|
if c.rooms == nil {
|
|
return
|
|
}
|
|
|
|
c.authed(r, "POST "+v3+"/createRoom", c.limitRoomCreate, c.handleCreateRoom)
|
|
|
|
// Membership. The two join endpoints differ only in what they are
|
|
// addressed by: /join/{roomIdOrAlias} resolves an alias first.
|
|
c.authed(r, "POST "+v3+"/join/{roomIDOrAlias}", c.limitJoin, c.handleJoinRoom)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/join", c.limitJoin, c.handleJoinRoomByID)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/leave", c.limitDefault, c.handleLeaveRoom)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/invite", c.limitDefault, c.handleInvite)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/kick", c.limitDefault, c.handleKick)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/ban", c.limitDefault, c.handleBan)
|
|
c.authed(r, "POST "+v3+"/rooms/{roomID}/unban", c.limitDefault, c.handleUnban)
|
|
|
|
// Sending. The state endpoint is registered twice, because the empty
|
|
// state key is both valid and the common case and arrives in two
|
|
// spellings: ".../state/m.room.name" and ".../state/m.room.name/".
|
|
// The trailing-slash form uses a multi-segment wildcard, which is the
|
|
// only kind Go's router matches against an empty segment — and which
|
|
// also means a state key a client did not escape arrives whole rather
|
|
// than as a 404.
|
|
c.authed(r, "PUT "+v3+"/rooms/{roomID}/send/{eventType}/{txnID}",
|
|
c.limitDefault, c.handleSendEvent)
|
|
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}",
|
|
c.limitDefault, c.handlePutState)
|
|
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
|
|
c.limitDefault, c.handlePutState)
|
|
c.authed(r, "PUT "+v3+"/rooms/{roomID}/redact/{eventID}/{txnID}",
|
|
c.limitDefault, c.handleRedact)
|
|
|
|
// Reading.
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/event/{eventID}", c.limitDefault, c.handleGetEvent)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/state", c.limitDefault, c.handleGetState)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}",
|
|
c.limitDefault, c.handleGetStateEvent)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
|
|
c.limitDefault, c.handleGetStateEvent)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/members", c.limitDefault, c.handleGetMembers)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/joined_members",
|
|
c.limitDefault, c.handleGetJoinedMembers)
|
|
c.authed(r, "GET "+v3+"/joined_rooms", c.limitDefault, c.handleGetJoinedRooms)
|
|
|
|
// The directory. Resolving an alias and reading the public room list
|
|
// need no token: a client handed a link has to be able to follow it
|
|
// before it decides whether to log in.
|
|
c.public(r, "GET "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleGetAlias)
|
|
c.authed(r, "PUT "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handlePutAlias)
|
|
c.authed(r, "DELETE "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleDeleteAlias)
|
|
c.authed(r, "GET "+v3+"/rooms/{roomID}/aliases", c.limitDefault, c.handleGetRoomAliases)
|
|
|
|
c.public(r, "GET "+v3+"/publicRooms", c.limitDefault, c.handleGetPublicRooms)
|
|
c.authed(r, "POST "+v3+"/publicRooms", c.limitDefault, c.handlePostPublicRooms)
|
|
c.public(r, "GET "+v3+"/directory/list/room/{roomID}",
|
|
c.limitDefault, c.handleGetRoomVisibility)
|
|
c.authed(r, "PUT "+v3+"/directory/list/room/{roomID}",
|
|
c.limitDefault, c.handlePutRoomVisibility)
|
|
}
|
|
|
|
// public registers an endpoint that needs no access token.
|
|
func (c *ClientAPI) public(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h httputil.Handler) {
|
|
r.Handle(pattern, limiter.MiddlewareUnless(c.clientIP, c.rateLimitExempt)(httputil.Wrap(h)))
|
|
}
|
|
|
|
// authed registers an endpoint that needs one.
|
|
//
|
|
// The rate limiter still keys on the client address rather than the user:
|
|
// it runs before the token is resolved, and a limiter that had to hit the
|
|
// database to find its own key would be a denial of service of its own.
|
|
func (c *ClientAPI) authed(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h AuthedHandler) {
|
|
r.Handle(pattern, limiter.MiddlewareUnless(c.clientIP, c.rateLimitExempt)(httputil.Wrap(c.withAuth(h))))
|
|
}
|