Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e2446262 |
@@ -279,6 +279,20 @@ content-addressable store (deduplicated by digest, reaped by GC once
|
|||||||
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
|
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
|
||||||
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
|
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
|
||||||
|
|
||||||
|
### Local npm registry
|
||||||
|
|
||||||
|
A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped)
|
||||||
|
and `npm install` work against `/api/v1/local/{name}/`. npm requires an
|
||||||
|
`_authToken` for the registry before it will publish; any value works.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .npmrc
|
||||||
|
registry=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/
|
||||||
|
//artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/:_authToken=unused
|
||||||
|
```
|
||||||
|
|
||||||
|
Versions are immutable; dist-tags sent with a publish move to that version.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
| Field | Default | Behaviour |
|
| Field | Default | Behaviour |
|
||||||
@@ -297,6 +311,7 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat
|
|||||||
```
|
```
|
||||||
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
||||||
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
|
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
|
||||||
|
PUT /api/v1/local/{name}/{package} npm publish (local npm repos)
|
||||||
GET /v2/{name}/{path} Docker Registry v2
|
GET /v2/{name}/{path} Docker Registry v2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ already-running stack.
|
|||||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
verified against the origin fixture.
|
||||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||||
index), rpm (real package + **automatic repodata** generation).
|
index), rpm (real package + **automatic repodata** generation), npm (stock
|
||||||
|
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
|
||||||
|
second version, republish rejected).
|
||||||
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
|
||||||
across two members.
|
across two members.
|
||||||
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build dockere2e
|
||||||
|
|
||||||
|
package e2edocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLocalNPMPublishInstall drives a stock npm client against a local npm
|
||||||
|
// repo: publish unscoped and scoped packages, install and require() them in a
|
||||||
|
// fresh project, publish a second version, and reject a republish.
|
||||||
|
func TestLocalNPMPublishInstall(t *testing.T) {
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := fmt.Sprintf("e2e-npm-local-%d", time.Now().UnixNano())
|
||||||
|
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name))
|
||||||
|
defer deleteRepo(t, name)
|
||||||
|
|
||||||
|
reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
|
||||||
|
script := `set -euo pipefail
|
||||||
|
printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.npmrc
|
||||||
|
mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d"
|
||||||
|
printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json
|
||||||
|
echo "module.exports = '$1@$2';" > index.js; }
|
||||||
|
mk e2e-npm-plain 1.0.0 && npm publish
|
||||||
|
mk @e2e/npm-scoped 1.0.0 && npm publish
|
||||||
|
mk e2e-npm-plain 1.0.0
|
||||||
|
if npm publish 2>/dev/null; then echo "republish accepted"; exit 1; fi
|
||||||
|
mkdir -p /w/app && cd /w/app && npm init -y >/dev/null
|
||||||
|
npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped
|
||||||
|
node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))"
|
||||||
|
mk e2e-npm-plain 1.1.0 && npm publish
|
||||||
|
echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')"
|
||||||
|
echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"`
|
||||||
|
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||||
|
"-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("npm client run failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0",
|
||||||
|
`VERSIONS ["1.0.0","1.1.0"]`,
|
||||||
|
"LATEST 1.1.0",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(out), want) {
|
||||||
|
t.Fatalf("missing %q in npm output:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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.+_-]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
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 !npmRefRe.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(r.Body, pkg)
|
||||||
|
if err != nil {
|
||||||
|
npmError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := pkg[strings.LastIndex(pkg, "/")+1:]
|
||||||
|
stem := pkg + "/-/" + base + "-" + rel.version
|
||||||
|
// The tarball's immutable row is the version's uniqueness gate.
|
||||||
|
if err := h.npmStore(r.Context(), remote.Name, stem+".tgz", rel.tarball, "application/octet-stream", false); err != nil {
|
||||||
|
if errors.Is(err, database.ErrAlreadyExists) {
|
||||||
|
npmError(w, http.StatusConflict, fmt.Sprintf("%s@%s already exists; overwrites are not allowed", pkg, rel.version))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
npmError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.npmStore(r.Context(), remote.Name, stem+".json", rel.manifest, "application/json", false); err != nil {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) npmStore(ctx context.Context, repo, path string, data []byte, contentType string, mutable bool) 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 h.db.UpsertLocalFile(ctx, repo, path, result.ContentHash)
|
||||||
|
}
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) }
|
||||||
|
tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, 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
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("X-Artifact-Source", "local")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return map[string]any{"_id": pkg, "name": pkg, "dist-tags": tags, "versions": versions}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNPMPackageName(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"left-pad": "left-pad",
|
||||||
|
"left-pad/": "left-pad",
|
||||||
|
"@acme%2fwidget": "@acme/widget",
|
||||||
|
"@acme%2Fwidget": "@acme/widget",
|
||||||
|
"@acme/widget": "@acme/widget",
|
||||||
|
"left-pad/-/left-pad.tgz": "",
|
||||||
|
"@acme/widget/-/w-1.tgz": "",
|
||||||
|
"a/b": "",
|
||||||
|
"../etc": "",
|
||||||
|
"Upper": "",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for in, want := range tests {
|
||||||
|
if got := npmPackageName(in); got != want {
|
||||||
|
t.Errorf("npmPackageName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type publishOpts struct {
|
||||||
|
bodyName, manifestName, version, integrity, shasum string
|
||||||
|
tags map[string]string
|
||||||
|
extraVersion bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishBody(t *testing.T, pkg string, tgz []byte, o publishOpts) string {
|
||||||
|
t.Helper()
|
||||||
|
sha := sha512.Sum512(tgz)
|
||||||
|
sh1 := sha1.Sum(tgz)
|
||||||
|
if o.bodyName == "" {
|
||||||
|
o.bodyName = pkg
|
||||||
|
}
|
||||||
|
if o.manifestName == "" {
|
||||||
|
o.manifestName = pkg
|
||||||
|
}
|
||||||
|
if o.version == "" {
|
||||||
|
o.version = "1.0.0"
|
||||||
|
}
|
||||||
|
if o.integrity == "" {
|
||||||
|
o.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha[:])
|
||||||
|
}
|
||||||
|
if o.shasum == "" {
|
||||||
|
o.shasum = hex.EncodeToString(sh1[:])
|
||||||
|
}
|
||||||
|
if o.tags == nil {
|
||||||
|
o.tags = map[string]string{"latest": o.version}
|
||||||
|
}
|
||||||
|
versions := map[string]any{o.version: map[string]any{
|
||||||
|
"name": o.manifestName, "version": o.version, "main": "index.js",
|
||||||
|
"dist": map[string]any{"integrity": o.integrity, "shasum": o.shasum, "tarball": "http://elsewhere/x.tgz"},
|
||||||
|
}}
|
||||||
|
if o.extraVersion {
|
||||||
|
versions["2.0.0"] = map[string]any{"name": pkg, "version": "2.0.0"}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"_id": pkg, "name": o.bodyName, "dist-tags": o.tags, "versions": versions,
|
||||||
|
"_attachments": map[string]any{pkg + "-" + o.version + ".tgz": map[string]any{
|
||||||
|
"content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNPMPublish(t *testing.T) {
|
||||||
|
tgz := []byte("fake tarball bytes")
|
||||||
|
|
||||||
|
rel, err := parseNPMPublish(strings.NewReader(publishBody(t, "@acme/widget", tgz, publishOpts{tags: map[string]string{"latest": "1.0.0", "beta": "1.0.0"}})), "@acme/widget")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid scoped publish rejected: %v", err)
|
||||||
|
}
|
||||||
|
if rel.version != "1.0.0" || string(rel.tarball) != string(tgz) || len(rel.tags) != 2 {
|
||||||
|
t.Fatalf("unexpected release: %+v", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none", shasum: hex.EncodeToString(make([]byte, 20))})), "left-pad"); err == nil {
|
||||||
|
t.Fatal("shasum fallback should reject a mismatched tarball")
|
||||||
|
}
|
||||||
|
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none"})), "left-pad"); err != nil {
|
||||||
|
t.Fatalf("matching shasum fallback rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := map[string]publishOpts{
|
||||||
|
"body name mismatch": {bodyName: "other"},
|
||||||
|
"manifest name mismatch": {manifestName: "other"},
|
||||||
|
"integrity mismatch": {integrity: "sha512-AAAA"},
|
||||||
|
"path-like version": {version: "../1.0.0"},
|
||||||
|
"tag at another version": {tags: map[string]string{"latest": "9.9.9"}},
|
||||||
|
"path-like tag": {tags: map[string]string{"../x": "1.0.0"}},
|
||||||
|
"multiple versions at once": {extraVersion: true},
|
||||||
|
}
|
||||||
|
for name, o := range bad {
|
||||||
|
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, o)), "left-pad"); err == nil {
|
||||||
|
t.Errorf("%s: expected rejection", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := parseNPMPublish(strings.NewReader(`{"name":"left-pad","versions":{}}`), "left-pad"); err == nil {
|
||||||
|
t.Error("deprecate-style body without attachments should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNPMPackument(t *testing.T) {
|
||||||
|
blobs := map[string]string{
|
||||||
|
"h1": `{"name":"my_pkg","version":"1.0.0","dist":{"shasum":"aa","tarball":"http://elsewhere/x.tgz"}}`,
|
||||||
|
"h2": `{"name":"my_pkg","version":"1.1.0"}`,
|
||||||
|
"h3": "1.1.0",
|
||||||
|
"hx": `{"name":"myXpkg","version":"6.6.6"}`,
|
||||||
|
}
|
||||||
|
read := func(h string) ([]byte, error) {
|
||||||
|
b, ok := blobs[h]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("missing blob")
|
||||||
|
}
|
||||||
|
return []byte(b), nil
|
||||||
|
}
|
||||||
|
files := []database.LocalFile{
|
||||||
|
{FilePath: "my_pkg/-/dist-tags/latest", ContentHash: "h3"},
|
||||||
|
{FilePath: "my_pkg/-/my_pkg-1.0.0.json", ContentHash: "h1"},
|
||||||
|
{FilePath: "my_pkg/-/my_pkg-1.0.0.tgz", ContentHash: "nope"},
|
||||||
|
{FilePath: "my_pkg/-/my_pkg-1.1.0.json", ContentHash: "h2"},
|
||||||
|
{FilePath: "myXpkg/-/myXpkg-6.6.6.json", ContentHash: "hx"},
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := buildNPMPackument("my_pkg", files, read, "http://reg/api/v1/local/npm/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tags := doc["dist-tags"].(map[string]string); tags["latest"] != "1.1.0" {
|
||||||
|
t.Errorf("dist-tags = %v", tags)
|
||||||
|
}
|
||||||
|
versions := doc["versions"].(map[string]any)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("versions = %v (LIKE over-match must be dropped)", versions)
|
||||||
|
}
|
||||||
|
v1 := versions["1.0.0"].(map[string]any)["dist"].(map[string]any)
|
||||||
|
if v1["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.0.0.tgz" || v1["shasum"] != "aa" {
|
||||||
|
t.Errorf("1.0.0 dist = %v", v1)
|
||||||
|
}
|
||||||
|
if versions["1.1.0"].(map[string]any)["dist"].(map[string]any)["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.1.0.tgz" {
|
||||||
|
t.Error("1.1.0 missing rewritten tarball")
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc, err := buildNPMPackument("absent", files, read, "x/"); err != nil || doc != nil {
|
||||||
|
t.Errorf("absent package: doc=%v err=%v", doc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxyHandler struct {
|
type ProxyHandler struct {
|
||||||
@@ -41,6 +42,7 @@ func (h *ProxyHandler) Routes() chi.Router {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||||
r.Get("/local/{localName}/*", h.handleLocal)
|
r.Get("/local/{localName}/*", h.handleLocal)
|
||||||
|
r.Put("/local/{localName}/*", h.handleLocalPut)
|
||||||
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -199,6 +201,13 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if remote.PackageType == models.PackageNPM {
|
||||||
|
if pkg := npmPackageName(path); pkg != "" {
|
||||||
|
h.serveNPMPackument(w, r, remote, pkg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prov, _ := provider.Get(remote.PackageType)
|
prov, _ := provider.Get(remote.PackageType)
|
||||||
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
||||||
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
||||||
|
|||||||
@@ -101,6 +101,27 @@ pip install <package>`,
|
|||||||
];
|
];
|
||||||
|
|
||||||
case 'npm':
|
case 'npm':
|
||||||
|
if (isLocal) {
|
||||||
|
const reg = `${url}/api/v1/local/${name}/`;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Configure .npmrc (registry + publish token)',
|
||||||
|
language: 'bash',
|
||||||
|
code: `cat >> .npmrc <<'EOF'
|
||||||
|
registry=${reg}
|
||||||
|
//${host}/api/v1/local/${name}/:_authToken=unused
|
||||||
|
EOF`,
|
||||||
|
note: 'npm refuses to publish without an _authToken for the registry; any value works. For a scope only, use @<scope>:registry=… instead of registry=.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Publish and install',
|
||||||
|
language: 'bash',
|
||||||
|
code: `npm publish
|
||||||
|
npm install <package>`,
|
||||||
|
note: 'Versions are immutable: republishing an existing version is rejected. The dist-tags sent with the publish (latest, or --tag) move to the new version.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Point npm at this registry',
|
title: 'Point npm at this registry',
|
||||||
|
|||||||
Reference in New Issue
Block a user