Files
cnpmcore/app/port/controller/package/SavePackageVersionController.ts
fengmk2 4427a4fca5 feat: use egg v4 (#747)
BREAKING CHANGE: only support egg >= 4.0.0

the first app on egg v4

https://github.com/eggjs/egg/issues/3644
2025-02-09 15:43:24 +08:00

280 lines
11 KiB
TypeScript

import { PackageJson, Simplify } from 'type-fest';
import { isEqual } from 'lodash';
import {
UnprocessableEntityError,
ForbiddenError,
ConflictError,
} from 'egg-errors';
import {
HTTPController,
HTTPMethod,
HTTPMethodEnum,
HTTPParam,
HTTPBody,
Inject,
Context,
EggContext,
} from '@eggjs/tegg';
import * as ssri from 'ssri';
import validateNpmPackageName from 'validate-npm-package-name';
import { Static, Type } from 'egg-typebox-validate-fengmk2/typebox';
import { AbstractController } from '../AbstractController';
import { getScopeAndName, FULLNAME_REG_STRING, extractPackageJSON } from '../../../common/PackageUtil';
import { PackageManagerService } from '../../../core/service/PackageManagerService';
import { PackageVersion as PackageVersionEntity } from '../../../core/entity/PackageVersion';
import {
VersionRule,
TagWithVersionRule,
Name as NameType,
Description as DescriptionType,
} from '../../typebox';
import { RegistryManagerService } from '../../../core/service/RegistryManagerService';
import { PackageJSONType } from '../../../repository/PackageRepository';
import { CacheAdapter } from '../../../common/adapter/CacheAdapter';
const STRICT_CHECK_TARBALL_FIELDS: (keyof PackageJson)[] = [ 'name', 'version', 'scripts', 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', 'license', 'licenses', 'bin' ];
type PackageVersion = Simplify<PackageJson.PackageJsonStandard & {
name: 'string';
version: 'string';
deprecated?: 'string';
readme?: 'string';
dist?: {
shasum: string;
integrity: string;
[key: string]: string | number;
},
}>;
const FullPackageRule = Type.Object({
name: NameType,
// Since we don't validate versions & _attachments previous, here we use Type.Any() just for object validate
versions: Type.Optional(Type.Any()),
_attachments: Type.Optional(Type.Any()),
description: Type.Optional(DescriptionType),
'dist-tags': Type.Optional(Type.Record(Type.String(), Type.String())),
readme: Type.Optional(Type.String({ transform: [ 'trim' ] })),
});
// overwrite versions & _attachments
type FullPackage = Omit<Static<typeof FullPackageRule>, 'versions' | '_attachments'> &
{ versions: { [key: string]: PackageVersion } } &
{ _attachments: {
[key: string]: {
content_type: string;
data: string;
length: number;
};
}};
// base64 regex https://stackoverflow.com/questions/475074/regex-to-parse-or-validate-base64-data/475217#475217
const PACKAGE_ATTACH_DATA_RE = /^[A-Za-z0-9+/]{4}/;
@HTTPController()
export class SavePackageVersionController extends AbstractController {
@Inject()
private readonly packageManagerService: PackageManagerService;
@Inject()
private readonly registryManagerService: RegistryManagerService;
@Inject()
private readonly cacheAdapter: CacheAdapter;
// https://github.com/cnpm/cnpmjs.org/blob/master/docs/registry-api.md#publish-a-new-package
// https://github.com/npm/libnpmpublish/blob/main/publish.js#L43
@HTTPMethod({
// PUT /:fullname
// https://www.npmjs.com/package/path-to-regexp#custom-matching-parameters
path: `/:fullname(${FULLNAME_REG_STRING})`,
method: HTTPMethodEnum.PUT,
})
async save(@Context() ctx: EggContext, @HTTPParam() fullname: string, @HTTPBody() pkg: FullPackage) {
this.validateNpmCommand(ctx);
ctx.tValidate(FullPackageRule, pkg);
const { user } = await this.ensurePublishAccess(ctx, fullname, false);
fullname = fullname.trim();
if (fullname !== pkg.name) {
throw new UnprocessableEntityError(`fullname(${fullname}) not match package.name(${pkg.name})`);
}
// Using https://github.com/npm/validate-npm-package-name to validate package name
const validateResult = validateNpmPackageName(pkg.name);
if (!validateResult.validForNewPackages) {
// if pkg already exists, still allow to publish
const [ scope, name ] = getScopeAndName(fullname);
const pkg = await this.packageRepository.findPackage(scope, name);
if (!pkg) {
const errors = (validateResult.errors || validateResult.warnings || []).join(', ');
throw new UnprocessableEntityError(`package.name invalid, errors: ${errors}`);
}
}
const versions = Object.values(pkg.versions);
if (versions.length === 0) {
throw new UnprocessableEntityError('versions is empty');
}
// auth maintainter
const attachments = pkg._attachments ?? {};
const attachmentFilename = Object.keys(attachments)[0];
if (!attachmentFilename) {
// `deprecated: ''` meaning remove deprecated message
const isDeprecatedRequest = versions.some(version => 'deprecated' in version);
// handle deprecated request
// PUT /:fullname?write=true
// https://github.com/npm/cli/blob/latest/lib/commands/deprecate.js#L48
if (isDeprecatedRequest) {
return await this.saveDeprecatedVersions(pkg.name, versions);
}
// invalid attachments
throw new UnprocessableEntityError('_attachments is empty');
}
// handle add new version
const packageVersion = versions[0];
// check version format
ctx.tValidate(VersionRule, packageVersion);
const attachment = attachments[attachmentFilename];
const distTags = pkg['dist-tags'] ?? {};
let tagNames = Object.keys(distTags);
if (tagNames.length === 0) {
throw new UnprocessableEntityError('dist-tags is empty');
}
const [ scope, name ] = getScopeAndName(fullname);
// see @https://github.com/cnpm/cnpmcore/issues/574
// add default latest tag
if (!pkg['dist-tags']!.latest) {
const existsPkg = await this.packageRepository.findPackage(scope, name);
const existsLatestTag = existsPkg && await this.packageRepository.findPackageTag(existsPkg?.packageId, 'latest');
if (!existsPkg || !existsLatestTag) {
this.logger.warn('[package:version:add] add default latest tag');
pkg['dist-tags']!.latest = pkg['dist-tags']![tagNames[0]];
tagNames = [ ...tagNames, 'latest' ];
}
}
const tagWithVersion = { tag: tagNames[0], version: distTags[tagNames[0]] };
ctx.tValidate(TagWithVersionRule, tagWithVersion);
if (tagWithVersion.version !== packageVersion.version) {
throw new UnprocessableEntityError(`dist-tags version "${tagWithVersion.version}" not match package version "${packageVersion.version}"`);
}
// check attachment data format and size
if (!attachment.data || typeof attachment.data !== 'string') {
throw new UnprocessableEntityError('attachment.data format invalid');
}
if (!PACKAGE_ATTACH_DATA_RE.test(attachment.data)) {
throw new UnprocessableEntityError('attachment.data string format invalid');
}
const tarballBytes = Buffer.from(attachment.data, 'base64');
if (tarballBytes.length !== attachment.length) {
throw new UnprocessableEntityError(`attachment size ${attachment.length} not match download size ${tarballBytes.length}`);
}
// check integrity or shasum
const integrity = packageVersion.dist?.integrity;
// for content security reason
// check integrity
if (integrity) {
const algorithm = ssri.checkData(tarballBytes, integrity);
if (!algorithm) {
throw new UnprocessableEntityError('dist.integrity invalid');
}
} else {
const integrityObj = ssri.fromData(tarballBytes, {
algorithms: [ 'sha1' ],
});
const shasum = integrityObj.sha1[0].hexDigest();
if (packageVersion.dist?.shasum && packageVersion.dist.shasum !== shasum) {
// if integrity not exists, check shasum
throw new UnprocessableEntityError('dist.shasum invalid');
}
}
// https://github.com/cnpm/cnpmcore/issues/542
// check tgz & manifests
if (this.config.cnpmcore.strictValidateTarballPkg) {
const tarballPkg = await extractPackageJSON(tarballBytes);
const versionManifest = pkg.versions[tarballPkg.version];
const diffKeys = STRICT_CHECK_TARBALL_FIELDS.filter(key => {
const targetKey = key as unknown as keyof typeof versionManifest;
return !isEqual(tarballPkg[key], versionManifest[targetKey]);
});
if (diffKeys.length > 0) {
throw new UnprocessableEntityError(`${diffKeys} mismatch between tarball and manifest`);
}
}
// make sure readme is string
const readme = typeof packageVersion.readme === 'string' ? packageVersion.readme : '';
// remove readme
packageVersion.readme = undefined;
// make sure description is string
if (typeof packageVersion.description !== 'string') {
packageVersion.description = '';
}
const registry = await this.registryManagerService.ensureSelfRegistry();
let packageVersionEntity: PackageVersionEntity | undefined;
const lockName = `${pkg.name}:publish`;
const lockRes = await this.cacheAdapter.usingLock(`${pkg.name}:publish`, 60, async () => {
packageVersionEntity = await this.packageManagerService.publish({
scope,
name,
version: packageVersion.version,
description: packageVersion.description as string,
packageJson: packageVersion as PackageJSONType,
readme,
dist: {
content: tarballBytes,
},
tags: tagNames,
registryId: registry.registryId,
isPrivate: true,
}, user);
});
// lock fail
if (!lockRes) {
this.logger.warn('[package:version:add] check lock:%s fail', lockName);
throw new ConflictError('Unable to create the publication lock, please try again later.');
}
this.logger.info('[package:version:add] %s@%s, packageVersionId: %s, tag: %s, userId: %s',
packageVersion.name, packageVersion.version, packageVersionEntity?.packageVersionId,
tagWithVersion.tag, user?.userId);
ctx.status = 201;
return {
ok: true,
rev: `${packageVersionEntity?.id}-${packageVersionEntity?.packageVersionId}`,
};
}
// https://github.com/cnpm/cnpmjs.org/issues/415
private async saveDeprecatedVersions(fullname: string, versions: PackageVersion[]) {
const pkg = await this.getPackageEntityByFullname(fullname);
await this.packageManagerService.saveDeprecatedVersions(pkg, versions.map(v => {
return { version: v.version, deprecated: v.deprecated! };
}));
return { ok: true };
}
private validateNpmCommand(ctx: EggContext) {
// forbidden star/unstar request
// npm@6: referer: 'star [REDACTED]'
// npm@>=7: 'npm-command': 'star'
let command = ctx.get<string>('npm-command');
if (!command) {
command = ctx.get<string>('referer').split(' ', 1)[0];
}
if (command === 'star' || command === 'unstar') {
throw new ForbiddenError(`npm ${command} is not allowed`);
}
}
}