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 5: the Server-Server API. Warp joins rooms on other servers and lets their users join its rooms, invites across servers with the invitee's server countersigning, rejects remote invites (locally if the inviter is unreachable), and exchanges events, typing, receipts and to-device messages with other homeservers — all behind the runtime switch, which now starts and stops real outbound traffic. The protocol lives in internal/federation with no knowledge of rooms: X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping the dial address and the TLS name apart, a signed client that refuses every request while the switch is off or the policy denies the destination, and a key ring that caches other servers' keys for at most a week, with optional notaries. The roomserver takes events from elsewhere through the spec's intake: signatures and hashes (a hash mismatch keeps the redacted form), the rules against the event's own auth events, against the state before it — with /get_missing_events for small gaps and /state_ids for wide ones — and against the current state, where a failure is a soft failure. Events held without their state are outliers: a column on roomserver_events, because the current state of a room joined over federation is almost all outliers, and excluded from every timeline query. Forks are resolved at the room's head. Membership across servers is make_join/send_join, make_leave/send_leave and countersigned invites, in both directions; m.room.server_acl is enforced on everything another server sends. History before a remote join is backfilled when /messages pages past it, stored at negative stream positions. internal/federationapi is the running component: the inbound endpoints, and an outbound queue that persists what it owes each destination, sends it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to six hours, survives restarts, and drops events persisted while federation is off rather than delivering a backlog in one burst. Its Module is what the switch starts and stops. Also: a TLS listener for port 8448, trusted key servers, key validity and an outbound CA bundle in the config; per-destination health and backoff reset in the admin API; remote profiles, aliases and directories in the Client-Server API; a Complement federation profile. See docs/federation.md, including its known gaps (no partial-state joins, no key rotation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
662 lines
23 KiB
Go
662 lines
23 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/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
|
|
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
|
|
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,
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
default:
|
|
// Presence and device list updates belong to phases that have not
|
|
// landed (presence is off by default; device lists are Phase 7).
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|