Files
Warp/server/internal/clientapi/clientapi.go
T
tajniak81andClaude Opus 5.5 325431a5be Voice and video calls: TURN endpoint and web client calls
Server: GET /voip/turnServer, configured under client_api.turn. A shared
secret (coturn use-auth-secret) gives each user expiring TURN REST API
credentials; static credentials are the fallback; with nothing configured
it answers {} so clients still try a direct connection.

Web client: 1:1 calls (Matrix VoIP via matrix-js-sdk) in rooms with one
other person. Voice and video buttons in the room header, a ringing card
with answer, answer without video and decline, and a call panel that
stays up across rooms: mute, camera, screen sharing, expand, hang up,
ring tones. Call events show in the timeline and room list. Works around
the SDK keeping a failed microphone request forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:04:02 +02:00

441 lines
18 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
turn config.TURN
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
// 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,
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)
// 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))))
}