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 9. internal/appservice reads registration files (appservice.config_files), matches namespaces the way Synapse does (anchored at the start), and delivers events to each service as transactions read from the roomserver's event stream at a cursor kept in appservice_state. A transaction in flight is recorded, so after a restart it is resent under the same ID; failures back off from 2s to 5m, and a successful ping cuts the backoff short. In the Client-Server API an as_token acts as the service's sender or, with ?user_id=, as any existing user in its namespace. Services register and log in their users with m.login.application_service; exclusive user and alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown aliases and users in a namespace are asked of the service first. The ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when rate_limited is false, skip the rate limiter. Users can change their password (POST /account/password) and deactivate their account (POST /account/deactivate), both behind the password. Deactivation revokes tokens, deletes devices, optionally erases the profile and leaves every room through a new userapi.AccountObserver. The admin API is complete: user detail, the admin flag, deactivation and reactivation, password resets, devices, media quotas (media.max_user_storage_bytes plus per-user overrides), registration tokens with use limits and expiry (registration.require_token), server notices (internal/servernotices, server_notices.*), an editor for the federation allow/deny lists persisted in server_settings, a delivery view of the application services, and an audit log of every change. Migration 0011 adds the registration token, server notice room, media quota, audit log and appservice cursor tables. Policy lists are now returned sorted. The Complement entrypoint loads the registrations Complement mounts in /complement/appservice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package appservice
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Errors of the Application Service API client. The ping endpoint reports
|
|
// each with its own Matrix error code.
|
|
var (
|
|
// ErrNoURL is a service registered without a URL, which cannot be
|
|
// sent anything.
|
|
ErrNoURL = errors.New("application service has no URL")
|
|
|
|
// ErrConnectionFailed means the service could not be reached.
|
|
ErrConnectionFailed = errors.New("could not connect to the application service")
|
|
|
|
// ErrConnectionTimeout means it did not answer in time.
|
|
ErrConnectionTimeout = errors.New("the application service did not answer in time")
|
|
)
|
|
|
|
// BadStatusError is an answer from the service that was not a success.
|
|
type BadStatusError struct {
|
|
Status int
|
|
|
|
// Body is the start of what the service answered, for whoever has to
|
|
// work out why.
|
|
Body string
|
|
}
|
|
|
|
func (e *BadStatusError) Error() string {
|
|
return fmt.Sprintf("the application service answered %d: %s", e.Status, e.Body)
|
|
}
|
|
|
|
// maxErrorBody caps how much of a failed answer is kept.
|
|
const maxErrorBody = 4 << 10
|
|
|
|
// Client speaks the Application Service API: the requests a homeserver
|
|
// makes to a service.
|
|
type Client struct {
|
|
http *http.Client
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewClient builds a client. The timeout bounds one request; transactions
|
|
// that time out are retried like any other failure.
|
|
func NewClient(timeout time.Duration) *Client {
|
|
return &Client{
|
|
http: &http.Client{
|
|
// A service is reached at the URL its registration names and
|
|
// nowhere else: a redirect would take the hs_token with it.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
},
|
|
timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// transactionBody is PUT /_matrix/app/v1/transactions/{txnId}.
|
|
type transactionBody struct {
|
|
Events []json.RawMessage `json:"events"`
|
|
}
|
|
|
|
// SendTransaction delivers a batch of events. The same transaction ID is
|
|
// resent with the same events until the service accepts it, and the
|
|
// service deduplicates by ID.
|
|
func (c *Client) SendTransaction(ctx context.Context, svc *Service, txnID int64, events []json.RawMessage) error {
|
|
body, err := json.Marshal(transactionBody{Events: events})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.do(ctx, svc, http.MethodPut, "/_matrix/app/v1/transactions/"+strconv.FormatInt(txnID, 10), body)
|
|
return err
|
|
}
|
|
|
|
// QueryUser asks a service whether a user in its namespace exists, which
|
|
// it answers by creating the user if it wants it to. It reports whether
|
|
// the service said yes.
|
|
func (c *Client) QueryUser(ctx context.Context, svc *Service, userID string) (bool, error) {
|
|
return c.query(ctx, svc, "/_matrix/app/v1/users/"+url.PathEscape(userID))
|
|
}
|
|
|
|
// QueryAlias asks a service whether an alias in its namespace exists,
|
|
// which it answers by creating the room and the alias.
|
|
func (c *Client) QueryAlias(ctx context.Context, svc *Service, alias string) (bool, error) {
|
|
return c.query(ctx, svc, "/_matrix/app/v1/rooms/"+url.PathEscape(alias))
|
|
}
|
|
|
|
// query makes a GET whose answer is yes (200) or no (404).
|
|
func (c *Client) query(ctx context.Context, svc *Service, path string) (bool, error) {
|
|
_, err := c.do(ctx, svc, http.MethodGet, path, nil)
|
|
var status *BadStatusError
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.As(err, &status) && status.Status == http.StatusNotFound:
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// Ping makes POST /_matrix/app/v1/ping and reports how long it took.
|
|
func (c *Client) Ping(ctx context.Context, svc *Service, transactionID string) (time.Duration, error) {
|
|
body, err := json.Marshal(map[string]string{"transaction_id": transactionID})
|
|
if transactionID == "" {
|
|
body, err = []byte(`{}`), nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
start := time.Now()
|
|
if _, err := c.do(ctx, svc, http.MethodPost, "/_matrix/app/v1/ping", body); err != nil {
|
|
return 0, err
|
|
}
|
|
return time.Since(start), nil
|
|
}
|
|
|
|
// do makes one request to a service, authenticated with its hs_token.
|
|
func (c *Client) do(ctx context.Context, svc *Service, method, path string, body []byte) ([]byte, error) {
|
|
if svc.URL == "" {
|
|
return nil, ErrNoURL
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
defer cancel()
|
|
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(svc.URL, "/")+path, reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
// The header form only. The spec deprecated ?access_token= for this in
|
|
// v1.4, and a token in a URL ends up in the service's access log.
|
|
req.Header.Set("Authorization", "Bearer "+svc.HSToken)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
var netErr net.Error
|
|
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionTimeout, err)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
|
return nil, &BadStatusError{Status: resp.StatusCode, Body: string(head)}
|
|
}
|
|
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
}
|
|
return out, nil
|
|
}
|