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>
563 lines
18 KiB
Go
563 lines
18 KiB
Go
package federation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
|
)
|
|
|
|
// Errors an outbound request can fail with before anything is sent.
|
|
var (
|
|
// ErrDisabled means federation is switched off. Every outbound request
|
|
// checks the switch itself, so that a component that missed the
|
|
// toggle still cannot reach another server.
|
|
ErrDisabled = errors.New("federation is disabled")
|
|
|
|
// ErrNotAllowed means the destination is excluded by the allow and
|
|
// deny lists.
|
|
ErrNotAllowed = errors.New("federation with that server is not allowed")
|
|
|
|
// ErrSelf means a request was addressed to this server. Asking
|
|
// ourselves over the network can only be a bug in the caller, and on
|
|
// a server whose name does not resolve to itself it would ask
|
|
// somebody else.
|
|
ErrSelf = errors.New("refusing to send a federation request to this server")
|
|
)
|
|
|
|
// HTTPError is a non-2xx answer from another server.
|
|
type HTTPError struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
if e.Code != "" {
|
|
return fmt.Sprintf("remote server answered %d %s: %s", e.Status, e.Code, e.Message)
|
|
}
|
|
return fmt.Sprintf("remote server answered %d", e.Status)
|
|
}
|
|
|
|
// IsNotFound reports a 404 from another server.
|
|
func IsNotFound(err error) bool {
|
|
var httpErr *HTTPError
|
|
return errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound
|
|
}
|
|
|
|
// Response size limits. A send_join answer for a large room is the room's
|
|
// entire state and auth chain, which for a public room of tens of
|
|
// thousands of members is hundreds of megabytes; nothing else comes close.
|
|
const (
|
|
defaultMaxResponse = 16 << 20
|
|
largeMaxResponse = 1 << 30
|
|
)
|
|
|
|
// Request timeouts. Joining a large room makes the resident server
|
|
// assemble and sign that whole answer first, which takes minutes.
|
|
const (
|
|
defaultRequestTimeout = time.Minute
|
|
longRequestTimeout = 10 * time.Minute
|
|
)
|
|
|
|
// ResolveFunc turns a server name into where to connect.
|
|
type ResolveFunc func(ctx context.Context, serverName string) (Resolved, error)
|
|
|
|
// ClientOptions configure a Client.
|
|
type ClientOptions struct {
|
|
// ServerName is this server's name: the origin of every request.
|
|
ServerName string
|
|
|
|
// Key signs every request.
|
|
Key *signing.KeyPair
|
|
|
|
// Resolve is server discovery. Nil uses a Resolver.
|
|
Resolve ResolveFunc
|
|
|
|
// TLSConfig is the client TLS configuration, for tests and for
|
|
// deployments with a private CA. Nil uses the system roots.
|
|
TLSConfig *tls.Config
|
|
|
|
// Enabled reports the live federation switch. Nil means off.
|
|
Enabled func() bool
|
|
|
|
// Policy filters destinations. Nil allows any.
|
|
Policy *Policy
|
|
|
|
// AllowPrivateMediaRedirects lets a media download follow another
|
|
// server's redirect to a loopback, private or link-local address.
|
|
// Only tests, whose servers are all on loopback, set it: anywhere
|
|
// else it would let another server point this one at its own
|
|
// internal network and read the answer back through a client.
|
|
AllowPrivateMediaRedirects bool
|
|
}
|
|
|
|
// Client sends signed Server-Server API requests.
|
|
type Client struct {
|
|
serverName string
|
|
key *signing.KeyPair
|
|
resolve ResolveFunc
|
|
http *http.Client
|
|
enabled func() bool
|
|
policy *Policy
|
|
userAgent string
|
|
|
|
// mediaHTTP follows the redirects a media download may answer with.
|
|
// It is not the federation transport: the URL is an ordinary web
|
|
// address, often a CDN, and nothing is signed.
|
|
mediaHTTP *http.Client
|
|
}
|
|
|
|
// dialTargetKey carries the resolved addresses of a request down to the
|
|
// dialer, which is the only place they are needed: the URL holds the Host
|
|
// the certificate is checked against, and the address the connection goes
|
|
// to can be something else entirely.
|
|
type dialTargetKey struct{}
|
|
|
|
// NewClient builds a client.
|
|
func NewClient(opts ClientOptions) *Client {
|
|
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
|
tlsConfig := opts.TLSConfig
|
|
if tlsConfig == nil {
|
|
tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
}
|
|
transport := &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
targets, _ := ctx.Value(dialTargetKey{}).([]string)
|
|
if len(targets) == 0 {
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
var lastErr error
|
|
for _, target := range targets {
|
|
conn, err := dialer.DialContext(ctx, network, target)
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, lastErr
|
|
},
|
|
TLSClientConfig: tlsConfig,
|
|
TLSHandshakeTimeout: 15 * time.Second,
|
|
ResponseHeaderTimeout: 0, // bounded by the request context instead
|
|
MaxIdleConns: 200,
|
|
MaxIdleConnsPerHost: 4,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
|
|
if opts.Enabled == nil {
|
|
opts.Enabled = func() bool { return false }
|
|
}
|
|
if opts.Policy == nil {
|
|
opts.Policy = NewPolicy(nil, nil)
|
|
}
|
|
c := &Client{
|
|
serverName: opts.ServerName,
|
|
key: opts.Key,
|
|
resolve: opts.Resolve,
|
|
http: &http.Client{
|
|
Transport: transport,
|
|
// Federation endpoints do not redirect; following one would
|
|
// send a signed request somewhere it was not addressed.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
},
|
|
enabled: opts.Enabled,
|
|
policy: opts.Policy,
|
|
userAgent: buildinfo.UserAgent(),
|
|
mediaHTTP: newMediaRedirectClient(tlsConfig, opts.AllowPrivateMediaRedirects),
|
|
}
|
|
if c.resolve == nil {
|
|
// The well-known document is fetched with ordinary HTTPS and the
|
|
// same roots as everything else.
|
|
resolver := NewResolver(&http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{TLSClientConfig: tlsConfig, Proxy: nil},
|
|
})
|
|
c.resolve = resolver.Resolve
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ServerName returns the origin this client signs as.
|
|
func (c *Client) ServerName() string { return c.serverName }
|
|
|
|
// check refuses a destination before anything goes on the wire.
|
|
func (c *Client) check(destination string) error {
|
|
switch {
|
|
case !c.enabled():
|
|
return ErrDisabled
|
|
case destination == c.serverName:
|
|
return ErrSelf
|
|
case !c.policy.Allowed(destination):
|
|
return fmt.Errorf("%w: %s", ErrNotAllowed, destination)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// request describes one call.
|
|
type request struct {
|
|
method string
|
|
destination string
|
|
path string // already escaped
|
|
query url.Values
|
|
body any
|
|
signed bool
|
|
maxBytes int64
|
|
timeout time.Duration
|
|
}
|
|
|
|
// do performs a request and decodes a JSON answer into out (which may be
|
|
// nil).
|
|
func (c *Client) do(ctx context.Context, req request, out any) error {
|
|
resp, cancel, err := c.send(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
limit := req.maxBytes
|
|
if limit == 0 {
|
|
limit = defaultMaxResponse
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s%s: read response: %w", req.method, req.destination, req.path, err)
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return fmt.Errorf("%s %s%s: response is larger than %d bytes", req.method, req.destination, req.path, limit)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return httpError(resp.StatusCode, body)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
return fmt.Errorf("%s %s%s: decode response: %w", req.method, req.destination, req.path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// send performs a request and hands back the response with its body
|
|
// unread, for the callers that stream it. The caller closes the body and
|
|
// then calls cancel, which ends the request's deadline.
|
|
func (c *Client) send(ctx context.Context, req request) (*http.Response, context.CancelFunc, error) {
|
|
if err := c.check(req.destination); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
resolved, err := c.resolve(ctx, req.destination)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve %s: %w", req.destination, err)
|
|
}
|
|
|
|
timeout := req.timeout
|
|
if timeout == 0 {
|
|
timeout = defaultRequestTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
ctx = context.WithValue(ctx, dialTargetKey{}, resolved.Addresses)
|
|
|
|
var content []byte
|
|
if req.body != nil {
|
|
if content, err = json.Marshal(req.body); err != nil {
|
|
cancel()
|
|
return nil, nil, fmt.Errorf("encode request: %w", err)
|
|
}
|
|
}
|
|
|
|
target := "https://" + resolved.Host + req.path
|
|
if len(req.query) > 0 {
|
|
target += "?" + req.query.Encode()
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, req.method, target, bytes.NewReader(content))
|
|
if err != nil {
|
|
cancel()
|
|
return nil, nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
if content == nil {
|
|
httpReq.Body = http.NoBody
|
|
} else {
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
}
|
|
httpReq.Header.Set("User-Agent", c.userAgent)
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
|
|
if req.signed {
|
|
// The signature covers the request target exactly as it goes on
|
|
// the wire, so it is taken from the built request rather than
|
|
// from the path this function was handed.
|
|
x, err := SignRequest(c.key, c.serverName, req.destination, req.method,
|
|
httpReq.URL.RequestURI(), content)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, nil, fmt.Errorf("sign request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Authorization", x.String())
|
|
}
|
|
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, nil, fmt.Errorf("%s %s%s: %w", req.method, req.destination, req.path, err)
|
|
}
|
|
return resp, cancel, nil
|
|
}
|
|
|
|
// httpError turns a non-2xx answer into an *HTTPError, with the Matrix
|
|
// error envelope if the body was one.
|
|
func httpError(status int, body []byte) *HTTPError {
|
|
httpErr := &HTTPError{Status: status}
|
|
var envelope struct {
|
|
Code string `json:"errcode"`
|
|
Message string `json:"error"`
|
|
}
|
|
if json.Unmarshal(body, &envelope) == nil {
|
|
httpErr.Code, httpErr.Message = envelope.Code, envelope.Message
|
|
}
|
|
return httpErr
|
|
}
|
|
|
|
// esc escapes one path segment. Room, event and user IDs carry sigils and
|
|
// colons, which are legal in a path; slashes would not be, and an event ID
|
|
// of room versions 3 and before can contain one.
|
|
func esc(s string) string { return url.PathEscape(s) }
|
|
|
|
// ServerKeys fetches a server's own key response, unsigned and unverified:
|
|
// verifying it is the key ring's job.
|
|
func (c *Client) ServerKeys(ctx context.Context, serverName string) (json.RawMessage, error) {
|
|
var out json.RawMessage
|
|
err := c.do(ctx, request{method: http.MethodGet, destination: serverName, path: "/_matrix/key/v2/server"}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// QueryKeys asks a notary for other servers' keys.
|
|
func (c *Client) QueryKeys(ctx context.Context, notary string, query map[string]map[string]KeyCriteria) ([]json.RawMessage, error) {
|
|
var out struct {
|
|
ServerKeys []json.RawMessage `json:"server_keys"`
|
|
}
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPost, destination: notary, path: "/_matrix/key/v2/query",
|
|
body: map[string]any{"server_keys": query},
|
|
}, &out)
|
|
return out.ServerKeys, err
|
|
}
|
|
|
|
// KeyCriteria is one key asked of a notary.
|
|
type KeyCriteria struct {
|
|
MinimumValidUntilTS int64 `json:"minimum_valid_until_ts,omitempty"`
|
|
}
|
|
|
|
// SendTransaction delivers a transaction.
|
|
func (c *Client) SendTransaction(ctx context.Context, destination, txnID string, txn Transaction) (SendTransactionResponse, error) {
|
|
var out SendTransactionResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPut, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/send/" + esc(txnID), body: txn,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// MakeJoin asks a resident server for a join event template.
|
|
func (c *Client) MakeJoin(ctx context.Context, destination, roomID, userID string, versions []string) (MakeJoinResponse, error) {
|
|
query := url.Values{}
|
|
for _, v := range versions {
|
|
query.Add("ver", v)
|
|
}
|
|
var out MakeJoinResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/make_join/" + esc(roomID) + "/" + esc(userID),
|
|
query: query,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SendJoin submits a signed join and receives the room's state.
|
|
func (c *Client) SendJoin(ctx context.Context, destination, roomID, eventID string, joinEvent json.RawMessage) (SendJoinResponse, error) {
|
|
var out SendJoinResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPut, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v2/send_join/" + esc(roomID) + "/" + esc(eventID),
|
|
body: joinEvent,
|
|
maxBytes: largeMaxResponse,
|
|
timeout: longRequestTimeout,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// MakeLeave asks a resident server for a leave event template.
|
|
func (c *Client) MakeLeave(ctx context.Context, destination, roomID, userID string) (MakeJoinResponse, error) {
|
|
var out MakeJoinResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/make_leave/" + esc(roomID) + "/" + esc(userID),
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SendLeave submits a signed leave.
|
|
func (c *Client) SendLeave(ctx context.Context, destination, roomID, eventID string, leaveEvent json.RawMessage) error {
|
|
return c.do(ctx, request{
|
|
method: http.MethodPut, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v2/send_leave/" + esc(roomID) + "/" + esc(eventID),
|
|
body: leaveEvent,
|
|
}, nil)
|
|
}
|
|
|
|
// Invite asks the invitee's server to countersign an invite.
|
|
func (c *Client) Invite(ctx context.Context, destination, roomID, eventID string, req InviteRequest) (InviteResponse, error) {
|
|
var out InviteResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPut, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v2/invite/" + esc(roomID) + "/" + esc(eventID),
|
|
body: req,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Event fetches one event.
|
|
func (c *Client) Event(ctx context.Context, destination, eventID string) (Transaction, error) {
|
|
var out Transaction
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/event/" + esc(eventID),
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// EventAuth fetches the auth chain of an event.
|
|
func (c *Client) EventAuth(ctx context.Context, destination, roomID, eventID string) (EventAuthResponse, error) {
|
|
var out EventAuthResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/event_auth/" + esc(roomID) + "/" + esc(eventID),
|
|
maxBytes: largeMaxResponse,
|
|
timeout: longRequestTimeout,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// StateIDs fetches the IDs of the room state at an event.
|
|
func (c *Client) StateIDs(ctx context.Context, destination, roomID, eventID string) (StateIDsResponse, error) {
|
|
var out StateIDsResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/state_ids/" + esc(roomID),
|
|
query: url.Values{"event_id": {eventID}},
|
|
maxBytes: largeMaxResponse,
|
|
timeout: longRequestTimeout,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// State fetches the room state at an event, events and all.
|
|
func (c *Client) State(ctx context.Context, destination, roomID, eventID string) (StateResponse, error) {
|
|
var out StateResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/state/" + esc(roomID),
|
|
query: url.Values{"event_id": {eventID}},
|
|
maxBytes: largeMaxResponse,
|
|
timeout: longRequestTimeout,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// MissingEvents asks for the events between what this server has and an
|
|
// event it was sent.
|
|
func (c *Client) MissingEvents(ctx context.Context, destination, roomID string, req MissingEventsRequest) (MissingEventsResponse, error) {
|
|
var out MissingEventsResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPost, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/get_missing_events/" + esc(roomID),
|
|
body: req,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Backfill asks for history before the given events.
|
|
func (c *Client) Backfill(ctx context.Context, destination, roomID string, from []string, limit int) (Transaction, error) {
|
|
query := url.Values{"limit": {strconv.Itoa(limit)}}
|
|
for _, id := range from {
|
|
query.Add("v", id)
|
|
}
|
|
var out Transaction
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/backfill/" + esc(roomID),
|
|
query: query,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// QueryProfile asks a server for one of its users' profile. An empty field
|
|
// asks for the whole profile.
|
|
func (c *Client) QueryProfile(ctx context.Context, destination, userID, field string) (map[string]any, error) {
|
|
query := url.Values{"user_id": {userID}}
|
|
if field != "" {
|
|
query.Set("field", field)
|
|
}
|
|
var out map[string]any
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/query/profile", query: query,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// QueryDirectory resolves an alias on the server that owns it.
|
|
func (c *Client) QueryDirectory(ctx context.Context, destination, alias string) (DirectoryResponse, error) {
|
|
var out DirectoryResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/query/directory", query: url.Values{"room_alias": {alias}},
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// PublicRooms reads another server's public room directory.
|
|
func (c *Client) PublicRooms(ctx context.Context, destination string, limit int, since, search string) (json.RawMessage, error) {
|
|
body := map[string]any{}
|
|
if limit > 0 {
|
|
body["limit"] = limit
|
|
}
|
|
if since != "" {
|
|
body["since"] = since
|
|
}
|
|
if search = strings.TrimSpace(search); search != "" {
|
|
body["filter"] = map[string]string{"generic_search_term": search}
|
|
}
|
|
var out json.RawMessage
|
|
err := c.do(ctx, request{
|
|
method: http.MethodPost, destination: destination, signed: true,
|
|
path: "/_matrix/federation/v1/publicRooms", body: body,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Version asks a server what software it runs.
|
|
func (c *Client) Version(ctx context.Context, destination string) (VersionResponse, error) {
|
|
var out VersionResponse
|
|
err := c.do(ctx, request{
|
|
method: http.MethodGet, destination: destination,
|
|
path: "/_matrix/federation/v1/version",
|
|
}, &out)
|
|
return out, err
|
|
}
|