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 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
696 lines
24 KiB
Go
696 lines
24 KiB
Go
package federationapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
|
"github.com/dariuszjaniak/warp/server/internal/federation"
|
|
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
|
|
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
"github.com/dariuszjaniak/warp/server/internal/syncapi"
|
|
)
|
|
|
|
// maxBody caps a federation request body. A transaction is up to 50 PDUs
|
|
// of up to 64 KiB each, plus EDUs; anything past this is not a
|
|
// transaction.
|
|
const maxBody = 8 << 20
|
|
|
|
// Error codes the federation API uses beyond the Client-Server set.
|
|
const (
|
|
errCodeIncompatibleRoomVersion = "M_INCOMPATIBLE_ROOM_VERSION"
|
|
errCodeUnableToAuthoriseJoin = "M_UNABLE_TO_AUTHORISE_JOIN"
|
|
)
|
|
|
|
// EphemeralSink takes in EDUs from other servers. *syncapi.API satisfies
|
|
// it.
|
|
type EphemeralSink interface {
|
|
SetTyping(ctx context.Context, req syncapi.TypingRequest) error
|
|
SetReceipt(ctx context.Context, req syncapi.ReceiptRequest) error
|
|
SendToDevice(ctx context.Context, req syncapi.SendToDeviceRequest) error
|
|
}
|
|
|
|
// Profiles answers profile queries for local users. *userapi.API
|
|
// satisfies it.
|
|
type Profiles interface {
|
|
Account(ctx context.Context, localpart string) (storage.Account, error)
|
|
}
|
|
|
|
// ServerOptions configure the inbound API.
|
|
type ServerOptions struct {
|
|
ServerName string
|
|
Gate *federation.Gate
|
|
Keys *federation.KeyRing
|
|
Rooms *roomserver.API
|
|
Ephemeral EphemeralSink
|
|
Profiles Profiles
|
|
Store storage.FederationStore
|
|
|
|
// Media serves this server's media to others. Nil leaves the media
|
|
// endpoints unregistered.
|
|
Media *mediaapi.API
|
|
|
|
// UserKeys answers queries and claims of this server's users'
|
|
// end-to-end keys, and takes in key update EDUs. Nil leaves the key
|
|
// endpoints unregistered.
|
|
UserKeys KeyService
|
|
|
|
Logger *slog.Logger
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Server is the inbound Server-Server API.
|
|
type Server struct {
|
|
serverName string
|
|
gate *federation.Gate
|
|
keys *federation.KeyRing
|
|
rooms *roomserver.API
|
|
ephemeral EphemeralSink
|
|
profiles Profiles
|
|
store storage.FederationStore
|
|
media *mediaapi.API
|
|
keyService KeyService
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewServer builds the inbound API.
|
|
func NewServer(opts ServerOptions) (*Server, error) {
|
|
switch {
|
|
case opts.ServerName == "":
|
|
return nil, errors.New("federationapi: ServerName must not be empty")
|
|
case opts.Gate == nil, opts.Keys == nil, opts.Rooms == nil, opts.Store == nil:
|
|
return nil, errors.New("federationapi: Gate, Keys, Rooms and Store are required")
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.Default()
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
return &Server{
|
|
serverName: opts.ServerName,
|
|
gate: opts.Gate,
|
|
keys: opts.Keys,
|
|
rooms: opts.Rooms,
|
|
ephemeral: opts.Ephemeral,
|
|
profiles: opts.Profiles,
|
|
store: opts.Store,
|
|
media: opts.Media,
|
|
keyService: opts.UserKeys,
|
|
log: opts.Logger,
|
|
now: opts.Now,
|
|
}, nil
|
|
}
|
|
|
|
// authedHandler is a federation handler that runs after the request has
|
|
// been authenticated, with the origin and the body it was signed over.
|
|
type authedHandler func(w http.ResponseWriter, r *http.Request, origin string, body []byte) error
|
|
|
|
// RegisterRoutes adds every federation endpoint, each behind the gate: with
|
|
// the switch off the whole surface answers M_UNRECOGNIZED, as a server
|
|
// with no federation API would.
|
|
func (s *Server) RegisterRoutes(r *httputil.Router) {
|
|
public := func(pattern string, h httputil.Handler) {
|
|
r.Handle(pattern, s.gate.ProtectFunc(h))
|
|
}
|
|
authed := func(pattern string, h authedHandler) {
|
|
r.Handle(pattern, s.gate.ProtectFunc(s.authenticated(h)))
|
|
}
|
|
|
|
// Keys and version need no authentication: a server has to be able to
|
|
// fetch our key before it can check anything we sign.
|
|
public("GET /_matrix/key/v2/server", s.handleServerKey)
|
|
public("GET /_matrix/key/v2/server/{keyID}", s.handleServerKey)
|
|
public("POST /_matrix/key/v2/query", s.handleKeyQuery)
|
|
public("GET /_matrix/key/v2/query/{serverName}", s.handleKeyQueryOne)
|
|
public("GET /_matrix/key/v2/query/{serverName}/{keyID}", s.handleKeyQueryOne)
|
|
public("GET /_matrix/federation/v1/version", s.handleVersion)
|
|
|
|
const v1, v2 = "/_matrix/federation/v1", "/_matrix/federation/v2"
|
|
authed("PUT "+v1+"/send/{txnID}", s.handleSend)
|
|
authed("GET "+v1+"/make_join/{roomID}/{userID}", s.handleMakeJoin)
|
|
authed("PUT "+v1+"/send_join/{roomID}/{eventID}", s.handleSendJoinV1)
|
|
authed("PUT "+v2+"/send_join/{roomID}/{eventID}", s.handleSendJoinV2)
|
|
authed("GET "+v1+"/make_leave/{roomID}/{userID}", s.handleMakeLeave)
|
|
authed("PUT "+v1+"/send_leave/{roomID}/{eventID}", s.handleSendLeaveV1)
|
|
authed("PUT "+v2+"/send_leave/{roomID}/{eventID}", s.handleSendLeaveV2)
|
|
authed("PUT "+v2+"/invite/{roomID}/{eventID}", s.handleInvite)
|
|
authed("GET "+v1+"/event/{eventID}", s.handleEvent)
|
|
authed("GET "+v1+"/state/{roomID}", s.handleState)
|
|
authed("GET "+v1+"/state_ids/{roomID}", s.handleStateIDs)
|
|
authed("GET "+v1+"/event_auth/{roomID}/{eventID}", s.handleEventAuth)
|
|
authed("GET "+v1+"/backfill/{roomID}", s.handleBackfill)
|
|
authed("POST "+v1+"/get_missing_events/{roomID}", s.handleMissingEvents)
|
|
authed("GET "+v1+"/query/directory", s.handleQueryDirectory)
|
|
authed("GET "+v1+"/query/profile", s.handleQueryProfile)
|
|
authed("GET "+v1+"/publicRooms", s.handlePublicRoomsGet)
|
|
authed("POST "+v1+"/publicRooms", s.handlePublicRoomsPost)
|
|
authed("GET "+v1+"/hierarchy/{roomID}", s.handleHierarchy)
|
|
|
|
s.registerMediaRoutes(authed)
|
|
s.registerKeyRoutes(authed)
|
|
}
|
|
|
|
// authenticated checks X-Matrix and the allow/deny lists before the
|
|
// handler runs.
|
|
func (s *Server) authenticated(h authedHandler) httputil.Handler {
|
|
return func(w http.ResponseWriter, r *http.Request) error {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusRequestEntityTooLarge, httputil.ErrCodeTooLarge, "Request body too large")
|
|
}
|
|
origin, err := s.keys.AuthenticateRequest(r.Context(), r, body)
|
|
if err != nil {
|
|
httputil.LoggerFrom(r.Context()).Info("federation request failed authentication", "error", err)
|
|
return httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnauthorized, "%s", err.Error())
|
|
}
|
|
if !s.gate.Policy().Allowed(origin) {
|
|
return httputil.Forbidden("Federation with %s is not allowed by this server", origin)
|
|
}
|
|
return h(w, r, origin, body)
|
|
}
|
|
}
|
|
|
|
// fedError maps a roomserver error onto the Matrix error a remote server
|
|
// is sent.
|
|
func fedError(err error) error {
|
|
var matrixErr *httputil.MatrixError
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.As(err, &matrixErr):
|
|
return err
|
|
case errors.Is(err, roomserver.ErrIncompatibleRoomVersion):
|
|
return httputil.NewError(http.StatusBadRequest, errCodeIncompatibleRoomVersion, "%s", err.Error())
|
|
case errors.Is(err, roomserver.ErrNoSuchRoom), errors.Is(err, roomserver.ErrNoSuchEvent),
|
|
errors.Is(err, roomserver.ErrNoSuchAlias):
|
|
return httputil.NotFound("%s", err.Error())
|
|
case errors.Is(err, roomserver.ErrForbidden), errors.Is(err, roomserver.ErrRejected),
|
|
errors.Is(err, roomserver.ErrNotInRoom):
|
|
return httputil.Forbidden("%s", err.Error())
|
|
case errors.Is(err, roomserver.ErrBadEvent), errors.Is(err, roomserver.ErrInvalidContent):
|
|
return httputil.BadJSON("%s", err.Error())
|
|
case errors.Is(err, roomserver.ErrFederationDisabled):
|
|
return httputil.Unrecognized()
|
|
case errors.Is(err, roomserver.ErrRemote):
|
|
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "%s", err.Error())
|
|
}
|
|
return err
|
|
}
|
|
|
|
// decode reads an already-read body.
|
|
func decode(body []byte, v any) error {
|
|
if len(body) == 0 {
|
|
return httputil.NotJSON()
|
|
}
|
|
if err := json.Unmarshal(body, v); err != nil {
|
|
return httputil.BadJSON("Invalid request body: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- keys and version -------------------------------------------------------
|
|
|
|
func (s *Server) handleServerKey(w http.ResponseWriter, _ *http.Request) error {
|
|
doc, err := s.keys.OwnKeyResponse()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httputil.WriteRawJSON(w, http.StatusOK, doc)
|
|
return nil
|
|
}
|
|
|
|
// keyQueryResponse is the notary answer.
|
|
type keyQueryResponse struct {
|
|
ServerKeys []json.RawMessage `json:"server_keys"`
|
|
}
|
|
|
|
func (s *Server) handleKeyQuery(w http.ResponseWriter, r *http.Request) error {
|
|
var req struct {
|
|
ServerKeys map[string]json.RawMessage `json:"server_keys"`
|
|
}
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
resp := keyQueryResponse{ServerKeys: []json.RawMessage{}}
|
|
for serverName := range req.ServerKeys {
|
|
docs, err := s.keys.NotaryResponse(serverName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.ServerKeys = append(resp.ServerKeys, docs...)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleKeyQueryOne(w http.ResponseWriter, r *http.Request) error {
|
|
docs, err := s.keys.NotaryResponse(r.PathValue("serverName"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if docs == nil {
|
|
docs = []json.RawMessage{}
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, keyQueryResponse{ServerKeys: docs})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) error {
|
|
var resp federation.VersionResponse
|
|
resp.Server.Name = "Warp"
|
|
resp.Server.Version = buildinfo.Version
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// --- transactions -------------------------------------------------------------
|
|
|
|
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
txnID := r.PathValue("txnID")
|
|
if cached, ok, err := s.store.InboundTransaction(r.Context(), origin, txnID); err != nil {
|
|
return err
|
|
} else if ok {
|
|
httputil.WriteRawJSON(w, http.StatusOK, cached)
|
|
return nil
|
|
}
|
|
|
|
var txn federation.Transaction
|
|
if err := decode(body, &txn); err != nil {
|
|
return err
|
|
}
|
|
if txn.Origin != "" && txn.Origin != origin {
|
|
return httputil.Forbidden("The transaction's origin is %s, but it was signed by %s", txn.Origin, origin)
|
|
}
|
|
if len(txn.PDUs) > maxPDUsPerTxn || len(txn.EDUs) > maxEDUsPerTxn {
|
|
return httputil.BadJSON("A transaction carries at most %d PDUs and %d EDUs", maxPDUsPerTxn, maxEDUsPerTxn)
|
|
}
|
|
|
|
resp := federation.SendTransactionResponse{PDUs: map[string]federation.PDUResult{}}
|
|
for _, raw := range txn.PDUs {
|
|
eventID, err := s.rooms.ReceivePDU(r.Context(), origin, raw)
|
|
if eventID == "" {
|
|
// Unparseable: there is no ID to report a result under.
|
|
s.log.Info("dropping an unreadable PDU", "origin", origin, "error", err)
|
|
continue
|
|
}
|
|
result := federation.PDUResult{}
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
s.log.Info("refused a PDU", "origin", origin, "event_id", eventID, "error", err)
|
|
}
|
|
resp.PDUs[eventID] = result
|
|
}
|
|
for _, edu := range txn.EDUs {
|
|
s.receiveEDU(r.Context(), origin, edu)
|
|
}
|
|
|
|
encoded, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.RecordInboundTransaction(r.Context(), origin, txnID, encoded, s.now().UnixMilli()); err != nil {
|
|
s.log.Warn("could not record an inbound transaction", "origin", origin, "txn_id", txnID, "error", err)
|
|
}
|
|
httputil.WriteRawJSON(w, http.StatusOK, encoded)
|
|
return nil
|
|
}
|
|
|
|
// sameServer reports whether a user belongs to the server that sent the
|
|
// EDU about them. Nothing in an EDU is signed, so this is the only check
|
|
// there is, and without it any server could make anybody appear to type.
|
|
func sameServer(userID, origin string) bool {
|
|
server, err := id.ServerNameOf(userID)
|
|
return err == nil && server == origin
|
|
}
|
|
|
|
// receiveEDU takes one EDU in. Failures are logged and otherwise ignored:
|
|
// an EDU is ephemeral, and the transaction it came in succeeds whatever
|
|
// becomes of it.
|
|
func (s *Server) receiveEDU(ctx context.Context, origin string, edu federation.EDU) {
|
|
if s.ephemeral == nil {
|
|
return
|
|
}
|
|
switch edu.Type {
|
|
case federation.EDUTyping:
|
|
var c federation.TypingContent
|
|
if json.Unmarshal(edu.Content, &c) != nil || !sameServer(c.UserID, origin) || !s.allowed(ctx, c.RoomID, origin) {
|
|
return
|
|
}
|
|
if err := s.ephemeral.SetTyping(ctx, syncapi.TypingRequest{RoomID: c.RoomID, User: c.UserID, Typing: c.Typing}); err != nil {
|
|
s.log.Debug("ignored a typing EDU", "origin", origin, "room_id", c.RoomID, "error", err)
|
|
}
|
|
|
|
case federation.EDUReceipt:
|
|
var c federation.ReceiptContent
|
|
if json.Unmarshal(edu.Content, &c) != nil {
|
|
return
|
|
}
|
|
for roomID, byType := range c {
|
|
if !s.allowed(ctx, roomID, origin) {
|
|
continue
|
|
}
|
|
for receiptType, byUser := range byType {
|
|
if receiptType != syncapi.ReceiptRead {
|
|
continue
|
|
}
|
|
for userID, data := range byUser {
|
|
if !sameServer(userID, origin) || len(data.EventIDs) == 0 {
|
|
continue
|
|
}
|
|
if err := s.ephemeral.SetReceipt(ctx, syncapi.ReceiptRequest{
|
|
RoomID: roomID, User: userID, ReceiptType: receiptType,
|
|
EventID: data.EventIDs[len(data.EventIDs)-1], ThreadID: data.Data.ThreadID,
|
|
}); err != nil {
|
|
s.log.Debug("ignored a receipt EDU", "origin", origin, "room_id", roomID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case federation.EDUDirectToDev:
|
|
var c federation.DirectToDeviceContent
|
|
if json.Unmarshal(edu.Content, &c) != nil || !sameServer(c.Sender, origin) {
|
|
return
|
|
}
|
|
if err := s.ephemeral.SendToDevice(ctx, syncapi.SendToDeviceRequest{
|
|
Sender: c.Sender, EventType: c.Type, Messages: c.Messages,
|
|
}); err != nil {
|
|
s.log.Debug("ignored a to-device EDU", "origin", origin, "error", err)
|
|
}
|
|
|
|
case federation.EDUDeviceListUp, federation.EDUSigningKeys:
|
|
s.receiveKeyEDU(ctx, origin, edu)
|
|
|
|
default:
|
|
// Presence is off by default and not federated yet.
|
|
s.log.Debug("ignored an EDU", "origin", origin, "type", edu.Type)
|
|
}
|
|
}
|
|
|
|
// --- membership ---------------------------------------------------------------
|
|
|
|
func (s *Server) handleMakeJoin(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
version, tmpl, err := s.rooms.MakeJoin(r.Context(), origin, r.PathValue("roomID"), r.PathValue("userID"),
|
|
r.URL.Query()["ver"])
|
|
if err != nil {
|
|
if errors.Is(err, roomserver.ErrForbidden) && strings.Contains(err.Error(), "authorise a restricted join") {
|
|
return httputil.NewError(http.StatusBadRequest, errCodeUnableToAuthoriseJoin, "%s", err.Error())
|
|
}
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.MakeJoinResponse{RoomVersion: version, Event: tmpl})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) sendJoin(r *http.Request, origin string, body []byte) (federation.SendJoinResponse, error) {
|
|
res, err := s.rooms.SendJoin(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body)
|
|
if err != nil {
|
|
return federation.SendJoinResponse{}, fedError(err)
|
|
}
|
|
return federation.SendJoinResponse{
|
|
Origin: s.serverName,
|
|
State: res.State,
|
|
AuthChain: res.AuthChain,
|
|
Event: res.Event,
|
|
ServersInRoom: res.ServersInRoom,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) handleSendJoinV2(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
resp, err := s.sendJoin(r, origin, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// handleSendJoinV1 is the deprecated form, which wraps the same answer in
|
|
// a [200, body] pair.
|
|
func (s *Server) handleSendJoinV1(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
resp, err := s.sendJoin(r, origin, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, []any{http.StatusOK, resp})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleMakeLeave(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
version, tmpl, err := s.rooms.MakeLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("userID"))
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.MakeJoinResponse{RoomVersion: version, Event: tmpl})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleSendLeaveV2(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
if err := s.rooms.SendLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body); err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleSendLeaveV1(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
if err := s.rooms.SendLeave(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"), body); err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, []any{http.StatusOK, struct{}{}})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
var req federation.InviteRequest
|
|
if err := decode(body, &req); err != nil {
|
|
return err
|
|
}
|
|
signed, err := s.rooms.ReceiveInvite(r.Context(), origin, req.RoomVersion, req.Event, req.InviteRoomState)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.InviteResponse{Event: signed})
|
|
return nil
|
|
}
|
|
|
|
// --- reads --------------------------------------------------------------------
|
|
|
|
func (s *Server) transaction(pdus []json.RawMessage) federation.Transaction {
|
|
if pdus == nil {
|
|
pdus = []json.RawMessage{}
|
|
}
|
|
return federation.Transaction{Origin: s.serverName, OriginServerTS: s.now().UnixMilli(), PDUs: pdus}
|
|
}
|
|
|
|
func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
raw, err := s.rooms.EventForServer(r.Context(), origin, r.PathValue("eventID"))
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, s.transaction([]json.RawMessage{raw}))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleState(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
eventID := r.URL.Query().Get("event_id")
|
|
if eventID == "" {
|
|
return httputil.MissingParam("event_id is required")
|
|
}
|
|
state, chain, err := s.rooms.StateForServer(r.Context(), origin, r.PathValue("roomID"), eventID)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.StateResponse{PDUs: state, AuthChain: chain})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleStateIDs(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
eventID := r.URL.Query().Get("event_id")
|
|
if eventID == "" {
|
|
return httputil.MissingParam("event_id is required")
|
|
}
|
|
state, chain, err := s.rooms.StateIDsForServer(r.Context(), origin, r.PathValue("roomID"), eventID)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
if state == nil {
|
|
state = []string{}
|
|
}
|
|
if chain == nil {
|
|
chain = []string{}
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.StateIDsResponse{PDUIDs: state, AuthChainIDs: chain})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleEventAuth(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
chain, err := s.rooms.EventAuthForServer(r.Context(), origin, r.PathValue("roomID"), r.PathValue("eventID"))
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.EventAuthResponse{AuthChain: chain})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleBackfill(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
query := r.URL.Query()
|
|
from := query["v"]
|
|
if len(from) == 0 {
|
|
return httputil.MissingParam("v is required")
|
|
}
|
|
limit, err := strconv.Atoi(query.Get("limit"))
|
|
if err != nil || limit <= 0 {
|
|
return httputil.InvalidParam("limit must be a positive integer")
|
|
}
|
|
events, err := s.rooms.BackfillForServer(r.Context(), origin, r.PathValue("roomID"), from, limit)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, s.transaction(events))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleMissingEvents(w http.ResponseWriter, r *http.Request, origin string, body []byte) error {
|
|
var req federation.MissingEventsRequest
|
|
if err := decode(body, &req); err != nil {
|
|
return err
|
|
}
|
|
events, err := s.rooms.MissingEventsForServer(r.Context(), origin, r.PathValue("roomID"),
|
|
req.EarliestEvents, req.LatestEvents, req.Limit, req.MinDepth)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
if events == nil {
|
|
events = []json.RawMessage{}
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.MissingEventsResponse{Events: events})
|
|
return nil
|
|
}
|
|
|
|
// --- queries ------------------------------------------------------------------
|
|
|
|
func (s *Server) handleQueryDirectory(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error {
|
|
alias := r.URL.Query().Get("room_alias")
|
|
if alias == "" {
|
|
return httputil.MissingParam("room_alias is required")
|
|
}
|
|
resolved, err := s.rooms.QueryAliasForServer(r.Context(), alias)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, federation.DirectoryResponse{RoomID: resolved.RoomID, Servers: resolved.Servers})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleQueryProfile(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error {
|
|
query := r.URL.Query()
|
|
userID := query.Get("user_id")
|
|
localpart, server, err := id.SplitUserID(userID)
|
|
if err != nil {
|
|
return httputil.InvalidParam("user_id is not a user ID")
|
|
}
|
|
if server != s.serverName || s.profiles == nil {
|
|
return httputil.NotFound("%s is not a user of this server", userID)
|
|
}
|
|
account, err := s.profiles.Account(r.Context(), localpart)
|
|
if errors.Is(err, storage.ErrNoSuchUser) {
|
|
return httputil.NotFound("Profile not found")
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
resp := map[string]string{}
|
|
switch field := query.Get("field"); field {
|
|
case "":
|
|
if account.DisplayName != "" {
|
|
resp["displayname"] = account.DisplayName
|
|
}
|
|
if account.AvatarURL != "" {
|
|
resp["avatar_url"] = account.AvatarURL
|
|
}
|
|
case "displayname":
|
|
if account.DisplayName != "" {
|
|
resp["displayname"] = account.DisplayName
|
|
}
|
|
case "avatar_url":
|
|
if account.AvatarURL != "" {
|
|
resp["avatar_url"] = account.AvatarURL
|
|
}
|
|
default:
|
|
return httputil.InvalidParam("field must be displayname or avatar_url")
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handlePublicRoomsGet(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error {
|
|
query := r.URL.Query()
|
|
limit, _ := strconv.Atoi(query.Get("limit"))
|
|
return s.publicRooms(w, r, roomserver.PublicRoomsRequest{Limit: limit, Since: query.Get("since")})
|
|
}
|
|
|
|
func (s *Server) handlePublicRoomsPost(w http.ResponseWriter, r *http.Request, _ string, body []byte) error {
|
|
var req struct {
|
|
Limit int `json:"limit"`
|
|
Since string `json:"since"`
|
|
Filter struct {
|
|
GenericSearchTerm string `json:"generic_search_term"`
|
|
} `json:"filter"`
|
|
}
|
|
if len(body) > 0 {
|
|
if err := decode(body, &req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.publicRooms(w, r, roomserver.PublicRoomsRequest{
|
|
Limit: req.Limit, Since: req.Since, Search: req.Filter.GenericSearchTerm,
|
|
})
|
|
}
|
|
|
|
func (s *Server) publicRooms(w http.ResponseWriter, r *http.Request, req roomserver.PublicRoomsRequest) error {
|
|
resp, err := s.rooms.PublicRooms(r.Context(), req)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// handleHierarchy implements GET /_matrix/federation/v1/hierarchy/{roomID}:
|
|
// another server walking one of this server's spaces.
|
|
func (s *Server) handleHierarchy(w http.ResponseWriter, r *http.Request, origin string, _ []byte) error {
|
|
roomID := r.PathValue("roomID")
|
|
suggestedOnly, _ := strconv.ParseBool(r.URL.Query().Get("suggested_only"))
|
|
resp, err := s.rooms.FederatedHierarchy(r.Context(), origin, roomID, suggestedOnly)
|
|
if err != nil {
|
|
return fedError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// String is for logs.
|
|
func (s *Server) String() string { return fmt.Sprintf("federation API for %s", s.serverName) }
|
|
|
|
// allowed applies a room's server ACL to an EDU about it. An EDU is not
|
|
// a room event, but it is still a server speaking in the room, and a
|
|
// server the room has banned does not get to make its users appear to
|
|
// type.
|
|
func (s *Server) allowed(ctx context.Context, roomID, origin string) bool {
|
|
ok, err := s.rooms.ServerAllowed(ctx, roomID, origin)
|
|
return err == nil && ok
|
|
}
|