2c671a1be7
ci/woodpecker/tag/docker Pipeline was successful
npm clients need one registry for internal and public packages, but npm virtuals have no merger, so a local npm repo and npmjs cannot be served together. - merge packuments across members: union versions (member order wins), first-defined dist-tags minus ones naming missing versions - serve full and abbreviated (`install-v1`) packuments, scoped names included, via the shared virtual merge cache - point tarballs at `<virtual>/<member>/<path>` and 302 them to the owning member - reject an invalid Host with 400; serve virtual and local packuments `no-store` with `Vary: Host, X-Forwarded-Proto, Accept` - return 502 when a member errors; skip members without the package - add `docs/npm/virtual.md` and a stock `node:22` e2e-docker case Reviewed-on: #152 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
335 lines
10 KiB
Go
335 lines
10 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"crypto/sha512"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
// Local npm repos speak the npm registry publish/read protocol. Per package:
|
|
// <pkg>/-/<base>-<version>.tgz tarball (immutable)
|
|
// <pkg>/-/<base>-<version>.json version manifest (immutable)
|
|
// <pkg>/-/dist-tags/<tag> version string (mutable)
|
|
|
|
var (
|
|
npmNameRe = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`)
|
|
npmRefRe = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`)
|
|
npmSemverRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
|
|
|
npmMaxPublishBytes int64 = 256 << 20
|
|
)
|
|
|
|
func (h *ProxyHandler) localNPMRemote(r *http.Request, name string) (*models.Remote, bool) {
|
|
remote, err := h.db.GetRemote(r.Context(), name)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageNPM
|
|
}
|
|
|
|
// npmPackageName returns the package a packument/publish path addresses, or ""
|
|
// when the path is something else (e.g. a tarball under <pkg>/-/).
|
|
func npmPackageName(raw string) string {
|
|
p, err := url.PathUnescape(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
p = strings.TrimSuffix(p, "/")
|
|
if !npmNameRe.MatchString(p) {
|
|
return ""
|
|
}
|
|
return p
|
|
}
|
|
|
|
type npmRelease struct {
|
|
version string
|
|
manifest []byte
|
|
tarball []byte
|
|
tags []string
|
|
}
|
|
|
|
func parseNPMPublish(body io.Reader, pkg string) (*npmRelease, error) {
|
|
var doc struct {
|
|
Name string `json:"name"`
|
|
DistTags map[string]string `json:"dist-tags"`
|
|
Versions map[string]json.RawMessage `json:"versions"`
|
|
Attachments map[string]struct {
|
|
Data string `json:"data"`
|
|
} `json:"_attachments"`
|
|
}
|
|
if err := json.NewDecoder(body).Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("invalid publish body: %w", err)
|
|
}
|
|
if doc.Name != pkg {
|
|
return nil, fmt.Errorf("body name %q does not match %q", doc.Name, pkg)
|
|
}
|
|
if len(doc.Versions) != 1 || len(doc.Attachments) != 1 {
|
|
return nil, errors.New("publish must carry exactly one version and one tarball")
|
|
}
|
|
|
|
rel := &npmRelease{}
|
|
for v, m := range doc.Versions {
|
|
rel.version, rel.manifest = v, m
|
|
}
|
|
if !npmSemverRe.MatchString(rel.version) {
|
|
return nil, fmt.Errorf("invalid version %q", rel.version)
|
|
}
|
|
var meta struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Dist struct {
|
|
Integrity string `json:"integrity"`
|
|
Shasum string `json:"shasum"`
|
|
} `json:"dist"`
|
|
}
|
|
if err := json.Unmarshal(rel.manifest, &meta); err != nil {
|
|
return nil, fmt.Errorf("invalid version manifest: %w", err)
|
|
}
|
|
if meta.Name != pkg || meta.Version != rel.version {
|
|
return nil, errors.New("version manifest name/version mismatch")
|
|
}
|
|
|
|
for _, a := range doc.Attachments {
|
|
tgz, err := base64.StdEncoding.DecodeString(a.Data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid tarball encoding: %w", err)
|
|
}
|
|
rel.tarball = tgz
|
|
}
|
|
if err := verifyNPMDist(rel.tarball, meta.Dist.Integrity, meta.Dist.Shasum); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for tag, v := range doc.DistTags {
|
|
if !npmRefRe.MatchString(tag) || v != rel.version {
|
|
return nil, fmt.Errorf("dist-tag %q must point at the published version", tag)
|
|
}
|
|
rel.tags = append(rel.tags, tag)
|
|
}
|
|
return rel, nil
|
|
}
|
|
|
|
func verifyNPMDist(tgz []byte, integrity, shasum string) error {
|
|
if b64, ok := strings.CutPrefix(integrity, "sha512-"); ok {
|
|
sum := sha512.Sum512(tgz)
|
|
if base64.StdEncoding.EncodeToString(sum[:]) != b64 {
|
|
return errors.New("tarball does not match dist.integrity")
|
|
}
|
|
return nil
|
|
}
|
|
if shasum != "" {
|
|
sum := sha1.Sum(tgz)
|
|
if hex.EncodeToString(sum[:]) != shasum {
|
|
return errors.New("tarball does not match dist.shasum")
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("version manifest carries no sha512 integrity or shasum")
|
|
}
|
|
|
|
func npmError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|
|
|
|
func (h *ProxyHandler) handleLocalPut(w http.ResponseWriter, r *http.Request) {
|
|
remote, ok := h.localNPMRemote(r, chi.URLParam(r, "localName"))
|
|
if !ok {
|
|
npmError(w, http.StatusMethodNotAllowed, "publish is only supported on local npm repositories")
|
|
return
|
|
}
|
|
pkg := npmPackageName(chi.URLParam(r, "*"))
|
|
if pkg == "" {
|
|
npmError(w, http.StatusBadRequest, "invalid package name")
|
|
return
|
|
}
|
|
rel, err := parseNPMPublish(http.MaxBytesReader(w, r.Body, npmMaxPublishBytes), pkg)
|
|
if err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
npmError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("publish body exceeds %d bytes", npmMaxPublishBytes))
|
|
return
|
|
}
|
|
npmError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
base := pkg[strings.LastIndex(pkg, "/")+1:]
|
|
stem := pkg + "/-/" + base + "-" + rel.version
|
|
// The manifest gates visibility and tags may only name visible versions, so
|
|
// the order is tarball, manifest, tags. A tarball left by a failed publish is
|
|
// accepted again only with identical bytes; an existing manifest always conflicts.
|
|
conflict := func() {
|
|
npmError(w, http.StatusConflict, fmt.Sprintf("%s@%s already exists; overwrites are not allowed", pkg, rel.version))
|
|
}
|
|
ok, err = h.npmStoreImmutable(r.Context(), remote.Name, stem+".tgz", rel.tarball, "application/octet-stream")
|
|
if err != nil {
|
|
npmError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
conflict()
|
|
return
|
|
}
|
|
if _, err := h.npmStore(r.Context(), remote.Name, stem+".json", rel.manifest, "application/json", false); err != nil {
|
|
if errors.Is(err, database.ErrAlreadyExists) {
|
|
conflict()
|
|
return
|
|
}
|
|
npmError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
for _, tag := range rel.tags {
|
|
if _, err := h.npmStore(r.Context(), remote.Name, pkg+"/-/dist-tags/"+tag, []byte(rel.version), "text/plain", true); err != nil {
|
|
npmError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
slog.Info("local npm package published", "repo", remote.Name, "package", pkg, "version", rel.version)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": pkg})
|
|
}
|
|
|
|
// npmStoreImmutable reports false when path already holds different bytes.
|
|
func (h *ProxyHandler) npmStoreImmutable(ctx context.Context, repo, path string, data []byte, contentType string) (bool, error) {
|
|
hash, err := h.npmStore(ctx, repo, path, data, contentType, false)
|
|
if !errors.Is(err, database.ErrAlreadyExists) {
|
|
return err == nil, err
|
|
}
|
|
existing, err := h.db.GetLocalFile(ctx, repo, path)
|
|
if err != nil || existing == nil {
|
|
return false, fmt.Errorf("read existing %s: %v", path, err)
|
|
}
|
|
return existing.ContentHash == hash, nil
|
|
}
|
|
|
|
func (h *ProxyHandler) npmStore(ctx context.Context, repo, path string, data []byte, contentType string, mutable bool) (string, error) {
|
|
result, err := h.cas.Store(ctx, bytes.NewReader(data), contentType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
|
|
return "", err
|
|
}
|
|
if mutable {
|
|
return result.ContentHash, h.db.UpsertLocalFile(ctx, repo, path, result.ContentHash)
|
|
}
|
|
return result.ContentHash, h.db.CreateLocalFile(ctx, repo, path, result.ContentHash)
|
|
}
|
|
|
|
func (h *ProxyHandler) readLocalBlob(ctx context.Context, contentHash string) ([]byte, error) {
|
|
reader, _, err := h.store.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer reader.Close()
|
|
return io.ReadAll(reader)
|
|
}
|
|
|
|
func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, remote *models.Remote, pkg string) {
|
|
files, err := h.db.ListLocalFilesByPrefix(r.Context(), remote.Name, pkg+"/-/")
|
|
if err != nil {
|
|
npmError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
base, ok := requestBaseURL(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) }
|
|
tarballBase := base + "/api/v1/local/" + remote.Name + "/"
|
|
doc, err := buildNPMPackument(pkg, files, read, tarballBase)
|
|
if err != nil {
|
|
npmError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if doc == nil {
|
|
npmError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
setNPMPerHostHeaders(w)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Artifact-Source", "local")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(doc)
|
|
}
|
|
|
|
// setNPMPerHostHeaders keeps shared caches off packuments whose tarball URLs
|
|
// are built from the request host and Accept (abbreviated vs full).
|
|
func setNPMPerHostHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Vary", "Host, X-Forwarded-Proto, Accept")
|
|
}
|
|
|
|
// buildNPMPackument returns nil when pkg has no published versions.
|
|
func buildNPMPackument(pkg string, files []database.LocalFile, read func(hash string) ([]byte, error), tarballBase string) (map[string]any, error) {
|
|
prefix := pkg + "/-/"
|
|
versions := map[string]any{}
|
|
tags := map[string]string{}
|
|
for _, f := range files {
|
|
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
|
|
rest, ok := strings.CutPrefix(f.FilePath, prefix)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if tag, isTag := strings.CutPrefix(rest, "dist-tags/"); isTag {
|
|
v, err := read(f.ContentHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tags[tag] = string(v)
|
|
continue
|
|
}
|
|
stem, isManifest := strings.CutSuffix(rest, ".json")
|
|
if !isManifest {
|
|
continue
|
|
}
|
|
raw, err := read(f.ContentHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("manifest %s: %w", f.FilePath, err)
|
|
}
|
|
dist, _ := m["dist"].(map[string]any)
|
|
if dist == nil {
|
|
dist = map[string]any{}
|
|
m["dist"] = dist
|
|
}
|
|
dist["tarball"] = tarballBase + prefix + stem + ".tgz"
|
|
v, _ := m["version"].(string)
|
|
versions[v] = m
|
|
}
|
|
if len(versions) == 0 {
|
|
return nil, nil
|
|
}
|
|
for tag, v := range tags {
|
|
if _, ok := versions[v]; !ok {
|
|
delete(tags, tag)
|
|
}
|
|
}
|
|
return map[string]any{"_id": pkg, "name": pkg, "dist-tags": tags, "versions": versions}, nil
|
|
}
|