package appservice import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/dariuszjaniak/warp/server/internal/tracing" ) // 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{ Transport: tracing.Transport(nil), // 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 }