diff --git a/app/port/controller/PackageVersionFileController.ts b/app/port/controller/PackageVersionFileController.ts index 000a6277..af277525 100644 --- a/app/port/controller/PackageVersionFileController.ts +++ b/app/port/controller/PackageVersionFileController.ts @@ -19,6 +19,7 @@ import { PackageManagerService } from '../../core/service/PackageManagerService' import { PackageVersionFile } from '../../core/entity/PackageVersionFile'; import { PackageVersion } from '../../core/entity/PackageVersion'; import { DistRepository } from '../../repository/DistRepository'; +import { Spec } from '../typebox'; type FileItem = { path: string, @@ -65,49 +66,51 @@ export class PackageVersionFileController extends AbstractController { } @HTTPMethod({ - // PUT /:fullname/:versionOrTag/files - path: `/:fullname(${FULLNAME_REG_STRING})/:versionOrTag/files`, + // PUT /:fullname/:versionSpec/files + path: `/:fullname(${FULLNAME_REG_STRING})/:versionSpec/files`, method: HTTPMethodEnum.PUT, }) @Middleware(AdminAccess) - async sync(@HTTPParam() fullname: string, @HTTPParam() versionOrTag: string) { + async sync(@Context() ctx: EggContext, @HTTPParam() fullname: string, @HTTPParam() versionSpec: string) { + ctx.tValidate(Spec, `${fullname}@${versionSpec}`); this.#requireUnpkgEnable(); const [ scope, name ] = getScopeAndName(fullname); const { packageVersion } = await this.packageManagerService.showPackageVersionByVersionOrTag( - scope, name, versionOrTag); + scope, name, versionSpec); if (!packageVersion) { - throw new NotFoundError(`${fullname}@${versionOrTag} not found`); + throw new NotFoundError(`${fullname}@${versionSpec} not found`); } const files = await this.packageVersionFileService.syncPackageVersionFiles(packageVersion); return files.map(file => formatFileItem(file)); } @HTTPMethod({ - // GET /:fullname/:versionOrTag/files => /:fullname/:versionOrTag/files/${pkg.main} - // GET /:fullname/:versionOrTag/files?meta - // GET /:fullname/:versionOrTag/files/ - path: `/:fullname(${FULLNAME_REG_STRING})/:versionOrTag/files`, + // GET /:fullname/:versionSpec/files => /:fullname/:versionSpec/files/${pkg.main} + // GET /:fullname/:versionSpec/files?meta + // GET /:fullname/:versionSpec/files/ + path: `/:fullname(${FULLNAME_REG_STRING})/:versionSpec/files`, method: HTTPMethodEnum.GET, }) async listFiles(@Context() ctx: EggContext, @HTTPParam() fullname: string, - @HTTPParam() versionOrTag: string, + @HTTPParam() versionSpec: string, @HTTPQuery() meta: string) { this.#requireUnpkgEnable(); + ctx.tValidate(Spec, `${fullname}@${versionSpec}`); ctx.vary(this.config.cnpmcore.cdnVaryHeader); const [ scope, name ] = getScopeAndName(fullname); - const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionOrTag); + const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionSpec); ctx.set('cache-control', META_CACHE_CONTROL); const hasMeta = typeof meta === 'string' || ctx.path.endsWith('/files/'); // meta request if (hasMeta) { const files = await this.#listFilesByDirectory(packageVersion, '/'); if (!files) { - throw new NotFoundError(`${fullname}@${versionOrTag}/files not found`); + throw new NotFoundError(`${fullname}@${versionSpec}/files not found`); } return files; } - const { manifest } = await this.packageManagerService.showPackageVersionManifest(scope, name, versionOrTag, false, true); + const { manifest } = await this.packageManagerService.showPackageVersionManifest(scope, name, versionSpec, false, true); // GET /foo/1.0.0/files => /foo/1.0.0/files/{main} // ignore empty entry exp: @types/node@20.2.5/ const indexFile = manifest?.main || 'index.js'; @@ -115,26 +118,27 @@ export class PackageVersionFileController extends AbstractController { } @HTTPMethod({ - // GET /:fullname/:versionOrTag/files/:path - // GET /:fullname/:versionOrTag/files/:path?meta - path: `/:fullname(${FULLNAME_REG_STRING})/:versionOrTag/files/:path(.+)`, + // GET /:fullname/:versionSpec/files/:path + // GET /:fullname/:versionSpec/files/:path?meta + path: `/:fullname(${FULLNAME_REG_STRING})/:versionSpec/files/:path(.+)`, method: HTTPMethodEnum.GET, }) async raw(@Context() ctx: EggContext, @HTTPParam() fullname: string, - @HTTPParam() versionOrTag: string, + @HTTPParam() versionSpec: string, @HTTPParam() path: string, @HTTPQuery() meta: string) { this.#requireUnpkgEnable(); + ctx.tValidate(Spec, `${fullname}@${versionSpec}`); ctx.vary(this.config.cnpmcore.cdnVaryHeader); const [ scope, name ] = getScopeAndName(fullname); path = `/${path}`; - const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionOrTag); + const packageVersion = await this.#getPackageVersion(ctx, fullname, scope, name, versionSpec); if (path.endsWith('/')) { const directory = path.substring(0, path.length - 1); const files = await this.#listFilesByDirectory(packageVersion, directory); if (!files) { - throw new NotFoundError(`${fullname}@${versionOrTag}/files${directory} not found`); + throw new NotFoundError(`${fullname}@${versionSpec}/files${directory} not found`); } ctx.set('cache-control', META_CACHE_CONTROL); return files; @@ -142,7 +146,7 @@ export class PackageVersionFileController extends AbstractController { const file = await this.packageVersionFileService.showPackageVersionFile(packageVersion, path); if (!file) { - throw new NotFoundError(`File ${fullname}@${versionOrTag}${path} not found`); + throw new NotFoundError(`File ${fullname}@${versionSpec}${path} not found`); } const hasMeta = typeof meta === 'string'; if (hasMeta) { @@ -157,20 +161,20 @@ export class PackageVersionFileController extends AbstractController { return await this.distRepository.getDistStream(file.dist); } - async #getPackageVersion(ctx: EggContext, fullname: string, scope: string, name: string, versionOrTag: string) { + async #getPackageVersion(ctx: EggContext, fullname: string, scope: string, name: string, versionSpec: string) { const { blockReason, packageVersion } = await this.packageManagerService.showPackageVersionByVersionOrTag( - scope, name, versionOrTag); + scope, name, versionSpec); if (blockReason) { this.setCDNHeaders(ctx); - throw this.createPackageBlockError(blockReason, fullname, versionOrTag); + throw this.createPackageBlockError(blockReason, fullname, versionSpec); } if (!packageVersion) { - throw new NotFoundError(`${fullname}@${versionOrTag} not found`); + throw new NotFoundError(`${fullname}@${versionSpec} not found`); } - if (packageVersion.version !== versionOrTag) { + if (packageVersion.version !== versionSpec) { ctx.set('cache-control', META_CACHE_CONTROL); - let location = ctx.url.replace(`/${fullname}/${versionOrTag}/files`, `/${fullname}/${packageVersion.version}/files`); - location = location.replace(`/${fullname}/${encodeURIComponent(versionOrTag)}/files`, `/${fullname}/${packageVersion.version}/files`); + let location = ctx.url.replace(`/${fullname}/${versionSpec}/files`, `/${fullname}/${packageVersion.version}/files`); + location = location.replace(`/${fullname}/${encodeURIComponent(versionSpec)}/files`, `/${fullname}/${packageVersion.version}/files`); throw this.createControllerRedirectError(location); } return packageVersion; diff --git a/app/port/controller/package/ShowPackageVersionController.ts b/app/port/controller/package/ShowPackageVersionController.ts index 1d8cb7d9..7ac76e56 100644 --- a/app/port/controller/package/ShowPackageVersionController.ts +++ b/app/port/controller/package/ShowPackageVersionController.ts @@ -12,6 +12,7 @@ import { AbstractController } from '../AbstractController'; import { getScopeAndName, FULLNAME_REG_STRING } from '../../../common/PackageUtil'; import { isSyncWorkerRequest } from '../../../common/SyncUtil'; import { PackageManagerService } from '../../../core/service/PackageManagerService'; +import { Spec } from 'app/port/typebox'; @HTTPController() export class ShowPackageVersionController extends AbstractController { @@ -19,28 +20,29 @@ export class ShowPackageVersionController extends AbstractController { private packageManagerService: PackageManagerService; @HTTPMethod({ - // GET /:fullname/:versionOrTag - path: `/:fullname(${FULLNAME_REG_STRING})/:versionOrTag`, + // GET /:fullname/:versionSpec + path: `/:fullname(${FULLNAME_REG_STRING})/:versionSpec`, method: HTTPMethodEnum.GET, }) - async show(@Context() ctx: EggContext, @HTTPParam() fullname: string, @HTTPParam() versionOrTag: string) { + async show(@Context() ctx: EggContext, @HTTPParam() fullname: string, @HTTPParam() versionSpec: string) { // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#full-metadata-format + ctx.tValidate(Spec, `${fullname}@${versionSpec}`); const [ scope, name ] = getScopeAndName(fullname); const isSync = isSyncWorkerRequest(ctx); const abbreviatedMetaType = 'application/vnd.npm.install-v1+json'; const isFullManifests = ctx.accepts([ 'json', abbreviatedMetaType ]) !== abbreviatedMetaType; - const { blockReason, manifest, pkg } = await this.packageManagerService.showPackageVersionManifest(scope, name, versionOrTag, isSync, isFullManifests); + const { blockReason, manifest, pkg } = await this.packageManagerService.showPackageVersionManifest(scope, name, versionSpec, isSync, isFullManifests); if (!pkg) { const allowSync = this.getAllowSync(ctx); throw this.createPackageNotFoundErrorWithRedirect(fullname, undefined, allowSync); } if (blockReason) { this.setCDNHeaders(ctx); - throw this.createPackageBlockError(blockReason, fullname, versionOrTag); + throw this.createPackageBlockError(blockReason, fullname, versionSpec); } if (!manifest) { - throw new NotFoundError(`${fullname}@${versionOrTag} not found`); + throw new NotFoundError(`${fullname}@${versionSpec} not found`); } this.setCDNHeaders(ctx); return manifest; diff --git a/app/port/typebox.ts b/app/port/typebox.ts index e2f0125b..9f7de008 100644 --- a/app/port/typebox.ts +++ b/app/port/typebox.ts @@ -1,6 +1,7 @@ import { Type, Static } from '@sinclair/typebox'; import { RegistryType } from '../common/enum/Registry'; import semver from 'semver'; +import npa from 'npm-package-arg'; import { HookType } from '../common/enum/Hook'; import binaryConfig from '../../config/binaries'; @@ -53,6 +54,11 @@ export const Version = Type.String({ maxLength: 256, }); +export const Spec = Type.String({ + format: 'semver-spec', + minLength: 1, +}); + export const Description = Type.String({ maxLength: 10240, transform: [ 'trim' ] }); export const TagRule = Type.Object({ @@ -125,6 +131,16 @@ export function patchAjv(ajv: any) { return !semver.validRange(tag); }, }); + ajv.addFormat('semver-spec', { + type: 'string', + validate: (spec: string) => { + try { + return !!npa(spec); + } catch (e) { + return false; + } + }, + }); ajv.addFormat('binary-name', { type: 'string', validate: (binaryName: string) => { diff --git a/test/port/controller/PackageVersionFileController/listFiles.test.ts b/test/port/controller/PackageVersionFileController/listFiles.test.ts index 8b143871..7db8a54f 100644 --- a/test/port/controller/PackageVersionFileController/listFiles.test.ts +++ b/test/port/controller/PackageVersionFileController/listFiles.test.ts @@ -10,7 +10,7 @@ describe('test/port/controller/PackageVersionFileController/listFiles.test.ts', publisher = await TestUtil.createUser(); }); - describe('[GET /:fullname/:versionOrTag/files] listFiles()', () => { + describe('[GET /:fullname/:versionSpec/files] listFiles()', () => { it('should 404 when enableUnpkg = false', async () => { mock(app.config.cnpmcore, 'allowPublishNonScopePackage', true); mock(app.config.cnpmcore, 'enableUnpkg', false); @@ -68,6 +68,15 @@ describe('test/port/controller/PackageVersionFileController/listFiles.test.ts', assert.equal(res.body.error, '[NOT_FOUND] File foo@1.0.0/index.js not found'); }); + it('should 422 when invalid spec', async () => { + mock(app.config.cnpmcore, 'enableUnpkg', true); + const res = await app.httpRequest() + .get('/foo/@invalid-spec/files') + .expect(422); + + assert.equal(res.body.error, '[INVALID_PARAM] must match format "semver-spec"'); + }); + it('should list one package version files', async () => { mock(app.config.cnpmcore, 'allowPublishNonScopePackage', true); const pkg = await TestUtil.getFullPackage({ diff --git a/test/port/controller/PackageVersionFileController/raw.test.ts b/test/port/controller/PackageVersionFileController/raw.test.ts index ae1fffc3..8164d455 100644 --- a/test/port/controller/PackageVersionFileController/raw.test.ts +++ b/test/port/controller/PackageVersionFileController/raw.test.ts @@ -11,7 +11,7 @@ describe('test/port/controller/PackageVersionFileController/raw.test.ts', () => publisher = await TestUtil.createUser(); }); - describe('[GET /:fullname/:versionOrTag/files/:path] raw()', () => { + describe('[GET /:fullname/:versionSpec/files/:path] raw()', () => { it('should show one package version raw file', async () => { mock(app.config.cnpmcore, 'allowPublishNonScopePackage', true); const pkg = await TestUtil.getFullPackage({ @@ -137,6 +137,15 @@ describe('test/port/controller/PackageVersionFileController/raw.test.ts', () => assert.equal(res.body.error, `[NOT_FOUND] File ${pkg.name}@1.0.0/package2.json not found`); }); + it('should 422 when invalid spec', async () => { + mock(app.config.cnpmcore, 'enableUnpkg', true); + const res = await app.httpRequest() + .get('/foo/@invalid-spec/files/package.json?meta') + .expect(422); + + assert.equal(res.body.error, '[INVALID_PARAM] must match format "semver-spec"'); + }); + it('should ignore not exists file on tar onentry', async () => { const tarball = await TestUtil.readFixturesFile('unpkg.com/ide-metrics-api-grpc-0.0.1-main-gha.8962.tgz'); const { integrity } = await calculateIntegrity(tarball); diff --git a/test/port/controller/PackageVersionFileController/sync.test.ts b/test/port/controller/PackageVersionFileController/sync.test.ts index 4a59df76..0ab9a939 100644 --- a/test/port/controller/PackageVersionFileController/sync.test.ts +++ b/test/port/controller/PackageVersionFileController/sync.test.ts @@ -10,7 +10,7 @@ describe('test/port/controller/PackageVersionFileController/sync.test.ts', () => adminUser = await TestUtil.createAdmin(); }); - describe('[PUT /:fullname/:versionOrTag/files] sync()', () => { + describe('[PUT /:fullname/:versionSpec/files] sync()', () => { it('should 404 when enableUnpkg = false', async () => { mock(app.config.cnpmcore, 'allowPublishNonScopePackage', true); mock(app.config.cnpmcore, 'enableUnpkg', false); diff --git a/test/port/controller/package/ShowPackageVersionController.test.ts b/test/port/controller/package/ShowPackageVersionController.test.ts index 70e8620e..54ec2e55 100644 --- a/test/port/controller/package/ShowPackageVersionController.test.ts +++ b/test/port/controller/package/ShowPackageVersionController.test.ts @@ -10,7 +10,7 @@ describe('test/port/controller/package/ShowPackageVersionController.test.ts', () publisher = await TestUtil.createUser(); }); - describe('[GET /:fullname/:versionOrTag] show()', () => { + describe('[GET /:fullname/:versionSpec] show()', () => { it('should show one package version', async () => { mock(app.config.cnpmcore, 'allowPublishNonScopePackage', true); const pkg = await TestUtil.getFullPackage({ @@ -37,6 +37,15 @@ describe('test/port/controller/package/ShowPackageVersionController.test.ts', () assert.equal(res.body.dist.integrity, 'sha512-n+4CQg0Rp1Qo0p9a0R5E5io67T9iD3Lcgg6exmpmt0s8kd4XcOoHu2kiu6U7xd69cGq0efkNGWUBP229ObfRSA=='); assert.equal(res.body.dist.size, 251); assert.equal(res.body.description, 'work with utf8mb4 ๐Ÿ’ฉ, ๐Œ† utf8_unicode_ci, foo๐Œ†bar ๐Ÿป'); + + // support semver spec + await app.httpRequest() + .get('/foo/%5E1.0') + .expect(200); + + await app.httpRequest() + .get('/foo/^1.0') + .expect(200); }); it('should fix bug version', async () => { @@ -125,6 +134,14 @@ describe('test/port/controller/package/ShowPackageVersionController.test.ts', () assert(res.body.version === '2.0.0'); }); + it('should 422 with invalid spec', async () => { + const res = await app.httpRequest() + .get('/foo/@invalid-spec') + .expect(422) + .expect('content-type', 'application/json; charset=utf-8'); + assert(res.error, '[INVALID_PARAM] must match format "semver-spec"'); + }); + it('should work with scoped package', async () => { const pkg = await TestUtil.getFullPackage({ name: '@cnpm/foo',