// 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/delayedevents" "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/storage" "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 turn config.TURN delayed *delayedevents.Service rtcTransports []RTCTransport openID storage.OpenIDTokenStore 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 // TURN is the relay handed to clients for calls. TURN config.TURN // RTC is the group call backend, listed at the MSC4143 transports // endpoint. RTC config.RTC // DelayedEvents schedules MSC4140 delayed events. Nil, or a service // with delayed events switched off, leaves the endpoints unregistered // and refuses the delay parameter. DelayedEvents *delayedevents.Service // OpenIDTokens keeps the OpenID tokens a user hands third parties, // such as the LiveKit JWT service of a group call. Nil leaves the // OpenID endpoints unregistered. OpenIDTokens storage.OpenIDTokenStore // 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, turn: opts.TURN, delayed: opts.DelayedEvents, rtcTransports: RTCTransports(opts.RTC.LiveKitServiceURL), openID: opts.OpenIDTokens, 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) // Calls: the relay a client sets its peer connection up with. c.authed(r, "GET "+v3+"/voip/turnServer", c.limitDefault, c.handleTurnServer) c.registerOpenIDRoutes(r) c.registerDelayedEventRoutes(r) c.registerRTCRoutes(r) // 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, "POST "+slidingSyncPath, c.limitSync, c.handleSlidingSync) 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) // Relations and threads, which the spec introduced at v1. c.authed(r, "GET "+v1+"/rooms/{roomID}/relations/{eventID}", c.limitDefault, c.handleRelations) c.authed(r, "GET "+v1+"/rooms/{roomID}/relations/{eventID}/{relType}", c.limitDefault, c.handleRelations) c.authed(r, "GET "+v1+"/rooms/{roomID}/relations/{eventID}/{relType}/{eventType}", c.limitDefault, c.handleRelations) c.authed(r, "GET "+v1+"/rooms/{roomID}/threads", c.limitDefault, c.handleThreads) c.authed(r, "POST "+v3+"/search", c.limitDefault, c.handleSearch) // 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) // Spaces, and the summary of a room the user may not be in. c.authed(r, "GET "+v1+"/rooms/{roomID}/hierarchy", c.limitDefault, c.handleHierarchy) c.authed(r, "GET "+v1+"/room_summary/{roomIDOrAlias}", c.limitDefault, c.handleRoomSummary) // 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)))) }