enhance(packages/npm): expand version metadata and support npm deprecate (#37890)

Fixes #21624

Adds the npm package version metadata fields that Gitea's npm registry
was previously dropping on publish, and implements the `npm deprecate`
command, which Gitea did not accept before.

### New / pass-through metadata fields

The following are now parsed from the publish payload, persisted in the
stored `npm.Metadata`, and re-emitted on the abbreviated version
manifest returned to npm clients:

- `hasInstallScript` — auto-detected from `scripts.preinstall` /
`scripts.install` / `scripts.postinstall` (also honors a client-supplied
value). Without this flag, `npm install` skips lifecycle scripts.
- `_hasShrinkwrap` — authoritatively derived by inspecting the uploaded
tarball for a top-level `*/npm-shrinkwrap.json` entry. Client-supplied
values are ignored. Decompression failures fall back to `false` and do
not block publish (integrity has already been validated).
- `engines` (`map[string]string`)
- `cpu`, `os` (`[]string`)
- `directories` (`map[string]string`)
- `funding` (`any`; preserves the spec's string / object / array shape)
- `acceptDependencies` (`map[string]string`)
- `deprecated` (`string`)

`peerDependenciesMeta` was already in the stored struct but is now
exercised
by tests.

### `npm deprecate` support

`npm deprecate <pkg-spec> <message>` PUTs the package document to the
same URL as publish but with no `_attachments`. The router now detects
that shape and routes to a new handler that updates each affected
version's stored `Metadata.Deprecated` via
`packages_model.UpdateVersion`. An empty message clears the flag
(undeprecate). Unknown versions are silently skipped, matching npm's
behavior. No new routes were added.

Supported invocations include:

- `npm deprecate my-thing@"< 0.2.3" "critical bug fixed in v0.2.3"`
- `npm deprecate my-thing@1.x "1.x is no longer supported"`
- `npm deprecate my-thing@1.0.0 ""` (undeprecate)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
philip-x-rutkowski-intel-com
2026-08-03 05:26:08 +00:00
committed by GitHub
co-authored by wxiaoguang bircni
parent c2ebe3724f
commit 6487d14855
8 changed files with 553 additions and 28 deletions
+145 -1
View File
@@ -4,7 +4,9 @@
package npm
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha1"
"crypto/sha512"
"encoding/base64"
@@ -108,6 +110,15 @@ type PackageMetadataVersion struct {
Readme string `json:"readme,omitempty"`
Dist PackageDistribution `json:"dist"`
Maintainers []User `json:"maintainers,omitempty"`
HasInstallScript bool `json:"hasInstallScript,omitempty"`
HasShrinkwrap bool `json:"_hasShrinkwrap,omitempty"`
Engines map[string]string `json:"engines,omitempty"`
CPU []string `json:"cpu,omitempty"`
OS []string `json:"os,omitempty"`
Directories map[string]string `json:"directories,omitempty"`
Funding any `json:"funding,omitempty"`
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
Deprecated string `json:"deprecated,omitempty"`
}
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
@@ -243,13 +254,38 @@ type packageUpload struct {
Attachments map[string]*PackageAttachment `json:"_attachments"`
}
// ParsePackage parses the content into a npm package
// ParseUpload decodes a npm PUT body. Exactly one of the returned pointers
// is non-nil on success; a body without `_attachments` is a deprecate request,
// otherwise it is a "publish".
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
if err != nil {
return nil, nil, err
}
var upload packageUpload
if err := json.Unmarshal(body, &upload); err != nil {
return nil, nil, err
}
if len(upload.Attachments) == 0 {
dep, err := parseUploadDeprecation(&upload, body)
return nil, dep, err
}
p, err := parseUploadPackage(&upload)
return p, nil, err
}
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
// surface as ErrInvalidAttachment once name/version validation has passed.
func ParsePackage(r io.Reader) (*Package, error) {
var upload packageUpload
if err := json.NewDecoder(r).Decode(&upload); err != nil {
return nil, err
}
return parseUploadPackage(&upload)
}
// parseUploadPackage builds a Package from a decoded publish body.
func parseUploadPackage(upload *packageUpload) (*Package, error) {
for _, meta := range upload.Versions {
if !validateName(meta.Name) {
return nil, ErrInvalidPackageName
@@ -298,6 +334,13 @@ func ParsePackage(r io.Reader) (*Package, error) {
Bin: meta.Bin,
Readme: meta.Readme,
Repository: meta.Repository,
Engines: meta.Engines,
CPU: meta.CPU,
OS: meta.OS,
Directories: meta.Directories,
Funding: meta.Funding,
AcceptDependencies: meta.AcceptDependencies,
Deprecated: meta.Deprecated,
},
}
@@ -344,12 +387,75 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, ErrInvalidIntegrity
}
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
// packument can lie about either.
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
return p, nil
}
return nil, ErrInvalidPackage
}
// maxNpmTarballScanBytes caps the decompressed tarball bytes inspectTarball
// will read; defends against gzip bombs.
const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
// and hasInstallScript (package/package.json declares any of preinstall,
// install, postinstall). Both must be derived server-side because the client
// can lie in the packument. Any read/decode error yields (false, false) so a
// malformed archive does not block publishing.
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return false, false
}
defer gr.Close()
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
for {
hdr, err := tr.Next()
if err != nil {
return hasShrinkwrap, hasInstallScript
}
// npm pack puts files under a single root directory (usually "package/").
name := strings.TrimPrefix(hdr.Name, "./")
if strings.Count(name, "/") != 1 {
continue
}
switch {
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
hasShrinkwrap = true
case strings.HasSuffix(name, "/package.json"):
hasInstallScript = tarballDeclaresInstallScript(tr)
}
if hasShrinkwrap && hasInstallScript {
return hasShrinkwrap, hasInstallScript
}
}
}
// tarballDeclaresInstallScript reports whether a package.json declares any
// of preinstall, install, postinstall.
func tarballDeclaresInstallScript(r io.Reader) bool {
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
return false
}
for _, name := range []string{"preinstall", "install", "postinstall"} {
if strings.TrimSpace(pkg.Scripts[name]) != "" {
return true
}
}
return false
}
func validateName(name string) bool {
if strings.TrimSpace(name) != name {
return false
@@ -359,3 +465,41 @@ func validateName(name string) bool {
}
return nameMatch.MatchString(name)
}
// PackageDeprecation is the result of parsing an npm deprecate request body.
// Versions maps a version string to its deprecation message; an empty message
// means "undeprecate".
type PackageDeprecation struct {
PackageName string
Versions map[string]string
}
// parseUploadDeprecation builds a PackageDeprecation from a body with no
// `_attachments`. Only versions whose object explicitly contained a
// `deprecated` key are emitted, so a subset PUT cannot silently undeprecate
// versions it omitted. An empty string still means "undeprecate".
func parseUploadDeprecation(upload *packageUpload, body []byte) (*PackageDeprecation, error) {
if !validateName(upload.Name) {
return nil, ErrInvalidPackageName
}
var raw struct {
Versions map[string]map[string]any `json:"versions"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
d := &PackageDeprecation{
PackageName: upload.Name,
Versions: make(map[string]string, len(raw.Versions)),
}
for v, meta := range upload.Versions {
if meta == nil {
continue
}
if _, ok := raw.Versions[v]["deprecated"]; !ok {
continue
}
d.Versions[v] = meta.Deprecated
}
return d, nil
}