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 6: the media repository. Clients upload files, reserve an mxc:// URI and upload to it later, download and ask for thumbnails, all through the authenticated media endpoints of Matrix 1.11; other servers download this server's media through the federation media endpoints, and media uploaded elsewhere is fetched over federation on first use and kept. internal/mediaapi is the component. Content goes into a BlobStore named by its SHA-256, so the same file uploaded twice is stored once and the hash doubles as the ETag; the metadata that maps an mxc:// URI onto a blob is in media_repository and media_thumbnails. The local-filesystem store writes to a temporary file and renames it into place once hashed, so nothing ever refers to half a file. A storage driver can bring its own store through storage.BlobProvider, which is where PocketBase's filesystem abstraction (local or S3) plugs in with Phase 10. Reserved uploads belong to the user who made them, take one upload, lapse after a day, and are capped per user; a download of one waits for its content, woken in memory the moment it arrives, for as long as timeout_ms allows. Thumbnails come from a fixed set of configured sizes, so a thousand requested sizes cannot mean a thousand resizes. They are made on first request and kept, never enlarge, honour a JPEG's EXIF orientation, and point at the original when it already fits. The pixel count is checked from the header before anything is decoded, at most four resizes run at once, and concurrent requests for the same thumbnail share one. Remote media is fetched through the signed client: the multipart/mixed answer of Matrix 1.11, with a fallback to the legacy download endpoint for servers that answer M_UNRECOGNIZED, and Location redirects followed by a dialer that refuses loopback, private and link-local addresses. Concurrent requests share one fetch, detached from the request that started it. With federation off nothing is fetched, and what was cached before is still served. Downloads carry a sandboxing CSP, nosniff, inline Content-Disposition only for the types the spec lists as safe, and private immutable caching. The unauthenticated legacy download endpoints are off by default behind media.legacy_unauthenticated_downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
672 lines
23 KiB
Go
672 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/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
|
|
|
|
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
|
|
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,
|
|
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)
|
|
|
|
s.registerMediaRoutes(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)
|
|
}
|
|
|
|
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
|
|
}
|