// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge, // cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539). // // Two ways in, by what the manifest hands the bundle: // // - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module // keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A // token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's — // is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints // nothing; a token the forge rejects is renewed by the module's next call, and said here. // - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR // 0086), by basic authentication, when no token file is named. // // A credential is read when a call is made and never logged, answered or put in an error. 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 // TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login; // Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools). TokenFile string Account 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 } // ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts). const ToolsAccount = "mesh-tools" // FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle // handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login. func FromEnv() (Client, []string) { if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" { c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file, Account: os.Getenv("MESH_GITEA_TOOLS_USER")} if c.Account == "" { c.Account = ToolsAccount } if c.URL == "" { return c, []string{"MESH_GITEA_URL"} } return c, nil } 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 } // token is the tools' account's token, refused unless its scopes line names that account. func (c *Client) token() (string, error) { raw, err := os.ReadFile(c.TokenFile) if err != nil { return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err) } line, err := os.ReadFile(c.TokenFile + ".scopes") if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) { return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account) } token := strings.TrimSpace(string(raw)) if token == "" { return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile) } return token, nil } // authorize puts the bundle's credential on a request: the tools' token, or the admin login. func (c *Client) authorize(req *http.Request) error { if c.TokenFile != "" { token, err := c.token() if err != nil { return err } req.Header.Set("Authorization", "token "+token) return nil } password, err := c.password() if err != nil { return err } req.SetBasicAuth(c.User, password) return 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) { 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 } if err := c.authorize(req); err != nil { return nil, err } 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.StatusUnauthorized && c.TokenFile != "": return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w", method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"}) 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 }