// 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/config" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/ratelimit" "github.com/dariuszjaniak/warp/server/internal/settings" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // ClientAPI serves the Client-Server API. type ClientAPI struct { serverName string clientBaseURL string users *userapi.API settings *settings.Service federation *federation.Gate registration config.Registration 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 } // 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 // 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 // Registration is the file configuration of the registration flow. Registration config.Registration // 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 } c := &ClientAPI{ serverName: opts.ServerName, clientBaseURL: opts.ClientBaseURL, users: opts.Users, settings: opts.Settings, federation: opts.Federation, registration: opts.Registration, 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)) } else { c.limitDefault, c.limitLogin, c.limitRegister = ratelimit.Disabled(), 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, "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.authed(r, "GET "+v3+"/sync", c.limitDefault, c.handleSync) } // 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.Middleware(c.clientIP)(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.Middleware(c.clientIP)(httputil.Wrap(c.withAuth(h)))) }