// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge, // cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered // into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call // is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539). package forgehttp import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" ) // MaxBody is the largest JSON answer read from the forge. const MaxBody = 32 << 20 // Client is the forge's HTTP interface. type Client struct { URL string // e.g. http://127.0.0.1:3000 User string PasswordFile string HTTP *http.Client // when nil, a client with Timeout Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero } // FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. func FromEnv() (Client, []string) { c := Client{ URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), User: os.Getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), } var missing []string for _, kv := range [][2]string{{"MESH_GITEA_URL", c.URL}, {"MESH_GITEA_ADMIN_USER", c.User}, {"MESH_GITEA_ADMIN_PASSWORD_FILE", c.PasswordFile}} { if kv[1] == "" { missing = append(missing, kv[0]) } } return c, missing } // ErrNotFound is the forge answering 404. var ErrNotFound = errors.New("not found") // StatusError is an answer the forge gave that is not a success, said without the request's credential. type StatusError struct { Method, Path string Status int Said string } func (e *StatusError) Error() string { return fmt.Sprintf("the forge answered %d to %s %s: %s", e.Status, e.Method, e.Path, e.Said) } func (c *Client) client() *http.Client { if c.HTTP != nil { return c.HTTP } timeout := c.Timeout if timeout <= 0 { timeout = 20 * time.Second } return &http.Client{Timeout: timeout} } func (c *Client) password() (string, error) { raw, err := os.ReadFile(c.PasswordFile) if err != nil { // The path is the manifest's, not a secret; the content never appears. return "", fmt.Errorf("the forge's admin password cannot be read from its file: %w", err) } return strings.TrimSpace(string(raw)), nil } // Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit; // 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it // exactly: a cut body is refused, never read as if it were whole. func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) { password, err := c.password() if err != nil { return nil, err } var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { return nil, err } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, c.URL+path, body) if err != nil { return nil, err } req.SetBasicAuth(c.User, password) req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } res, err := c.client().Do(req) if err != nil { return nil, fmt.Errorf("the forge did not answer %s %s: %w", method, path, err) } defer res.Body.Close() got, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) if err != nil { return nil, err } if int64(len(got)) > limit { return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20) } switch { case res.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound) case res.StatusCode >= 300: said := strings.TrimSpace(string(got)) if len(said) > 300 { said = said[:300] + "…" } return nil, &StatusError{method, path, res.StatusCode, said} } return got, nil } // GetJSON reads one answer into into. func (c *Client) GetJSON(ctx context.Context, path string, into any) error { return c.SendJSON(ctx, http.MethodGet, path, nil, into) } // SendJSON sends one request and reads its answer into into, when into is not nil. func (c *Client) SendJSON(ctx context.Context, method, path string, payload, into any) error { body, err := c.Do(ctx, method, path, payload, MaxBody) if err != nil { return err } if into == nil { return nil } if err := json.Unmarshal(body, into); err != nil { return fmt.Errorf("the forge's answer to %s %s is not readable: %w", method, path, err) } return nil }