The forge's contents and diffpatch endpoints take a branch, never a commit to
build on, so the parent is read from the commit the forge made, and a branch
that moved under the commit is said. Blobs are read at the commit read first,
not at the branch name. Protection rules match as Gitea 1.27 matches them
([...], {a,b}, escapes, a plain name in any case), in the Go bundle and in
guard.ts alike. One commit carries at most 4 MiB per file or patch and 16 MiB
together; code search stops at its deadline and says so. The HTTP client the
two Go bundles copied is one package, internal/forgehttp.
152 lines
4.6 KiB
Go
152 lines
4.6 KiB
Go
// 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
|
|
}
|