// Package client is the HTTP client for the pages management API. // // It depends on the standard library and github.com/iceBear67/simplepages/api // only — see cmd/pages/deps_test.go, which fails the build if a server-side // package ever reaches the CLI through here. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/version" ) // DefaultTimeout bounds a single management request. const DefaultTimeout = 30 * time.Second // maxErrorBody caps how much of a non-2xx body is read before giving up on // finding an error envelope in it. A misrouted request can land on something // that answers with a megabyte of HTML. const maxErrorBody = 64 << 10 // Config configures a Client. type Config struct { // BaseURL is the management API root, e.g. "https://pages.example.com". // Any trailing slash is trimmed. BaseURL string // Token is the bearer credential. It is sent in the Authorization header // and must never appear in a URL, a log line or an error message. Token string // Timeout bounds each request. Zero means DefaultTimeout. Timeout time.Duration // HTTP overrides the underlying client. Its Timeout field is ignored: // deadlines come from the request context so the deploy path can give a // large blob upload longer than a management call. HTTP *http.Client // UserAgent overrides the default "pages/". UserAgent string } // Client talks to the management API. type Client struct { base string token string timeout time.Duration http *http.Client agent string } // New validates cfg and returns a client. func New(cfg Config) (*Client, error) { raw := strings.TrimSpace(cfg.BaseURL) if raw == "" { return nil, errors.New("no server URL: pass --server or set PAGES_SERVER") } u, err := url.Parse(raw) if err != nil { return nil, fmt.Errorf("invalid server URL %q: %w", raw, err) } switch u.Scheme { case "http", "https": case "": return nil, fmt.Errorf("invalid server URL %q: missing scheme, try https://%s", raw, raw) default: return nil, fmt.Errorf("invalid server URL %q: scheme must be http or https", raw) } if u.Host == "" { return nil, fmt.Errorf("invalid server URL %q: missing host", raw) } if cfg.Token == "" { return nil, errors.New("no token: pass --token-file or set PAGES_TOKEN") } hc := cfg.HTTP if hc == nil { tr := http.DefaultTransport.(*http.Transport).Clone() // The deploy path uploads blobs concurrently to one host. tr.MaxIdleConnsPerHost = 32 hc = &http.Client{Transport: tr} } // A redirect is a misconfigured --server, and following it silently would // send the bearer token somewhere the operator did not name. Report it and // let them fix the URL. (Go strips Authorization across hosts anyway, which // would turn the redirect into a confusing 401 instead.) hc.CheckRedirect = func(req *http.Request, via []*http.Request) error { return fmt.Errorf("server redirected to %s — use that as --server", req.URL.Redacted()) } agent := cfg.UserAgent if agent == "" { agent = "pages/" + version.Version } timeout := cfg.Timeout if timeout <= 0 { timeout = DefaultTimeout } return &Client{ base: strings.TrimRight(u.String(), "/"), token: cfg.Token, timeout: timeout, http: hc, agent: agent, }, nil } // BaseURL returns the server root the client was configured with. func (c *Client) BaseURL() string { return c.base } // do performs a request with a JSON body and decodes a JSON response. // // body may be nil for a bodyless request; out may be nil to discard the // response (204, or a response the caller does not need). func (c *Client) do(ctx context.Context, method, path string, body, out any) error { var rdr io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return fmt.Errorf("encode request: %w", err) } rdr = bytes.NewReader(buf) } ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", c.agent) if rdr != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return requestError(method, path, err) } defer func() { // Drain a little so the connection can be reused; a huge unread body is // not worth keeping the connection for. io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) resp.Body.Close() }() if resp.StatusCode >= 400 { return responseError(resp) } if out == nil || resp.StatusCode == http.StatusNoContent { return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("decode %s %s response: %w", method, path, err) } return nil } // requestError describes a transport-level failure. The URL is included; the // token is not, and cannot be — it never leaves the Authorization header. func requestError(method, path string, err error) error { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%s %s: timed out; raise --timeout if the server is slow", method, path) } return fmt.Errorf("%s %s: %w", method, path, err) } // responseError turns a non-2xx response into an *api.Error, so callers can // switch on api.CodeOf and the CLI can print "project_exists: ...". func responseError(resp *http.Response) error { raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) var env api.ErrorEnvelope if err := json.Unmarshal(raw, &env); err == nil && env.Error.Code != "" { return &env.Error } // Not an envelope: a proxy, a wrong --server, or a bug. Say what arrived // instead of pretending to a code we did not receive. msg := strings.TrimSpace(string(raw)) if len(msg) > 200 { msg = msg[:200] + "…" } if msg == "" { msg = http.StatusText(resp.StatusCode) } return &api.Error{Code: codeForStatus(resp.StatusCode), Message: msg} } func codeForStatus(status int) api.Code { switch status { case http.StatusBadRequest: return api.CodeBadRequest case http.StatusUnauthorized: return api.CodeUnauthorized case http.StatusForbidden: return api.CodeForbidden case http.StatusNotFound: return api.CodeNotFound case http.StatusMethodNotAllowed: return api.CodeMethodNotAllowed case http.StatusConflict: return api.CodeConflict case http.StatusRequestEntityTooLarge: return api.CodePayloadTooLarge case http.StatusTooManyRequests: return api.CodeRateLimited case http.StatusServiceUnavailable: return api.CodeUnavailable default: return api.CodeInternal } }