Either was read as whole and could leave a pinned version unnamed (review of #128).
267 lines
8.4 KiB
Go
267 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin
|
|
// account, which the vault delivered into a file this module owns (novox/hq ADR 0086). Basic
|
|
// authentication, so this bundle mints nothing and keeps nothing: the TypeScript bundle's token is its
|
|
// own. The password is read when a call is made and never logged, answered or put in an error.
|
|
|
|
// Forge is the forge's HTTP interface.
|
|
type Forge struct {
|
|
URL string // e.g. http://127.0.0.1:3000
|
|
User string
|
|
PasswordFile string
|
|
Owner string // the package registry's owner, as the seat serves it
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// ForgeFromEnv is the forge as the manifest hands it to this bundle.
|
|
func ForgeFromEnv() (*Forge, error) {
|
|
f := &Forge{
|
|
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
|
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
|
PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"),
|
|
Owner: os.Getenv("MESH_NPM_OWNER"),
|
|
}
|
|
var missing []string
|
|
for name, v := range map[string]string{"MESH_GITEA_URL": f.URL, "MESH_GITEA_ADMIN_USER": f.User,
|
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": f.PasswordFile, "MESH_NPM_OWNER": f.Owner} {
|
|
if v == "" {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest", strings.Join(missing, ", "))
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// 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 (f *Forge) client() *http.Client {
|
|
if f.HTTP != nil {
|
|
return f.HTTP
|
|
}
|
|
return &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
|
|
func (f *Forge) password() (string, error) {
|
|
raw, err := os.ReadFile(f.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
|
|
}
|
|
|
|
// maxBody is the largest answer read from the forge.
|
|
const maxBody = 32 << 20
|
|
|
|
// do sends one request and answers the body; 404 is errNotFound.
|
|
func (f *Forge) do(ctx context.Context, method, path string) ([]byte, error) {
|
|
password, err := f.password()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, f.URL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.SetBasicAuth(f.User, password)
|
|
req.Header.Set("Accept", "application/json")
|
|
res, err := f.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()
|
|
// One byte past the cap is read, so a body the cap cut is told from one that fits it exactly: a
|
|
// cut body is refused, never parsed as if it were whole.
|
|
body, err := io.ReadAll(io.LimitReader(res.Body, maxBody+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > maxBody {
|
|
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, maxBody>>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(body))
|
|
if len(said) > 300 {
|
|
said = said[:300] + "…"
|
|
}
|
|
return nil, &statusError{method, path, res.StatusCode, said}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (f *Forge) getJSON(ctx context.Context, path string, into any) error {
|
|
body, err := f.do(ctx, http.MethodGet, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(body, into); err != nil {
|
|
return fmt.Errorf("the forge's answer to %s is not readable: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageVersion is one version of one package, as the forge lists it.
|
|
type PackageVersion struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
const pageSize = 50
|
|
|
|
// Packages is every npm package version of the owner, all pages.
|
|
func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) {
|
|
var all []PackageVersion
|
|
for page := 1; page <= 1000; page++ {
|
|
var batch []PackageVersion
|
|
path := fmt.Sprintf("/api/v1/packages/%s?type=npm&page=%d&limit=%d", url.PathEscape(f.Owner), page, pageSize)
|
|
if err := f.getJSON(ctx, path, &batch); err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < pageSize {
|
|
return all, nil
|
|
}
|
|
}
|
|
return nil, errors.New("the forge listed more than 1000 pages of packages; stopped rather than read for ever")
|
|
}
|
|
|
|
func (f *Forge) versionPath(name, version string) string {
|
|
return fmt.Sprintf("/api/v1/packages/%s/npm/%s/%s", url.PathEscape(f.Owner), url.PathEscape(name), url.PathEscape(version))
|
|
}
|
|
|
|
// Size is the bytes of one version's files.
|
|
func (f *Forge) Size(ctx context.Context, name, version string) (int64, error) {
|
|
var files []struct {
|
|
Size int64 `json:"size"`
|
|
}
|
|
if err := f.getJSON(ctx, f.versionPath(name, version)+"/files", &files); err != nil {
|
|
return 0, err
|
|
}
|
|
var total int64
|
|
for _, file := range files {
|
|
total += file.Size
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// DistTags is the dist-tags of one package, from the registry's own metadata.
|
|
func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, error) {
|
|
var meta struct {
|
|
DistTags map[string]string `json:"dist-tags"`
|
|
}
|
|
path := fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(name))
|
|
if err := f.getJSON(ctx, path, &meta); err != nil {
|
|
return nil, err
|
|
}
|
|
return meta.DistTags, nil
|
|
}
|
|
|
|
// Delete removes one version from the registry.
|
|
func (f *Forge) Delete(ctx context.Context, name, version string) error {
|
|
_, err := f.do(ctx, http.MethodDelete, f.versionPath(name, version))
|
|
return err
|
|
}
|
|
|
|
// Repository is one repository on the forge, as far as reading its manifests needs.
|
|
type Repository struct {
|
|
FullName string `json:"full_name"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
Empty bool `json:"empty"`
|
|
}
|
|
|
|
// Repositories is every repository the admin sees.
|
|
func (f *Forge) Repositories(ctx context.Context) ([]Repository, error) {
|
|
var all []Repository
|
|
for page := 1; page <= 1000; page++ {
|
|
var found struct {
|
|
Data []Repository `json:"data"`
|
|
}
|
|
if err := f.getJSON(ctx, fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, pageSize), &found); err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, found.Data...)
|
|
if len(found.Data) < pageSize {
|
|
return all, nil
|
|
}
|
|
}
|
|
return nil, errors.New("the forge listed more than 1000 pages of repositories; stopped rather than read for ever")
|
|
}
|
|
|
|
func repoPath(fullName string) string {
|
|
owner, name, _ := strings.Cut(fullName, "/")
|
|
return url.PathEscape(owner) + "/" + url.PathEscape(name)
|
|
}
|
|
|
|
// Files is every file path on a branch, all pages of the forge's tree.
|
|
func (f *Forge) Files(ctx context.Context, repo Repository) ([]string, error) {
|
|
var paths []string
|
|
seen := 0
|
|
for page := 1; page <= 1000; page++ {
|
|
var tree struct {
|
|
Tree []struct {
|
|
Path string `json:"path"`
|
|
Type string `json:"type"`
|
|
} `json:"tree"`
|
|
Truncated bool `json:"truncated"`
|
|
TotalCount int `json:"total_count"`
|
|
}
|
|
path := fmt.Sprintf("/api/v1/repos/%s/git/trees/%s?recursive=true&page=%d&per_page=1000",
|
|
repoPath(repo.FullName), url.PathEscape(repo.DefaultBranch), page)
|
|
if err := f.getJSON(ctx, path, &tree); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range tree.Tree {
|
|
if e.Type == "blob" {
|
|
paths = append(paths, e.Path)
|
|
}
|
|
}
|
|
seen += len(tree.Tree)
|
|
if len(tree.Tree) == 0 || !tree.Truncated && (tree.TotalCount == 0 || seen >= tree.TotalCount) {
|
|
return paths, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%s has more than 1000 pages of files; not read whole", repo.FullName)
|
|
}
|
|
|
|
// Raw is one file's content on the repository's default branch.
|
|
func (f *Forge) Raw(ctx context.Context, repo Repository, file string) ([]byte, error) {
|
|
escaped := strings.Split(file, "/")
|
|
for i := range escaped {
|
|
escaped[i] = url.PathEscape(escaped[i])
|
|
}
|
|
return f.do(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/raw/%s?ref=%s",
|
|
repoPath(repo.FullName), strings.Join(escaped, "/"), url.QueryEscape(repo.DefaultBranch)))
|
|
}
|