Files
Warp/server/internal/federationapi/server.go
T
tajniak81andClaude Opus 5.5 92836f8dcd
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
Phase 6: media
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>
2026-10-16 21:26:49 +02:00

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
}