Files
Warp/server/internal/appservice/client.go
T
tajniak81andClaude Opus 5.5 a94794b866
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 9: application services and admin completion
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>
2026-10-17 12:58:28 +02:00

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
}