UNPKG

verdaccio

Version:

A lightweight private npm proxy registry

400 lines (399 loc) 14.4 kB
import { API_ERROR, DIST_TAGS, HTTP_STATUS, STORAGE, USERS } from "./constants.mjs"; import { logger } from "./logger/index.mjs"; import { ErrorCode, isObject, normalizeDistTags } from "./utils.mjs"; import _ from "lodash"; import { pkgUtils } from "@verdaccio/core"; import { generateRandomHexString } from "@verdaccio/utils"; import createDebug from "debug"; import { SearchMemoryIndexer } from "@verdaccio/search-indexer"; //#region src/lib/storage-utils.ts var debug = createDebug("verdaccio:storage-utils"); /** * Create an empty package manifest with all the internal bookkeeping * sections (`_uplinks`, `_distfiles`, `_attachments`, `_rev`) initialized. * Used as the starting point when a package is seen for the first time. * @param name package name * @return an empty manifest for the given package */ function generatePackageTemplate(name) { return { name, versions: {}, time: {}, [USERS]: {}, [DIST_TAGS]: {}, _uplinks: {}, _distfiles: {}, _attachments: {}, _rev: "" }; } /** * Normalize a manifest read from storage: ensure the object sections * (`versions`, `dist-tags`, `_distfiles`, `_attachments`, `_uplinks`, `time`) * exist, default the revision and `_id`, and normalize the dist-tags shape. * The manifest is mutated in place. * @param pkg package manifest reference * @return the same manifest, normalized */ function normalizePackage(pkg) { [ "versions", "dist-tags", "_distfiles", "_attachments", "_uplinks", "time" ].forEach((key) => { const pkgProp = pkg[key]; if (_.isNil(pkgProp) || isObject(pkgProp) === false) pkg[key] = {}; }); if (_.isString(pkg._rev) === false) { debug("package %o has no revision, setting default", pkg.name); pkg._rev = STORAGE.DEFAULT_REVISION; } if (_.isString(pkg._id) === false) pkg._id = pkg.name; normalizeDistTags(pkg); return pkg; } /** * Produce the next revision id in the `<counter>-<hash>` format used by the * storage layer, incrementing the counter of the previous revision. * @param rev previous revision id, eg. `2-a1b2c3` * @return the next revision id */ function generateRevision(rev) { return (+rev.split("-")[0] || 0) + 1 + "-" + generateRandomHexString(); } /** * Pick the most relevant readme for a package: the manifest readme or the * `latest` version readme first, otherwise the first readme found across the * `next`, `beta`, `alpha`, `test`, `dev` and `canary` dist-tags, in that order. * @param pkg package manifest * @return the readme content, or an empty string when none is available */ function getLatestReadme(pkg) { const versions = pkg["versions"] || {}; const distTags = pkg["dist-tags"] || {}; const latestVersion = distTags["latest"] ? versions[distTags["latest"]] || {} : {}; let readme = _.trim(pkg.readme || latestVersion.readme || ""); if (readme) return readme; [ "next", "beta", "alpha", "test", "dev", "canary" ].map(function(tag) { if (readme) return readme; const version = distTags[tag] ? versions[distTags[tag]] || {} : {}; readme = _.trim(version.readme || readme); }); return readme; } /** * Remove the readme from a version object: only one readme is kept per * package (see getLatestReadme), never one per version. The version is * mutated in place. * @param version version metadata * @return the same version without its readme */ function cleanUpReadme(version) { if (_.isNil(version) === false) delete version.readme; return version; } /** * Manifest properties allowed in responses served to clients; everything * else is verdaccio-internal bookkeeping and is stripped by cleanUpLinksRef. */ var WHITELIST = [ "_rev", "name", "versions", "dist-tags", "readme", "time", "_id", "users" ]; /** * Strip verdaccio-internal sections (`_distfiles`, `_attachments`, ...) from * a manifest before serving it to a client. The manifest is mutated in place. * @param keepUpLinkData whether the `_uplinks` section should be preserved * @param result manifest to clean up * @return the same manifest with only whitelisted properties */ function cleanUpLinksRef(keepUpLinkData, result) { const propertyToKeep = [...WHITELIST]; if (keepUpLinkData === true) propertyToKeep.push("_uplinks"); for (const i in result) if (propertyToKeep.indexOf(i) === -1) delete result[i]; return result; } /** * Verify a package does not exist locally yet; part of the publish flow. * @param name package name * @param localStorage local storage instance * @throws conflict error when the package already exists locally */ async function checkPackageLocal(name, localStorage) { debug("checking if %o already exists locally", name); try { if (await localStorage.getPackageMetadataAsync(name)) { debug("package %o already exists locally", name); throw ErrorCode.getConflict(API_ERROR.PACKAGE_EXIST); } } catch (err) { if (err.status === HTTP_STATUS.NOT_FOUND) { debug("package %o does not exist locally", name); return; } throw err; } } /** * Create a new package in the local storage and register its latest version * in the in-memory search indexer. * @param name package name * @param metadata package manifest to store * @param localStorage local storage instance */ function publishPackage(name, metadata, localStorage) { debug("publishing a new package for %o", name); return new Promise((resolve, reject) => { localStorage.addPackage(name, metadata, (err, latest) => { if (!_.isNull(err)) return reject(err); else if (!_.isUndefined(latest)) SearchMemoryIndexer.add(latest).catch((reason) => { debug("indexer has failed on add item %o", reason); logger.error("indexer has failed on add item"); }); return resolve(); }); }); } /** * Verify a package does not exist on any uplink yet; part of the publish * flow. Uplink failures other than 404 reject the publish, unless * `publish.allow_offline` is enabled in the configuration. * @param name package name * @param isAllowPublishOffline whether publishing with unreachable uplinks is allowed * @param syncMetadata bound Storage._syncUplinksMetadata function * @throws conflict error when the package exists upstream, service unavailable when uplinks are offline */ function checkPackageRemote(name, isAllowPublishOffline, syncMetadata) { debug("checking if %o already exists on uplinks", name); return new Promise((resolve, reject) => { syncMetadata(name, null, {}, (err, packageJsonLocal, upLinksErrors) => { if (err && err.status !== HTTP_STATUS.NOT_FOUND) return reject(err); if (_.isNil(packageJsonLocal) === false) { debug("package %o already exists on an uplink", name); return reject(ErrorCode.getConflict(API_ERROR.PACKAGE_EXIST)); } for (let errorItem = 0; errorItem < upLinksErrors.length; errorItem++) if (_.isNil(upLinksErrors[errorItem][0]) === false) { if (upLinksErrors[errorItem][0].status !== HTTP_STATUS.NOT_FOUND) { if (isAllowPublishOffline) { debug("uplink offline for %o, publish offline is allowed", name); return resolve(); } debug("uplink offline for %o, rejecting publish", name); return reject(ErrorCode.getServiceUnavailable(API_ERROR.UPLINK_OFFLINE_PUBLISH)); } } return resolve(); }); }); } /** * Merge the `time` field of a remote manifest into the cached one; remote * entries win on conflicts. Returns a new manifest, the inputs are untouched. * @param cacheManifest local cached manifest * @param remoteManifest manifest fetched from an uplink * @return the cached manifest with the merged time field */ function mergeUplinkTimeIntoLocal(cacheManifest, remoteManifest) { if ("time" in remoteManifest) { debug("merging remote time field into local manifest for %o", cacheManifest.name); return { ...cacheManifest, time: { ...cacheManifest.time, ...remoteManifest.time } }; } return cacheManifest; } /** * Build the search item emitted for a local package during a search stream, * based on its latest version: npm-search compatible maintainers * (`{ username, email }`), publisher (from `_npmUser`, falling back to the * first maintainer), license and links. * @param data package manifest * @param time modified time attached to the search item * @return the search item, or undefined when the package has no usable latest version */ function prepareSearchPackage(data, time) { const latest = pkgUtils.getLatest(data); if (!latest || !data.versions[latest]) { debug("no latest version found for %o, skipping search item", data.name); return; } debug("preparing search item for %o@%o", data.name, latest); if (latest && data.versions[latest]) { const version = data.versions[latest]; const versions = { [latest]: "latest" }; const rawMaintainers = version.maintainers || [version.author].filter(Boolean); const maintainers = Array.isArray(rawMaintainers) ? rawMaintainers.map((maintainer) => typeof maintainer === "string" ? { username: maintainer, email: "" } : { ...maintainer, username: maintainer?.username ?? maintainer?.name ?? "", email: maintainer?.email ?? "" }) : []; const npmUser = version._npmUser; const publisher = npmUser?.name ? { username: npmUser.name, email: npmUser.email ?? "" } : maintainers[0] ?? {}; return { name: version.name, description: version.description, [DIST_TAGS]: { latest }, maintainers, publisher, author: version.author, repository: version.repository, readmeFilename: version.readmeFilename || "", homepage: version.homepage, keywords: version.keywords, time: { modified: time }, bugs: version.bugs, license: version.license, versions }; } } /** * Whether the tarball url path ends with the given file name * (any query string or fragment is ignored, like the path parsing * in updateVersions). */ function tarballMatchesFilename(tarball, filename) { return tarball.split(/[?#]/)[0].endsWith(`/${filename}`); } /** * Build a distfile record from a version's dist metadata when its tarball * url matches the given file name. */ function distFileFromVersion(version, filename) { const dist = version?.dist; if (dist?.tarball && tarballMatchesFilename(dist.tarball, filename)) return { url: dist.tarball, sha: dist.shasum }; return null; } /** * Resolve the distfile record for a tarball filename. Prefers the * `_distfiles` bookkeeping, but falls back to the version metadata when the * record is missing (storages written by other verdaccio versions cache * versions without their distfile records, which made those tarballs * permanently unavailable). * @param manifest cached manifest * @param filename tarball file name, eg. pkg-1.0.0.tgz */ function lookupDistFile(manifest, filename) { const distFile = manifest?._distfiles?.[filename]; if (_.isNil(distFile) === false) return distFile; const versions = manifest?.versions ?? {}; const basename = manifest?.name?.replace(/^.*\//, ""); if (basename && filename.startsWith(`${basename}-`) && filename.endsWith(".tgz")) { const versionId = filename.slice(basename.length + 1, -4); const distFromVersion = distFileFromVersion(versions[versionId], filename); if (distFromVersion) { debug("distfile record missing for %o, using version %o dist", filename, versionId); return distFromVersion; } } for (const versionId in versions) { const distFromVersion = distFileFromVersion(versions[versionId], filename); if (distFromVersion) { debug("distfile record missing for %o, using version %o dist", filename, versionId); return distFromVersion; } } debug("no distfile record or version dist found for %o", filename); return null; } /** * Check whether the package metadata has enough data to be published. * @param pkg package manifest * @return true when the manifest carries a versions section */ function isPublishablePackage(pkg) { return Object.keys(pkg).includes("versions"); } /** * Whether a version declares an install lifecycle script (`install`, * `preinstall` or `postinstall`); surfaced in abbreviated manifests as * `hasInstallScript` so clients can warn about script execution. * @param version version metadata * @return true when an install script is present */ function hasInstallScript(version) { if (version?.scripts) return Object.keys(version.scripts).find((item) => { return [ "install", "preinstall", "postinstall" ].includes(item); }) !== void 0; return false; } /** * Convert a full manifest into the abbreviated format served when the client * requests `application/vnd.npm.install-v1+json` (npm install), keeping only * the fields the npm registry contract defines for installation. * https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md * @param manifest full package manifest * @return the abbreviated manifest */ function convertAbbreviatedManifest(manifest) { if (debug.enabled) debug("converting %o to abbreviated manifest with %o versions", manifest.name, Object.keys(manifest.versions).length); const abbreviatedVersions = Object.keys(manifest.versions).reduce((acc, version) => { const _version = manifest.versions[version]; acc[version] = { name: _version.name, version: _version.version, description: _version.description, deprecated: _version.deprecated, bin: _version.bin, dist: _version.dist, engines: _version.engines, cpu: _version.cpu, os: _version.os, funding: _version.funding, directories: _version.directories, dependencies: _version.dependencies, devDependencies: _version.devDependencies, peerDependencies: _version.peerDependencies, peerDependenciesMeta: _version.peerDependenciesMeta, optionalDependencies: _version.optionalDependencies, bundleDependencies: _version.bundleDependencies, _hasShrinkwrap: _version._hasShrinkwrap, hasInstallScript: hasInstallScript(_version) }; return acc; }, {}); return { name: manifest["name"], [DIST_TAGS]: manifest[DIST_TAGS], versions: abbreviatedVersions, modified: manifest?.time?.modified, time: manifest?.time }; } //#endregion export { WHITELIST, checkPackageLocal, checkPackageRemote, cleanUpLinksRef, cleanUpReadme, convertAbbreviatedManifest, distFileFromVersion, generatePackageTemplate, generateRevision, getLatestReadme, hasInstallScript, isPublishablePackage, lookupDistFile, mergeUplinkTimeIntoLocal, normalizePackage, prepareSearchPackage, publishPackage, tarballMatchesFilename }; //# sourceMappingURL=storage-utils.mjs.map