mirror of
https://github.com/verdaccio/verdaccio.git
synced 2024-12-30 22:34:10 -05:00
feat: add support for npm owner (#4582)
* feat: add support for npm owner * Revert debug msg * Finish feature and add test cases * Fix remote user name and more tests * Simplify passing remote user * Update version metadata with owners * Add test for validateUserName * Add comment for "change owner" * add config option * add check to removePackage, removeTarball * typo * check access when write=true * Add to config, fix undefined user * Update docs * Update docs * Update readme
This commit is contained in:
parent
ecbc569f23
commit
6e764e3c49
26 changed files with 816 additions and 39 deletions
9
.changeset/grumpy-pots-watch.md
Normal file
9
.changeset/grumpy-pots-watch.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
'@verdaccio/types': patch
|
||||
'@verdaccio/config': patch
|
||||
'@verdaccio/core': patch
|
||||
'@verdaccio/store': patch
|
||||
'@verdaccio/api': patch
|
||||
---
|
||||
|
||||
feat: add support for npm owner
|
|
@ -251,7 +251,7 @@ Verdaccio aims to support all features of a standard npm client that make sense
|
|||
|
||||
- Registering new users (`npm adduser {newuser}`) - **supported**
|
||||
- Change password (`npm profile set password`) - **supported**
|
||||
- Transferring ownership (`npm owner add {user} {pkg}`) - not supported, _PR-welcome_
|
||||
- Transferring ownership (`npm owner`) - **supported**
|
||||
- Token (`npm token`) - **supported**
|
||||
|
||||
### Miscellaneous
|
||||
|
|
|
@ -28,6 +28,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void {
|
|||
const name = req.params.package;
|
||||
let version = req.params.version;
|
||||
const write = req.query.write === 'true';
|
||||
const username = req?.remote_user?.name;
|
||||
const abbreviated =
|
||||
stringUtils.getByQualityPriorityValue(req.get('Accept')) === Storage.ABBREVIATED_HEADER;
|
||||
const requestOptions = {
|
||||
|
@ -37,6 +38,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void {
|
|||
host: req.host,
|
||||
remoteAddress: req.socket.remoteAddress,
|
||||
byPassCache: write,
|
||||
username,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
@ -76,11 +76,11 @@ const debug = buildDebug('verdaccio:api:publish');
|
|||
*
|
||||
* 3. Star a package
|
||||
*
|
||||
* Permissions: start a package depends of the publish and unpublish permissions, there is no
|
||||
* specific flag for star or un start.
|
||||
* Permissions: staring a package depends of the publish and unpublish permissions, there is no
|
||||
* specific flag for star or unstar.
|
||||
* The URL for star is similar to the unpublish (change package format)
|
||||
*
|
||||
* npm has no endpoint for star a package, rather mutate the metadata and acts as, the difference
|
||||
* npm has no endpoint for staring a package, rather mutate the metadata and acts as, the difference
|
||||
* is the users property which is part of the payload and the body only includes
|
||||
*
|
||||
* {
|
||||
|
@ -91,6 +91,23 @@ const debug = buildDebug('verdaccio:api:publish');
|
|||
}
|
||||
}
|
||||
*
|
||||
* 4. Change owners of a package
|
||||
*
|
||||
* Similar to staring a package, changing owners (maintainers) of a package uses the publish
|
||||
* endpoint.
|
||||
*
|
||||
* The body includes a list of the new owners with the following format
|
||||
*
|
||||
* {
|
||||
"_id": pkgName,
|
||||
"_rev": "4-b0cdaefc9bdb77c8",
|
||||
"maintainers": [
|
||||
{ "name": "first owner", "email": "me@verdaccio.org" },
|
||||
{ "name": "second owner", "email": "you@verdaccio.org" },
|
||||
...
|
||||
]
|
||||
}
|
||||
*
|
||||
*/
|
||||
export default function publish(router: Router, auth: Auth, storage: Storage): void {
|
||||
const can = allow(auth, {
|
||||
|
@ -127,10 +144,11 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v
|
|||
async function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) {
|
||||
const packageName = req.params.package;
|
||||
const rev = req.params.revision;
|
||||
const username = req?.remote_user?.name;
|
||||
|
||||
logger.debug({ packageName }, `unpublishing @{packageName}`);
|
||||
try {
|
||||
await storage.removePackage(packageName, rev);
|
||||
await storage.removePackage(packageName, rev, username);
|
||||
debug('package %s unpublished', packageName);
|
||||
res.status(HTTP_STATUS.CREATED);
|
||||
return next({ ok: API_MESSAGE.PKG_REMOVED });
|
||||
|
@ -155,13 +173,14 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v
|
|||
): Promise<void> {
|
||||
const packageName = req.params.package;
|
||||
const { filename, revision } = req.params;
|
||||
const username = req?.remote_user?.name;
|
||||
|
||||
logger.debug(
|
||||
{ packageName, filename, revision },
|
||||
`removing a tarball for @{packageName}-@{tarballName}-@{revision}`
|
||||
);
|
||||
try {
|
||||
await storage.removeTarball(packageName, filename, revision);
|
||||
await storage.removeTarball(packageName, filename, revision, username);
|
||||
res.status(HTTP_STATUS.CREATED);
|
||||
|
||||
logger.debug(
|
||||
|
@ -188,6 +207,12 @@ export function publishPackage(storage: Storage): any {
|
|||
const metadata = req.body;
|
||||
const username = req?.remote_user?.name;
|
||||
|
||||
debug('publishing package %o for user %o', packageName, username);
|
||||
logger.debug(
|
||||
{ packageName, username },
|
||||
'publishing package @{packageName} for user @{username}'
|
||||
);
|
||||
|
||||
try {
|
||||
const message = await storage.updateManifest(metadata, {
|
||||
name: packageName,
|
||||
|
|
|
@ -27,10 +27,22 @@ export default function (route: Router, auth: Auth, config: Config): void {
|
|||
rateLimit(config?.userRateLimit),
|
||||
function (req: $RequestExtend, res: Response, next: $NextFunctionVer): void {
|
||||
debug('verifying user');
|
||||
|
||||
if (typeof req.remote_user.name !== 'string' || req.remote_user.name === '') {
|
||||
debug('user not logged in');
|
||||
res.status(HTTP_STATUS.OK);
|
||||
return next({ ok: false });
|
||||
}
|
||||
|
||||
const username = req.params.org_couchdb_user.split(':')[1];
|
||||
const message = getAuthenticatedMessage(req.remote_user.name);
|
||||
debug('user authenticated message %o', message);
|
||||
res.status(HTTP_STATUS.OK);
|
||||
next({
|
||||
// 'npm owner' requires user info
|
||||
// TODO: we don't have the email
|
||||
name: username,
|
||||
email: '',
|
||||
ok: message,
|
||||
});
|
||||
}
|
||||
|
@ -61,6 +73,10 @@ export default function (route: Router, auth: Auth, config: Config): void {
|
|||
debug('login or adduser');
|
||||
const remoteName = req?.remote_user?.name;
|
||||
|
||||
if (!validatioUtils.validateUserName(req.params.org_couchdb_user, name)) {
|
||||
return next(errorUtils.getBadRequest(API_ERROR.USERNAME_MISMATCH));
|
||||
}
|
||||
|
||||
if (typeof remoteName !== 'undefined' && typeof name === 'string' && remoteName === name) {
|
||||
debug('login: no remote user detected');
|
||||
auth.authenticate(
|
||||
|
@ -97,6 +113,7 @@ export default function (route: Router, auth: Auth, config: Config): void {
|
|||
}
|
||||
);
|
||||
} else {
|
||||
debug('adduser: %o', name);
|
||||
if (
|
||||
validatioUtils.validatePassword(
|
||||
password,
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
generatePackageMetadata,
|
||||
initializeServer as initializeServerHelper,
|
||||
} from '@verdaccio/test-helper';
|
||||
import { GenericBody, PackageUsers } from '@verdaccio/types';
|
||||
import { Author, GenericBody, PackageUsers } from '@verdaccio/types';
|
||||
import { buildToken, generateRandomHexString } from '@verdaccio/utils';
|
||||
|
||||
import apiMiddleware from '../../src';
|
||||
|
@ -142,6 +142,37 @@ export function starPackage(
|
|||
return test;
|
||||
}
|
||||
|
||||
export function changeOwners(
|
||||
app,
|
||||
options: {
|
||||
maintainers: Author[];
|
||||
name: string;
|
||||
_rev: string;
|
||||
_id?: string;
|
||||
},
|
||||
token?: string
|
||||
): supertest.Test {
|
||||
const { _rev, _id, maintainers } = options;
|
||||
const ownerManifest = {
|
||||
_rev,
|
||||
_id,
|
||||
maintainers,
|
||||
};
|
||||
|
||||
const test = supertest(app)
|
||||
.put(`/${encodeURIComponent(options.name)}`)
|
||||
.set(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON)
|
||||
.send(JSON.stringify(ownerManifest))
|
||||
.set('accept', HEADERS.GZIP)
|
||||
.set(HEADER_TYPE.ACCEPT_ENCODING, HEADERS.JSON);
|
||||
|
||||
if (typeof token === 'string') {
|
||||
test.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token));
|
||||
}
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
export function getDisTags(app, pkgName) {
|
||||
return supertest(app)
|
||||
.get(`/-/package/${encodeURIComponent(pkgName)}/dist-tags`)
|
||||
|
|
24
packages/api/test/integration/config/owner.yaml
Normal file
24
packages/api/test/integration/config/owner.yaml
Normal file
|
@ -0,0 +1,24 @@
|
|||
storage: ./storage
|
||||
|
||||
auth:
|
||||
htpasswd:
|
||||
file: ./htpasswd-owner
|
||||
|
||||
web:
|
||||
enable: true
|
||||
title: verdaccio
|
||||
|
||||
log: { type: stdout, format: pretty, level: info }
|
||||
|
||||
# TODO: Add test case for $owner access
|
||||
packages:
|
||||
'@*/*':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
unpublish: $authenticated
|
||||
'**':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
unpublish: $authenticated
|
||||
|
||||
_debug: true
|
118
packages/api/test/integration/owner.spec.ts
Normal file
118
packages/api/test/integration/owner.spec.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
/* eslint-disable jest/no-commented-out-tests */
|
||||
import nock from 'nock';
|
||||
|
||||
import { HTTP_STATUS } from '@verdaccio/core';
|
||||
|
||||
import {
|
||||
changeOwners,
|
||||
createUser,
|
||||
getPackage,
|
||||
initializeServer,
|
||||
publishVersionWithToken,
|
||||
} from './_helper';
|
||||
|
||||
describe('owner', () => {
|
||||
test.each([['foo', '@scope%2Ffoo']])('should get owner of package', async (pkgName) => {
|
||||
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
|
||||
const app = await initializeServer('owner.yaml');
|
||||
const credentials = { name: 'test', password: 'test' };
|
||||
const response = await createUser(app, credentials.name, credentials.password);
|
||||
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
|
||||
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
|
||||
HTTP_STATUS.CREATED
|
||||
);
|
||||
|
||||
// expect publish to set owner to logged in user
|
||||
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
|
||||
const maintainers = manifest.body.maintainers;
|
||||
expect(maintainers).toHaveLength(1);
|
||||
// TODO: This should eventually include the email of the user
|
||||
expect(maintainers).toEqual([{ name: credentials.name, email: '' }]);
|
||||
});
|
||||
|
||||
test.each([['foo', '@scope%2Ffoo']])('should add/remove owner to package', async (pkgName) => {
|
||||
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
|
||||
const app = await initializeServer('owner.yaml');
|
||||
const credentials = { name: 'test', password: 'test' };
|
||||
const firstOwner = { name: 'test', email: '' };
|
||||
const response = await createUser(app, credentials.name, credentials.password);
|
||||
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
|
||||
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
|
||||
HTTP_STATUS.CREATED
|
||||
);
|
||||
|
||||
// publish sets owner to logged in user
|
||||
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
|
||||
const maintainers = manifest.body.maintainers;
|
||||
expect(maintainers).toHaveLength(1);
|
||||
expect(maintainers).toEqual([firstOwner]);
|
||||
|
||||
// add another owner
|
||||
const secondOwner = { name: 'tester', email: 'test@verdaccio.org' };
|
||||
const newOwners = [...maintainers, secondOwner];
|
||||
await changeOwners(
|
||||
app,
|
||||
{
|
||||
_rev: manifest.body._rev,
|
||||
_id: manifest.body._id,
|
||||
name: pkgName,
|
||||
maintainers: newOwners,
|
||||
},
|
||||
response.body.token
|
||||
).expect(HTTP_STATUS.CREATED);
|
||||
|
||||
const manifest2 = await getPackage(app, '', decodeURIComponent(pkgName));
|
||||
const maintainers2 = manifest2.body.maintainers;
|
||||
expect(maintainers2).toHaveLength(2);
|
||||
expect(maintainers2).toEqual([firstOwner, secondOwner]);
|
||||
|
||||
// remove original owner
|
||||
await changeOwners(
|
||||
app,
|
||||
{
|
||||
_rev: manifest2.body._rev,
|
||||
_id: manifest2.body._id,
|
||||
name: pkgName,
|
||||
maintainers: [secondOwner],
|
||||
},
|
||||
response.body.token
|
||||
).expect(HTTP_STATUS.CREATED);
|
||||
|
||||
const manifest3 = await getPackage(app, '', decodeURIComponent(pkgName));
|
||||
const maintainers3 = manifest3.body.maintainers;
|
||||
expect(maintainers3).toHaveLength(1);
|
||||
expect(maintainers3).toEqual([secondOwner]);
|
||||
});
|
||||
|
||||
test.each([['foo', '@scope%2Ffoo']])('should fail if user is not logged in', async (pkgName) => {
|
||||
nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404);
|
||||
const app = await initializeServer('owner.yaml');
|
||||
const credentials = { name: 'test', password: 'test' };
|
||||
const firstOwner = { name: 'test', email: '' };
|
||||
const response = await createUser(app, credentials.name, credentials.password);
|
||||
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
|
||||
await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect(
|
||||
HTTP_STATUS.CREATED
|
||||
);
|
||||
|
||||
// publish sets owner to logged in user
|
||||
const manifest = await getPackage(app, '', decodeURIComponent(pkgName));
|
||||
const maintainers = manifest.body.maintainers;
|
||||
expect(maintainers).toHaveLength(1);
|
||||
expect(maintainers).toEqual([firstOwner]);
|
||||
|
||||
// try adding another owner
|
||||
const secondOwner = { name: 'tester', email: 'test@verdaccio.org' };
|
||||
const newOwners = [...maintainers, secondOwner];
|
||||
await changeOwners(
|
||||
app,
|
||||
{
|
||||
_rev: manifest.body._rev,
|
||||
_id: manifest.body._id,
|
||||
name: pkgName,
|
||||
maintainers: newOwners,
|
||||
},
|
||||
'' // no token
|
||||
).expect(HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
});
|
|
@ -43,6 +43,12 @@ describe('search', () => {
|
|||
links: {
|
||||
npm: '',
|
||||
},
|
||||
maintainers: [
|
||||
{
|
||||
email: '',
|
||||
name: 'test',
|
||||
},
|
||||
],
|
||||
name: pkg,
|
||||
publisher: {},
|
||||
scope: '',
|
||||
|
@ -97,6 +103,12 @@ describe('search', () => {
|
|||
links: {
|
||||
npm: '',
|
||||
},
|
||||
maintainers: [
|
||||
{
|
||||
email: '',
|
||||
name: 'test',
|
||||
},
|
||||
],
|
||||
name: pkg,
|
||||
publisher: {},
|
||||
scope: '@scope',
|
||||
|
|
|
@ -148,6 +148,25 @@ describe('token', () => {
|
|||
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
|
||||
.expect(HTTP_STATUS.OK);
|
||||
expect(response2.body.ok).toBe(`you are authenticated as '${credentials.name}'`);
|
||||
expect(response2.body.name).toBe(credentials.name);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([['user.yaml'], ['user.jwt.yaml']])(
|
||||
'should return name of requested user',
|
||||
async (conf) => {
|
||||
const app = await initializeServer(conf);
|
||||
const username = 'yeti';
|
||||
const credentials = { name: 'jota', password: 'secretPass' };
|
||||
const response = await createUser(app, credentials.name, credentials.password);
|
||||
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
|
||||
const response3 = await supertest(app)
|
||||
.get(`/-/user/org.couchdb.user:${username}`)
|
||||
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token))
|
||||
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
|
||||
.expect(HTTP_STATUS.OK);
|
||||
expect(response3.body.ok).toBe(`you are authenticated as '${credentials.name}'`);
|
||||
expect(response3.body.name).toBe(username);
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -165,5 +184,38 @@ describe('token', () => {
|
|||
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
|
||||
.expect(HTTP_STATUS.OK);
|
||||
});
|
||||
|
||||
test.each([['user.yaml'], ['user.jwt.yaml']])(
|
||||
'should return "false" if user is not logged in',
|
||||
async (conf) => {
|
||||
const app = await initializeServer(conf);
|
||||
const credentials = { name: 'jota', password: '' };
|
||||
const response = await supertest(app)
|
||||
.get(`/-/user/org.couchdb.user:${credentials.name}`)
|
||||
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
|
||||
.expect(HTTP_STATUS.OK);
|
||||
expect(response.body.ok).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([['user.yaml'], ['user.jwt.yaml']])(
|
||||
'should fail if URL does not match user in request body',
|
||||
async (conf) => {
|
||||
const app = await initializeServer(conf);
|
||||
const credentials = { name: 'jota', password: 'secretPass' };
|
||||
const response = await createUser(app, credentials.name, credentials.password);
|
||||
expect(response.body.ok).toMatch(`user '${credentials.name}' created`);
|
||||
const response2 = await supertest(app)
|
||||
.put('/-/user/org.couchdb.user:yeti') // different user
|
||||
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token))
|
||||
.send({
|
||||
name: credentials.name,
|
||||
password: credentials.password,
|
||||
})
|
||||
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST);
|
||||
expect(response2.body.error).toBe(API_ERROR.USERNAME_MISMATCH);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -113,6 +113,7 @@ server:
|
|||
# https://verdaccio.org/docs/configuration#offline-publish
|
||||
# publish:
|
||||
# allow_offline: false
|
||||
# check_owner: false
|
||||
|
||||
# https://verdaccio.org/docs/configuration#url-prefix
|
||||
# url_prefix: /verdaccio/
|
||||
|
|
|
@ -119,6 +119,7 @@ server:
|
|||
# https://verdaccio.org/docs/configuration#offline-publish
|
||||
# publish:
|
||||
# allow_offline: false
|
||||
# check_owner: false
|
||||
|
||||
# https://verdaccio.org/docs/configuration#url-prefix
|
||||
# url_prefix: /verdaccio/
|
||||
|
|
|
@ -6,6 +6,7 @@ export const TIME_EXPIRATION_1H = '1h';
|
|||
export const DIST_TAGS = 'dist-tags';
|
||||
export const LATEST = 'latest';
|
||||
export const USERS = 'users';
|
||||
export const MAINTAINERS = 'maintainers';
|
||||
export const DEFAULT_USER = 'Anonymous';
|
||||
|
||||
export const HEADER_TYPE = {
|
||||
|
|
|
@ -39,6 +39,7 @@ export const API_ERROR = {
|
|||
BAD_PACKAGE_DATA: 'bad incoming package data',
|
||||
USERNAME_PASSWORD_REQUIRED: 'username and password is required',
|
||||
USERNAME_ALREADY_REGISTERED: 'username is already registered',
|
||||
USERNAME_MISMATCH: 'username does not match logged in user',
|
||||
};
|
||||
|
||||
export const SUPPORT_ERRORS = {
|
||||
|
|
|
@ -23,6 +23,7 @@ export {
|
|||
DEFAULT_PASSWORD_VALIDATION,
|
||||
DEFAULT_USER,
|
||||
USERS,
|
||||
MAINTAINERS,
|
||||
HtpasswdHashAlgorithm,
|
||||
} from './constants';
|
||||
const validationUtils = validatioUtils;
|
||||
|
|
|
@ -2,7 +2,7 @@ import assert from 'assert';
|
|||
|
||||
import { Manifest } from '@verdaccio/types';
|
||||
|
||||
import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS } from './constants';
|
||||
import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS, MAINTAINERS } from './constants';
|
||||
|
||||
export { validatePublishSingleVersion } from './schemes/publish-manifest';
|
||||
|
||||
|
@ -67,7 +67,6 @@ export function validatePackage(name: string): boolean {
|
|||
* @param {*} manifest
|
||||
* @param {*} name
|
||||
* @return {Object} the object with additional properties as dist-tags ad versions
|
||||
* FUTURE: rename to normalizeMetadata
|
||||
*/
|
||||
export function normalizeMetadata(manifest: Manifest, name: string): Manifest {
|
||||
assert.strictEqual(manifest.name, name);
|
||||
|
@ -77,7 +76,11 @@ export function normalizeMetadata(manifest: Manifest, name: string): Manifest {
|
|||
_manifest[DIST_TAGS] = {};
|
||||
}
|
||||
|
||||
// This may not be nee dit
|
||||
if (!Array.isArray(manifest[MAINTAINERS])) {
|
||||
_manifest[MAINTAINERS] = [];
|
||||
}
|
||||
|
||||
// This may not be needed
|
||||
if (!isObject(manifest['versions'])) {
|
||||
_manifest['versions'] = {};
|
||||
}
|
||||
|
@ -114,3 +117,11 @@ export function validatePassword(
|
|||
? password.match(validation) !== null
|
||||
: false;
|
||||
}
|
||||
|
||||
export function validateUserName(userName: any, expectedName: string): boolean {
|
||||
return (
|
||||
typeof userName === 'string' &&
|
||||
userName.split(':')[0] === 'org.couchdb.user' &&
|
||||
userName.split(':')[1] === expectedName
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
validateName,
|
||||
validatePackage,
|
||||
validatePassword,
|
||||
validateUserName,
|
||||
} from '../src/validation-utils';
|
||||
|
||||
describe('validatePackage', () => {
|
||||
|
@ -224,3 +225,17 @@ describe('validatePassword', () => {
|
|||
expect(validatePassword('1235678910')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUserName', () => {
|
||||
test('should validate username according to expected name', () => {
|
||||
expect(validateUserName('org.couchdb.user:test', 'test')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should fail to validate username if different from expected name', () => {
|
||||
expect(validateUserName('org.couchdb.user:foouser', 'test')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should fail to validate username if not given', () => {
|
||||
expect(validateUserName(undefined, 'test')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -196,6 +196,7 @@ export interface Security {
|
|||
|
||||
export interface PublishOptions {
|
||||
allow_offline: boolean;
|
||||
check_owners: boolean;
|
||||
}
|
||||
|
||||
export interface ListenAddress {
|
||||
|
|
|
@ -178,6 +178,7 @@ export interface FullRemoteManifest {
|
|||
'dist-tags': GenericBody;
|
||||
time: GenericBody;
|
||||
versions: Versions;
|
||||
/** store owners of this package */
|
||||
maintainers?: Author[];
|
||||
/** store the latest readme **/
|
||||
readme?: string;
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import semver from 'semver';
|
||||
|
||||
import { errorUtils, pkgUtils, searchUtils, validatioUtils } from '@verdaccio/core';
|
||||
import { API_ERROR, DIST_TAGS, HTTP_STATUS, USERS } from '@verdaccio/core';
|
||||
import { API_ERROR, DIST_TAGS, HTTP_STATUS, MAINTAINERS, USERS } from '@verdaccio/core';
|
||||
import { AttachMents, Manifest, Version, Versions } from '@verdaccio/types';
|
||||
import { generateRandomHexString, isNil, isObject } from '@verdaccio/utils';
|
||||
|
||||
|
@ -28,6 +28,7 @@ export function generatePackageTemplate(name: string): Manifest {
|
|||
time: {},
|
||||
[USERS]: {},
|
||||
[DIST_TAGS]: {},
|
||||
[MAINTAINERS]: [],
|
||||
_uplinks: {},
|
||||
_distfiles: {},
|
||||
_attachments: {},
|
||||
|
@ -109,6 +110,7 @@ export const WHITELIST = [
|
|||
'time',
|
||||
'_id',
|
||||
'users',
|
||||
'maintainers',
|
||||
];
|
||||
|
||||
export function cleanUpLinksRef(result: Manifest, keepUpLinkData?: boolean): Manifest {
|
||||
|
@ -290,6 +292,8 @@ export function mergeVersions(cacheManifest: Manifest, remoteManifest: Manifest)
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Should we merge owners? _cacheManifest[MAINTAINERS]
|
||||
|
||||
return cacheManifest;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,9 +10,11 @@ import { hasProxyTo } from '@verdaccio/config';
|
|||
import {
|
||||
API_ERROR,
|
||||
API_MESSAGE,
|
||||
DEFAULT_USER,
|
||||
DIST_TAGS,
|
||||
HEADER_TYPE,
|
||||
HTTP_STATUS,
|
||||
MAINTAINERS,
|
||||
SUPPORT_ERRORS,
|
||||
USERS,
|
||||
errorUtils,
|
||||
|
@ -83,7 +85,7 @@ import {
|
|||
} from './lib/storage-utils';
|
||||
import { getVersion, removeLowerVersions } from './lib/versions-utils';
|
||||
import { LocalStorage } from './local-storage';
|
||||
import { IGetPackageOptionsNext, StarManifestBody } from './type';
|
||||
import { IGetPackageOptionsNext, OwnerManifestBody, StarManifestBody } from './type';
|
||||
|
||||
const debug = buildDebug('verdaccio:storage');
|
||||
|
||||
|
@ -119,7 +121,7 @@ class Storage {
|
|||
*/
|
||||
public async changePackage(name: string, metadata: Manifest, revision: string): Promise<void> {
|
||||
debug('change existing package for package %o revision %o', name, revision);
|
||||
debug(`change manifest tags for %o revision %s`, name, revision);
|
||||
debug(`change manifest tags for %o revision %o`, name, revision);
|
||||
if (
|
||||
!validatioUtils.isObject(metadata.versions) ||
|
||||
!validatioUtils.isObject(metadata[DIST_TAGS])
|
||||
|
@ -128,7 +130,7 @@ class Storage {
|
|||
throw errorUtils.getBadData();
|
||||
}
|
||||
|
||||
debug(`change manifest udapting manifest for %o`, name);
|
||||
debug(`change manifest updating manifest for %o`, name);
|
||||
await this.updatePackage(name, async (localData: Manifest): Promise<Manifest> => {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const version in localData.versions) {
|
||||
|
@ -165,13 +167,14 @@ class Storage {
|
|||
|
||||
localData[USERS] = metadata[USERS];
|
||||
localData[DIST_TAGS] = metadata[DIST_TAGS];
|
||||
localData[MAINTAINERS] = metadata[MAINTAINERS];
|
||||
return localData;
|
||||
});
|
||||
}
|
||||
|
||||
public async removePackage(name: string, revision): Promise<void> {
|
||||
public async removePackage(name: string, revision: string, username: string): Promise<void> {
|
||||
debug('remove package %o', name);
|
||||
await this.removePackageByRevision(name, revision);
|
||||
await this.removePackageByRevision(name, revision, username);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -181,8 +184,13 @@ class Storage {
|
|||
versions, i.e. package version should be unpublished first.
|
||||
Used storage: local (write)
|
||||
*/
|
||||
public async removeTarball(
|
||||
name: string,
|
||||
filename: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async removeTarball(name: string, filename: string, _revision: string): Promise<Manifest> {
|
||||
_revision: string,
|
||||
username: string
|
||||
): Promise<Manifest> {
|
||||
debug('remove tarball %s for %s', filename, name);
|
||||
assert(validatioUtils.validateName(filename));
|
||||
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name);
|
||||
|
@ -197,6 +205,9 @@ class Storage {
|
|||
if (!cacheManifest._attachments[filename]) {
|
||||
throw errorUtils.getNotFound('no such file available');
|
||||
}
|
||||
|
||||
// check if logged in user is allowed to remove tarball
|
||||
await this.checkAllowedToChangePackage(cacheManifest, username);
|
||||
} catch (err: any) {
|
||||
if (err.code === noSuchFile) {
|
||||
throw errorUtils.getNotFound();
|
||||
|
@ -484,6 +495,17 @@ class Storage {
|
|||
public async getPackageManifest(options: IGetPackageOptionsNext): Promise<Manifest> {
|
||||
// convert dist remotes to local bars
|
||||
const [manifest] = await this.getPackageNext(options);
|
||||
|
||||
// If change access is requested (?write=true), then check if logged in user is allowed to change package
|
||||
if (options.byPassCache === true) {
|
||||
try {
|
||||
await this.checkAllowedToChangePackage(manifest, options.requestOptions.username);
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error.message }, 'getting package has failed: @{error}');
|
||||
throw errorUtils.getBadRequest(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const convertedManifest = convertDistRemoteToLocalTarballUrls(
|
||||
manifest,
|
||||
options.requestOptions,
|
||||
|
@ -727,7 +749,11 @@ class Storage {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async removePackageByRevision(pkgName: string, revision: string): Promise<void> {
|
||||
private async removePackageByRevision(
|
||||
pkgName: string,
|
||||
revision: string,
|
||||
username: string
|
||||
): Promise<void> {
|
||||
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(pkgName);
|
||||
debug('get package metadata for %o', pkgName);
|
||||
if (typeof storage === 'undefined') {
|
||||
|
@ -751,6 +777,9 @@ class Storage {
|
|||
|
||||
// TODO: move this to another method
|
||||
try {
|
||||
// check if logged in user is allowed to remove package
|
||||
await this.checkAllowedToChangePackage(manifest, username);
|
||||
|
||||
await this.localStorage.getStoragePlugin().remove(pkgName);
|
||||
// remove each attachment
|
||||
const attachments = Object.keys(manifest._attachments);
|
||||
|
@ -872,9 +901,10 @@ class Storage {
|
|||
}
|
||||
|
||||
public async updateManifest(
|
||||
manifest: Manifest | StarManifestBody,
|
||||
manifest: Manifest | StarManifestBody | OwnerManifestBody,
|
||||
options: UpdateManifestOptions
|
||||
): Promise<string | undefined> {
|
||||
debug('update manifest %o for user %o', manifest._id, options.requestOptions.username);
|
||||
if (isDeprecatedManifest(manifest as Manifest)) {
|
||||
// if the manifest is deprecated, we need to update the package.json
|
||||
await this.deprecate(manifest as Manifest, {
|
||||
|
@ -882,13 +912,22 @@ class Storage {
|
|||
});
|
||||
} else if (
|
||||
isPublishablePackage(manifest as Manifest) === false &&
|
||||
validatioUtils.isObject(manifest.users)
|
||||
validatioUtils.isObject((manifest as StarManifestBody).users)
|
||||
) {
|
||||
// if user request to apply a star to the manifest
|
||||
await this.star(manifest as StarManifestBody, {
|
||||
...options,
|
||||
});
|
||||
return API_MESSAGE.PKG_CHANGED;
|
||||
} else if (
|
||||
isPublishablePackage(manifest as Manifest) === false &&
|
||||
Array.isArray((manifest as OwnerManifestBody).maintainers)
|
||||
) {
|
||||
// if user request to change owners of package
|
||||
await this.changeOwners(manifest as OwnerManifestBody, {
|
||||
...options,
|
||||
});
|
||||
return API_MESSAGE.PKG_CHANGED;
|
||||
} else if (validatioUtils.validatePublishSingleVersion(manifest)) {
|
||||
// if continue, the version to be published does not exist
|
||||
// we create a new package
|
||||
|
@ -909,7 +948,7 @@ class Storage {
|
|||
return message;
|
||||
} else {
|
||||
debug('invalid body format');
|
||||
logger.info(
|
||||
logger.warn(
|
||||
{ packageName: options.name },
|
||||
`wrong package format on publish a package @{packageName}`
|
||||
);
|
||||
|
@ -970,6 +1009,36 @@ class Storage {
|
|||
return API_MESSAGE.PKG_CHANGED;
|
||||
}
|
||||
|
||||
private async changeOwners(
|
||||
manifest: OwnerManifestBody,
|
||||
options: UpdateManifestOptions
|
||||
): Promise<string> {
|
||||
const { maintainers } = manifest;
|
||||
const { requestOptions, name } = options;
|
||||
debug('change owners of %o', name);
|
||||
const { username } = requestOptions;
|
||||
if (!username) {
|
||||
throw errorUtils.getBadRequest('update owners only allowed for logged in users');
|
||||
}
|
||||
if (!maintainers || maintainers.length === 0) {
|
||||
throw errorUtils.getBadRequest('maintainers field is required and must not be empty');
|
||||
}
|
||||
|
||||
const localPackage = await this.getPackageManifest({
|
||||
name,
|
||||
requestOptions,
|
||||
uplinksLook: false,
|
||||
});
|
||||
|
||||
await this.changePackage(
|
||||
name,
|
||||
{ ...localPackage, maintainers: maintainers as Author[] },
|
||||
options.revision as string
|
||||
);
|
||||
|
||||
return API_MESSAGE.PKG_CHANGED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local package, on fails return null.
|
||||
* Errors are considered package not found.
|
||||
|
@ -1027,7 +1096,8 @@ class Storage {
|
|||
options: PublishOptions
|
||||
): Promise<[Manifest, string, string]> {
|
||||
const { name } = options;
|
||||
debug('publishing a new package for %o', name);
|
||||
const username = options.requestOptions.username;
|
||||
debug('publishing a new package for %o as %o', name, username);
|
||||
let successResponseMessage;
|
||||
const manifest: Manifest = { ...validatioUtils.normalizeMetadata(body, name) };
|
||||
const { _attachments, versions } = manifest;
|
||||
|
@ -1065,14 +1135,15 @@ class Storage {
|
|||
|
||||
const hasPackageInStorage = await this.hasPackage(name);
|
||||
if (!hasPackageInStorage) {
|
||||
await this.createNewLocalCachePackage(name);
|
||||
await this.createNewLocalCachePackage(name, username);
|
||||
successResponseMessage = API_MESSAGE.PKG_CREATED;
|
||||
} else {
|
||||
await this.checkAllowedToChangePackage(localManifest as Manifest, username);
|
||||
successResponseMessage = API_MESSAGE.PKG_CHANGED;
|
||||
}
|
||||
} catch (err: any) {
|
||||
debug('error on change or update a package with %o', err.message);
|
||||
logger.error({ err: err.message }, 'error on create package: @{err}');
|
||||
logger.error({ err: err.message }, 'error on publish new version: @{err}');
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
@ -1089,7 +1160,6 @@ class Storage {
|
|||
} catch (err: any) {
|
||||
logger.error({ err: err.message }, 'updated version has failed: @{err}');
|
||||
debug('error on create a version for %o with error %o', name, err.message);
|
||||
// TODO: remove tarball if add version fails
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
@ -1106,8 +1176,7 @@ class Storage {
|
|||
logger.error({ err: err.message }, 'merge version has failed: @{err}');
|
||||
debug('error on create a version for %o with error %o', name, err.message);
|
||||
// TODO: undo if this fails
|
||||
// 1. remove tarball
|
||||
// 2. remove updated version
|
||||
// 1. remove updated version
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
@ -1119,6 +1188,9 @@ class Storage {
|
|||
});
|
||||
} catch (err: any) {
|
||||
logger.error({ err: err.message }, 'upload tarball has failed: @{err}');
|
||||
// TODO: undo if this fails
|
||||
// 1. remove updated version
|
||||
// 2. remove new dist tags
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
@ -1293,11 +1365,14 @@ class Storage {
|
|||
await this.updatePackage(name, async (data: Manifest): Promise<Manifest> => {
|
||||
// keep only one readme per package
|
||||
data.readme = metadata.readme;
|
||||
debug('%s` readme mutated', name);
|
||||
debug('%s readme mutated', name);
|
||||
// TODO: lodash remove
|
||||
metadata = cleanUpReadme(metadata);
|
||||
metadata.contributors = normalizeContributors(metadata.contributors as Author[]);
|
||||
debug('%s` contributors normalized', name);
|
||||
debug('%s contributors normalized', name);
|
||||
|
||||
// Copy current owners to version
|
||||
metadata.maintainers = data.maintainers;
|
||||
|
||||
// Update tarball stats
|
||||
if (metadata.dist) {
|
||||
|
@ -1358,7 +1433,7 @@ class Storage {
|
|||
tagVersion(data, version, tag);
|
||||
|
||||
try {
|
||||
debug('%s` add on database', name);
|
||||
debug('%s add on database', name);
|
||||
await this.localStorage.getStoragePlugin().add(name);
|
||||
this.logger.debug({ name, version }, 'version @{version} added to database for @{name}');
|
||||
} catch (err: any) {
|
||||
|
@ -1373,7 +1448,10 @@ class Storage {
|
|||
* @param name name of the package
|
||||
* @returns
|
||||
*/
|
||||
private async createNewLocalCachePackage(name: string): Promise<void> {
|
||||
private async createNewLocalCachePackage(
|
||||
name: string,
|
||||
username: string | undefined
|
||||
): Promise<void> {
|
||||
const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name);
|
||||
|
||||
if (!storage) {
|
||||
|
@ -1390,6 +1468,13 @@ class Storage {
|
|||
},
|
||||
};
|
||||
|
||||
// Set initial package owner
|
||||
// TODO: Add email of user
|
||||
packageData.maintainers =
|
||||
username && username.length > 0
|
||||
? [{ name: username, email: '' }]
|
||||
: [{ name: DEFAULT_USER, email: '' }];
|
||||
|
||||
try {
|
||||
await storage.createPackage(name, packageData);
|
||||
this.logger.info({ name }, 'created new package @{name}');
|
||||
|
@ -1929,6 +2014,21 @@ class Storage {
|
|||
return { fileCount: version.dist.fileCount, unpackedSize: version.dist.unpackedSize };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAllowedToChangePackage(manifest: Manifest, username: string | undefined) {
|
||||
// Checks to perform if config "publish:check_owners" is true
|
||||
debug('check if user %o is an owner and allowed to change package', username);
|
||||
// if name of owner is not included in list of maintainers, then throw an error
|
||||
if (
|
||||
this.config?.publish?.check_owners === true &&
|
||||
manifest.maintainers &&
|
||||
manifest.maintainers.length > 0 &&
|
||||
!manifest.maintainers.some((maintainer) => maintainer.name === username)
|
||||
) {
|
||||
logger.error({ username }, '@{username} is not a maintainer (package owner)');
|
||||
throw Error('only owners are allowed to change package');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Storage };
|
||||
|
|
|
@ -62,3 +62,9 @@ export type UpdateManifestOptions = {
|
|||
* values in the body.
|
||||
*/
|
||||
export type StarManifestBody = Pick<Manifest, '_id' | 'users' | '_rev'>;
|
||||
|
||||
/**
|
||||
* When the command `npm owner add/rm` is executed, the body only contains the following
|
||||
* values in the body.
|
||||
*/
|
||||
export type OwnerManifestBody = Pick<Manifest, '_id' | 'maintainers' | '_rev'>;
|
||||
|
|
18
packages/store/test/fixtures/config/publishWithOwnerAndCheck.yaml
vendored
Normal file
18
packages/store/test/fixtures/config/publishWithOwnerAndCheck.yaml
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
packages:
|
||||
'@scope/foo':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
'@*/*':
|
||||
access: $all
|
||||
publish: $all
|
||||
proxy: ver
|
||||
'foo':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
'*':
|
||||
access: $all
|
||||
publish: $all
|
||||
proxy: npmjs
|
||||
|
||||
publish:
|
||||
check_owners: true
|
15
packages/store/test/fixtures/config/publishWithOwnerDefault.yaml
vendored
Normal file
15
packages/store/test/fixtures/config/publishWithOwnerDefault.yaml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
packages:
|
||||
'@scope/foo':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
'@*/*':
|
||||
access: $all
|
||||
publish: $all
|
||||
proxy: ver
|
||||
'foo':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
'*':
|
||||
access: $all
|
||||
publish: $all
|
||||
proxy: npmjs
|
|
@ -24,7 +24,14 @@ import {
|
|||
generateRemotePackageMetadata,
|
||||
getDeprecatedPackageMetadata,
|
||||
} from '@verdaccio/test-helper';
|
||||
import { AbbreviatedManifest, ConfigYaml, Manifest, PackageUsers, Version } from '@verdaccio/types';
|
||||
import {
|
||||
AbbreviatedManifest,
|
||||
Author,
|
||||
ConfigYaml,
|
||||
Manifest,
|
||||
PackageUsers,
|
||||
Version,
|
||||
} from '@verdaccio/types';
|
||||
|
||||
import { Storage } from '../src';
|
||||
import manifestFooRemoteNpmjs from './fixtures/manifests/foo-npmjs.json';
|
||||
|
@ -89,6 +96,31 @@ const executeStarPackage = async (
|
|||
});
|
||||
};
|
||||
|
||||
const executeChangeOwners = async (
|
||||
storage,
|
||||
options: {
|
||||
maintainers: Author[];
|
||||
username: string;
|
||||
name: string;
|
||||
_rev: string;
|
||||
_id?: string;
|
||||
}
|
||||
) => {
|
||||
const { name, _rev, _id, maintainers, username } = options;
|
||||
const ownerManifest = {
|
||||
_rev,
|
||||
_id,
|
||||
maintainers,
|
||||
};
|
||||
return storage.updateManifest(ownerManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name,
|
||||
uplinksLook: true,
|
||||
revision: '1',
|
||||
requestOptions: { ...defaultRequestOptions, username },
|
||||
});
|
||||
};
|
||||
|
||||
describe('storage', () => {
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
|
@ -657,6 +689,204 @@ describe('storage', () => {
|
|||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe('owner', () => {
|
||||
test.each([
|
||||
['foo', 'publishWithOwnerDefault.yaml'],
|
||||
['foo', 'publishWithOwnerAndCheck.yaml'],
|
||||
])('new package %s, %s (anonymous)', async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: true,
|
||||
requestOptions: defaultRequestOptions,
|
||||
});
|
||||
const manifest = (await storage.getPackageByOptions({
|
||||
name: pkgName,
|
||||
uplinksLook: true,
|
||||
requestOptions: defaultRequestOptions,
|
||||
})) as Manifest;
|
||||
expect(manifest?.maintainers).toEqual([{ name: 'Anonymous', email: '' }]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['foo', 'publishWithOwnerDefault.yaml'],
|
||||
['foo', 'publishWithOwnerAndCheck.yaml'],
|
||||
])('new package %s, %s (logged in)', async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const owner = { name: 'fooUser', email: '' };
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
const options = { ...defaultRequestOptions, username: owner.name };
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: true,
|
||||
requestOptions: options,
|
||||
});
|
||||
const manifest = (await storage.getPackageByOptions({
|
||||
name: pkgName,
|
||||
uplinksLook: true,
|
||||
requestOptions: defaultRequestOptions,
|
||||
})) as Manifest;
|
||||
expect(manifest?.maintainers).toEqual([owner]);
|
||||
expect(manifest?.versions['1.0.0'].maintainers).toEqual([owner]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['foo', 'publishWithOwnerDefault.yaml'],
|
||||
['foo', 'publishWithOwnerAndCheck.yaml'],
|
||||
])('add/remove owner %s, %s', async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const firstOwner = { name: 'fooUser', email: '' };
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
const options = { ...defaultRequestOptions, username: firstOwner.name };
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
});
|
||||
|
||||
// add owner
|
||||
const secondOwner = { name: 'barUser', email: '' };
|
||||
const maintainers = [firstOwner, secondOwner];
|
||||
|
||||
const message = await executeChangeOwners(storage, {
|
||||
_rev: bodyNewManifest._rev,
|
||||
_id: bodyNewManifest._id,
|
||||
name: pkgName,
|
||||
username: firstOwner.name,
|
||||
maintainers: maintainers,
|
||||
});
|
||||
expect(message).toEqual(API_MESSAGE.PKG_CHANGED);
|
||||
|
||||
const manifest = (await storage.getPackageByOptions({
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
})) as Manifest;
|
||||
expect(manifest?.maintainers).toEqual(maintainers);
|
||||
// published version should not be affected
|
||||
expect(manifest?.versions['1.0.0'].maintainers).toEqual([firstOwner]);
|
||||
|
||||
// remove owner
|
||||
const maintainers2 = [secondOwner];
|
||||
const message2 = await executeChangeOwners(storage, {
|
||||
_rev: bodyNewManifest._rev,
|
||||
_id: bodyNewManifest._id,
|
||||
name: pkgName,
|
||||
username: firstOwner.name,
|
||||
maintainers: maintainers2,
|
||||
});
|
||||
expect(message2).toEqual(API_MESSAGE.PKG_CHANGED);
|
||||
|
||||
const manifest2 = (await storage.getPackageByOptions({
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
})) as Manifest;
|
||||
expect(manifest2?.maintainers).toEqual(maintainers2);
|
||||
// published version should not be affected
|
||||
expect(manifest2?.versions['1.0.0'].maintainers).toEqual([firstOwner]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['foo', 'publishWithOwnerDefault.yaml'],
|
||||
['foo', 'publishWithOwnerAndCheck.yaml'],
|
||||
])('should fail removing last owner %s, %s', async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
const owner = 'fooUser';
|
||||
const options = { ...defaultRequestOptions, username: owner };
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
});
|
||||
|
||||
// no owners
|
||||
await expect(
|
||||
executeChangeOwners(storage, {
|
||||
_rev: bodyNewManifest._rev,
|
||||
_id: bodyNewManifest._id,
|
||||
name: pkgName,
|
||||
username: owner,
|
||||
maintainers: [],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test.each([['foo', 'publishWithOwnerDefault.yaml']])(
|
||||
'ok to publish as non-owner without check %s, %s',
|
||||
async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
const owner = 'fooUser';
|
||||
const options = { ...defaultRequestOptions, username: owner };
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
});
|
||||
|
||||
// try to publish as user who's not an owner
|
||||
const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1');
|
||||
const nonOwner = 'barUser';
|
||||
const options2 = { ...defaultRequestOptions, username: nonOwner };
|
||||
const message2 = await storage.updateManifest(bodyNewManifest2, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options2,
|
||||
});
|
||||
expect(message2).toEqual(API_MESSAGE.PKG_CHANGED);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([['foo', 'publishWithOwnerAndCheck.yaml']])(
|
||||
'should fail publishing as non-owner with check %s, %s',
|
||||
async (pkgName, configFile) => {
|
||||
const config = getConfig(configFile);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0');
|
||||
const owner = 'fooUser';
|
||||
const options = { ...defaultRequestOptions, username: owner };
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
});
|
||||
|
||||
// try to publish as user who's not an owner
|
||||
const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1');
|
||||
const nonOwner = 'barUser';
|
||||
const options2 = { ...defaultRequestOptions, username: nonOwner };
|
||||
await expect(
|
||||
storage.updateManifest(bodyNewManifest2, {
|
||||
signal: new AbortController().signal,
|
||||
name: pkgName,
|
||||
uplinksLook: false,
|
||||
requestOptions: options2,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTarball', () => {
|
||||
|
@ -1261,6 +1491,7 @@ describe('storage', () => {
|
|||
|
||||
describe('removeTarball', () => {
|
||||
test('should fail on remove tarball of package does not exist', async () => {
|
||||
const username = 'foouser';
|
||||
const config = new Config(
|
||||
configExample({
|
||||
...getDefaultConfig(),
|
||||
|
@ -1269,7 +1500,7 @@ describe('storage', () => {
|
|||
);
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev')).rejects.toThrow(
|
||||
await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev', username)).rejects.toThrow(
|
||||
API_ERROR.NO_PACKAGE
|
||||
);
|
||||
});
|
||||
|
@ -1277,6 +1508,7 @@ describe('storage', () => {
|
|||
|
||||
describe('removePackage', () => {
|
||||
test('should remove entirely a package', async () => {
|
||||
const username = 'foouser';
|
||||
const config = new Config(
|
||||
configExample({
|
||||
...getDefaultConfig(),
|
||||
|
@ -1321,10 +1553,10 @@ describe('storage', () => {
|
|||
const _rev = manifest1._rev;
|
||||
// 3. remove the tarball
|
||||
await expect(
|
||||
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev)
|
||||
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, username)
|
||||
).resolves.toBeDefined();
|
||||
// 4. remove the package
|
||||
await storage.removePackage(manifest1.name, _rev);
|
||||
await storage.removePackage(manifest1.name, _rev, username);
|
||||
// 5. fails if package does not exist anymore in storage
|
||||
await expect(
|
||||
storage.getPackageByOptions({
|
||||
|
@ -1338,6 +1570,76 @@ describe('storage', () => {
|
|||
})
|
||||
).rejects.toThrow('package does not exist on uplink: foo');
|
||||
});
|
||||
|
||||
test('ok to remove package as non-owner without check', async () => {
|
||||
const config = getConfig('publishWithOwnerDefault.yaml');
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const owner = 'fooUser';
|
||||
const options = { ...defaultRequestOptions, username: owner };
|
||||
|
||||
// 1. publish a package
|
||||
const bodyNewManifest = generatePackageMetadata('foo', '1.0.0');
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: 'foo',
|
||||
uplinksLook: true,
|
||||
requestOptions: options,
|
||||
});
|
||||
// 2. request package (should be available in the local cache)
|
||||
const manifest1 = (await storage.getPackageByOptions({
|
||||
name: 'foo',
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
})) as Manifest;
|
||||
const _rev = manifest1._rev;
|
||||
// 3. remove the tarball as other user
|
||||
const nonOwner = 'barUser';
|
||||
await expect(
|
||||
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner)
|
||||
).resolves.toBeDefined();
|
||||
// 4. remove the package as other user
|
||||
await storage.removePackage(manifest1.name, _rev, nonOwner);
|
||||
// 5. fails if package does not exist anymore in storage
|
||||
await expect(
|
||||
storage.getPackageByOptions({
|
||||
name: 'foo',
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
})
|
||||
).rejects.toThrow('package does not exist on uplink: foo');
|
||||
});
|
||||
|
||||
test('should fail as non-owner with check', async () => {
|
||||
const config = getConfig('publishWithOwnerAndCheck.yaml');
|
||||
const storage = new Storage(config);
|
||||
await storage.init(config);
|
||||
const owner = 'fooUser';
|
||||
const options = { ...defaultRequestOptions, username: owner };
|
||||
|
||||
// 1. publish a package
|
||||
const bodyNewManifest = generatePackageMetadata('foo', '1.0.0');
|
||||
await storage.updateManifest(bodyNewManifest, {
|
||||
signal: new AbortController().signal,
|
||||
name: 'foo',
|
||||
uplinksLook: true,
|
||||
requestOptions: options,
|
||||
});
|
||||
// 2. request package (should be available in the local cache)
|
||||
const manifest1 = (await storage.getPackageByOptions({
|
||||
name: 'foo',
|
||||
uplinksLook: false,
|
||||
requestOptions: options,
|
||||
})) as Manifest;
|
||||
const _rev = manifest1._rev;
|
||||
// 3. try removing the tarball
|
||||
const nonOwner = 'barUser';
|
||||
await expect(
|
||||
storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner)
|
||||
).rejects.toThrow();
|
||||
// 4. try removing the package
|
||||
await expect(storage.removePackage(manifest1.name, _rev, nonOwner)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPackageByOptions()', () => {
|
||||
|
|
|
@ -185,20 +185,29 @@ packages:
|
|||
|
||||
### Offline Publish {#offline-publish}
|
||||
|
||||
By default `verdaccio` does not allow you to publish packages when the client is offline. This can be can be overridden by setting this value to _true_.
|
||||
By default `verdaccio` does not allow you to publish packages when the client is offline. This can be overridden by setting this value to _true_.
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
allow_offline: false
|
||||
```
|
||||
|
||||
### Checking Package Ownership {#chec-owner}
|
||||
|
||||
By default, [package access](packages.md) defines who is allowed to publish and unpublish packages. By setting `check_owner` to _true_, only package owners are allowed to make changes to a package. The first owner of a package is the user who published the first version. Further owners can be added or removed using [`npm owner`](https://docs.npmjs.com/cli/v10/commands/npm-owner). You can find the list of current owners in the package manifest under `maintainers`.
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
check_owner: false
|
||||
```
|
||||
|
||||
<small>Since: `verdaccio@2.3.6` due [#223](https://github.com/verdaccio/verdaccio/pull/223)</small>
|
||||
|
||||
### URL Prefix {#url-prefix}
|
||||
|
||||
The prefix is intended to be used when the server runs behinds the proxy and won't work properly if is used without a reverse proxy, check the **reverse proxy setup** page for more details.
|
||||
|
||||
The internal logic builds correctly the public url, validates the `host` header and and bad shaped `url_prefix`.
|
||||
The internal logic builds correctly the public url, validates the `host` header and bad shaped `url_prefix`.
|
||||
|
||||
eg: `url_prefix: /verdaccio`, `url_prefix: verdaccio/`, `url_prefix: verdaccio` would be `/verdaccio/`
|
||||
|
||||
|
|
Loading…
Reference in a new issue