Implements PLAN.md Phase 8. internal/pushrules is the rule language: the spec's default ruleset, all six condition kinds, glob and word-boundary matching, intentional mentions switching off the legacy body rules, and rule ordering as Synapse has it. internal/pushserver keeps the rules, pushers and notifications and talks to push gateways. Push rules are the m.push_rules account data, written only by the push server; reads keep the user's own rules and the enabled/actions of each default and take the rest from the current defaults, so new default rules reach every user without a migration. PUT account_data/m.push_rules is refused with 405. The push server listens on the roomserver's emit stage and evaluates each event on its own worker for every local member and an invite's target. Notifications and per-room unread counts are stored in migration 0010; counts are a seventh sync token stream, so /sync reports unread_notifications and picks up count changes made after the event or by a receipt from another device. m.read and m.read.private receipts clear the counts; leaving a room clears it. Pushers (http kind only) push through a bounded sender pool with retries, honour event_id_only, drop push keys the gateway rejects, and go away with their device. GET /notifications pages the history. New config section push: enabled, include_content, gateway_timeout, notification_retention. Account data writes now wake the user's blocked syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
16 KiB
Go
386 lines
16 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/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
|
|
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
|
|
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
|
|
|
|
// 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,
|
|
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,
|
|
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))
|
|
// 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, "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.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))))
|
|
}
|