diff --git a/docker-compose.yml b/docker-compose.yml index f24e5c1b..bbbceead 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,19 @@ services: env_file: - ./services/registry/.env + web: + image: mhart/alpine-node:12 + volumes: + - ./services/:/services + working_dir: /services/web + command: npm start + networks: + - entropic + ports: + - "3001:3001" + env_file: + - ./services/web/.env + volumes: postgres_data: beanstalk_wal: diff --git a/package.json b/package.json index 3759e0a2..0061bb59 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "lint-fix": "prettier --write '**/*.js'", "lint-md": "markdownlint \"**/*.md\" -i \"**/node_modules/**\"", "lint-registry": "cd services/registry; npm run lint", - "postinstall": "for d in cli services/registry services/workers services/common/boltzmann; do cd $d; npm i; cd -; done", + "postinstall": "for d in cli services/registry services/workers services/web services/common/boltzmann; do cd $d; npm i; cd -; done", "start": "docker-compose up", - "test": "for d in cli services/registry; do cd $d; npm t; cd -; done" + "test": "for d in cli services/registry services/web; do cd $d; npm t; cd -; done" } } diff --git a/services/common/boltzmann/middleware/requestid.js b/services/common/boltzmann/middleware/requestid.js index 552e994a..8ddc0494 100644 --- a/services/common/boltzmann/middleware/requestid.js +++ b/services/common/boltzmann/middleware/requestid.js @@ -2,16 +2,18 @@ const bole = require('bole'); const uuid = require('uuid'); +const os = require('os'); module.exports = createRequestId; function createRequestId( requestIdHeader = process.env.REQUEST_ID_HEADER || 'request-id' ) { + const host = os.hostname() return function mw(next) { return async function inner(context) { const request = context.request; - context.id = request.headers[requestIdHeader] || uuid.v1(); + context.id = request.headers[requestIdHeader] || `${host}_${uuid.v1()}`; context.logger = bole(context.id); const response = await next(context); diff --git a/services/common/storage-api/NOTES b/services/common/storage-api/NOTES new file mode 100644 index 00000000..5bfda9b1 --- /dev/null +++ b/services/common/storage-api/NOTES @@ -0,0 +1,45 @@ +storageApi + getProviders() + [Provider] + Provider.redirect + getProvider(name) + getAuthentication({remoteId, provider}) + Authentication + user: User + signup({username, email, remoteAuth: {token, id, provider}}) + * NOTE: bring email uniqueness check into signup + * NOTE: err.code + - "signup.email_taken" + - "signup.username_taken" + User + getTokens({for: user, page: N}) + [objects] + createToken({for: user, description}) + deleteToken({for: user, valueHashes: [String]}) -> { count } + resolveCLISession({session, value}) + fetchCLISession({session}) -> { description, value? } + createCLISession({description}) -> id String + getActiveMaintainers({namespace, host, name, page}) + + inviteMaintainer({namespace, host, name, from, to}) + * NOTE: err.code + - invite.invitee_dne + - invite.package_dne + - invite.already_accepted + - invite.already_declined + - I think the invite logic might be incorrect around + maintainers.js L75, need to look into this. + removeMaintainer({namespace, host, name, from, to}) + * NOTE: err.code + - invite.invitee_dne + - invite.package_dne + - invite.already_accepted + - invite.already_declined + - invite.invitee_not_maintainer (distinct from invitee_dne: namespace exists but is not a maintainer) + acceptMaintainerInvite({namespace, host, name, member, bearer}) + * NOTE: err.code + - invite.invitee_dne + - invite.package_dne + - invite.already_accepted + - invite.already_declined + - invite.invitee_not_maintainer (distinct from invitee_dne: namespace exists but is not a maintainer) diff --git a/services/common/storage-api/index.js b/services/common/storage-api/index.js new file mode 100644 index 00000000..5321a129 --- /dev/null +++ b/services/common/storage-api/index.js @@ -0,0 +1,10 @@ +'use strict' + +module.exports = class Client { + constructor ({ host = process.env.STORAGE_HOST, requestId }) { + this.host = host + this.requestId = requestId + } + + +} diff --git a/services/common/storage-api/package-lock.json b/services/common/storage-api/package-lock.json new file mode 100644 index 00000000..746906bb --- /dev/null +++ b/services/common/storage-api/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "storage-api", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + } + } +} diff --git a/services/common/storage-api/package.json b/services/common/storage-api/package.json new file mode 100644 index 00000000..1d01d4e0 --- /dev/null +++ b/services/common/storage-api/package.json @@ -0,0 +1,15 @@ +{ + "name": "storage-api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "Chris Dickinson (http://neversaw.us/)", + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0" + } +} diff --git a/services/registry/handlers/auth.js b/services/registry/handlers/auth.js index a48095ea..7ee1b2e7 100644 --- a/services/registry/handlers/auth.js +++ b/services/registry/handlers/auth.js @@ -14,15 +14,9 @@ module.exports = { async function login(context) { // this is all that's required to work with npm-profile. const body = await json(context.request); - const id = uuid.v4(); - await context.redis.setexAsync( - `cli_${id}`, - 5000, - JSON.stringify({ + const id = await context.storageApi.createCLISession({ description: body.hostname - }) - ); - + }) return response.json({ doneUrl: `${process.env.EXTERNAL_HOST}/-/v1/login/poll/${id}`, loginUrl: `${process.env.EXTERNAL_HOST}/www/login?cli=${id}` @@ -41,7 +35,7 @@ async function poll(context, { session }) { return response.error('invalid request', 400); } const result = JSON.parse( - (await context.redis.getAsync(`cli_${session}`)) || '{}' + (await context.storageApi.fetchCLISession({session})) || '{}' ); if (result.value) { return response.json({ diff --git a/services/registry/handlers/index.js b/services/registry/handlers/index.js index 34857b60..422eecb9 100644 --- a/services/registry/handlers/index.js +++ b/services/registry/handlers/index.js @@ -16,7 +16,6 @@ function makeRouter() { ...require('./packages'), ...require('./maintainers'), ...require('./namespaces'), - ...require('./www'), fork.get('/-/v1/login/poll/:session', auth.poll), fork.post('/-/v1/login', auth.login), diff --git a/services/registry/handlers/maintainers.js b/services/registry/handlers/maintainers.js index f94675b8..f9b35bb5 100644 --- a/services/registry/handlers/maintainers.js +++ b/services/registry/handlers/maintainers.js @@ -33,71 +33,55 @@ module.exports = [ ]; async function maintainers(context, { namespace, host, name }) { - const pkg = await Package.objects - .get({ - active: true, - name, - 'namespace.active': true, - 'namespace.name': namespace - }) - .catch(Package.objects.NotFound, () => null); + const [err, maintainers] = await context.storageApi.getActiveMaintainers({namespace, host, name, page: 0}).then( + xs => [null, xs], + xs => [xs, null] + ) - if (!pkg) { + if (err) { + if (err.status === 404) { + return response.error( + `"${namespace}@${host}/${name}" does not exist.`, + 404 + ); + } + + context.logger.error(err.message || err) return response.error( - `"${namespace}@${host}/${name}" does not exist.`, - 404 + `Caught error fetching maintainers for "${namespace}@${host}/${name}".`, + 500 ); } - - const namespaces = await Namespace.objects - .filter({ - 'maintainers.package_id': pkg.id, - 'maintainers.accepted': true, - 'maintainers.active': true - }) - .then(); - - const objects = namespaces.map(ns => ns.name).sort(); return response.json({ objects }); } // Invite a maintainer. Maintainers are namespaces, which might be a single user or a group of users. // More correctly: maintainership is a relationship between a namespace and a package. async function invite(context, { namespace, host, name, invitee }) { - if (!context.pkg) { - return response.error(`"${namespace}@${host}/${name}" not found.`, 404); - } - - if (!context.invitee) { - return response.error(`${invitee} not found.`, 404); - } + const [err, invite] = await context.storageApi.inviteMaintainer({ + namespace, + host, + name, + from: context.user.name, + to: invitee + }).then( + xs => [null, xs], + xs => [xs, null] + ) - const existing = await Maintainer.objects - .get({ - namespace: context.invitee, - package: context.pkg - }) - .catch(Maintainer.objects.NotFound, () => null); - if (existing) { - if (existing.active === false) { - return response.message( - `${invitee} has already declined to maintain ${namespace}@${host}/${name}.` - ); - } - if (existing.accepted === false) { - return response.message( - `${invitee} has already been invited to maintain ${namespace}@${host}/${name}.` - ); - } + if (err) { + const msg = { + 'invite.invitee_dne': `Unknown namespace: "${invitee}".`, + 'invite.package_dne': `Unknown package: "${invitee}".`, + 'invite.already_accepted': `Namespace "${invitee}" is already a member.`, + 'invite.already_declined': `Namespace "${invitee}" has declined this invite.` + }[err.code] + return response.error( + msg || `Caught error inviting "${invitee}" to ${namespace}@${host}/${name}`, + err.code + ); } - await Maintainer.objects.create({ - namespace: context.invitee, - package: context.pkg, - accepted: false, - active: true - }); - context.logger.info( `${invitee} invited to join the maintainers of ${namespace}@${host}/${name} by ${ context.user.name @@ -109,35 +93,32 @@ async function invite(context, { namespace, host, name, invitee }) { } async function remove(context, { namespace, host, name, invitee }) { - if (!context.pkg) { - return response.error( - `"${namespace}@${host}/${name}" does not exist.`, - 404 - ); - } - - if (!context.invitee) { - return response.error(`${invitee} does not exist.`, 404); - } + const [err, invite] = await context.storageApi.removeMaintainer({ + namespace, + host, + name, + from: context.user.name, + to: invitee + }).then( + xs => [null, xs], + xs => [xs, null] + ) - const maintainership = await Maintainer.objects - .filter({ - package_id: context.pkg.id, - namespace_id: context.invitee.id, - active: true - }) - .slice(0, 1) - .update({ - modified: new Date(), - active: false - }) - .then(); + if (err) { + const msg = { + 'invite.invitee_dne': `Unknown namespace: "${invitee}".`, + 'invite.invitee_not_maintainer': `${invitee} was not a maintainer of ${namespace}@${host}/${name}.` + 'invite.package_dne': `Unknown package: "${invitee}".`, + 'invite.already_accepted': `Namespace "${invitee}" is already a member.`, + 'invite.already_declined': `Namespace "${invitee}" has declined this invite.` + }[err.code] - if (maintainership.length === 0) { - return response.message( - `${invitee} was not a maintainer of ${namespace}@${host}/${name}.` + return response.error( + msg || `Caught error inviting "${invitee}" to ${namespace}@${host}/${name}`, + err.code ); } + context.logger.info( `${invitee} removed as maintainer of ${namespace}@${host}/${name} by ${ context.user.name @@ -150,28 +131,16 @@ async function remove(context, { namespace, host, name, invitee }) { } async function accept(context, { namespace, host, name, member }) { - const invitation = await Maintainer.objects - .get({ - namespace_id: context.member.id, - package_id: context.pkg.id, - active: true, - accepted: false - }) - .catch(Maintainer.objects.NotFound, () => null); - - if (!invitation) { - return response.error('invitation not found', 404); - } - - await Maintainer.objects - .filter({ - id: invitation.id - }) - .update({ - modified: new Date(), - accepted: true - }); - + const [err] = await context.storageApi.acceptMaintainerInvite({ + namespace, + host, + name, + member, + bearer: context.user.name + }).then( + xs => [null, xs], + xs => [xs, null] + ) context.logger.info( `${ context.user.name diff --git a/services/registry/server.js b/services/registry/server.js index b030a652..fe40b6c5 100755 --- a/services/registry/server.js +++ b/services/registry/server.js @@ -26,7 +26,6 @@ const myMiddles = [ require('./middleware/postgres'), require('./middleware/transaction'), require('boltzmann/middleware/redis'), - require('./middleware/session'), require('./middleware/bearer-auth'), require('./middleware/object-store') ]; diff --git a/services/storage/handlers/index.js b/services/storage/handlers/index.js new file mode 100644 index 00000000..422eecb9 --- /dev/null +++ b/services/storage/handlers/index.js @@ -0,0 +1,57 @@ +'use strict'; + +module.exports = makeRouter; + +const ship = require('culture-ships').random(); + +const isLoggedIn = require('../decorators/is-logged-in'); +const { response, fork } = require('boltzmann'); +const pkg = require('../package.json'); +const User = require('../models/user'); +const auth = require('./auth'); + +function makeRouter() { + const router = fork.router()( + fork.get('/', version), + ...require('./packages'), + ...require('./maintainers'), + ...require('./namespaces'), + + fork.get('/-/v1/login/poll/:session', auth.poll), + fork.post('/-/v1/login', auth.login), + fork.get('/v1/auth/whoami', isLoggedIn(whoami)), + fork.get('/ping', ping) + ); + + return router; +} + +async function version() { + const data = { + server: 'entropic', + version: pkg.version, + message: ship, + website: 'https://www.entropic.dev' + }; + return response.json(data); +} + +async function greeting() { + const objects = await User.objects.all().then(); + return response.json({ objects }); +} + +async function ping() { + return response.text(ship); +} + +async function whoami(context) { + if (!context.user) { + return response.error({ + message: 'You are not logged in', + CODE: 'ENOTLOGGEDIN' + }); + } + // This isn't to spec but is what vcpm does. Consider changing it. + return response.json({ username: context.user.name }); +} diff --git a/services/storage/handlers/maintainers.js b/services/storage/handlers/maintainers.js new file mode 100644 index 00000000..f94675b8 --- /dev/null +++ b/services/storage/handlers/maintainers.js @@ -0,0 +1,216 @@ +'use strict'; + +const isNamespaceMember = require('../decorators/is-namespace-member'); +const packageExists = require('../decorators/package-exists'); +const findInvitee = require('../decorators/find-invitee'); +const canWrite = require('../decorators/can-write-package'); +const Maintainer = require('../models/maintainer'); +const Namespace = require('../models/namespace'); +const Package = require('../models/package'); +const { response, fork } = require('boltzmann'); + +module.exports = [ + fork.get( + '/v1/packages/package/:namespace([^@]+)@:host/:name/maintainers', + maintainers + ), + fork.post( + '/v1/packages/package/:namespace([^@]+)@:host/:name/maintainers/:invitee', + findInvitee(canWrite(invite)) + ), + fork.del( + '/v1/packages/package/:namespace([^@]+)@:host/:name/maintainers/:invitee', + findInvitee(canWrite(remove)) + ), + fork.post( + '/v1/packages/package/:namespace([^@]+)@:host/:name/invitation/:member', + packageExists(isNamespaceMember(accept)) + ), + fork.del( + '/v1/packages/package/:namespace([^@]+)@:host/:name/invitation/:member', + packageExists(isNamespaceMember(decline)) + ) +]; + +async function maintainers(context, { namespace, host, name }) { + const pkg = await Package.objects + .get({ + active: true, + name, + 'namespace.active': true, + 'namespace.name': namespace + }) + .catch(Package.objects.NotFound, () => null); + + if (!pkg) { + return response.error( + `"${namespace}@${host}/${name}" does not exist.`, + 404 + ); + } + + const namespaces = await Namespace.objects + .filter({ + 'maintainers.package_id': pkg.id, + 'maintainers.accepted': true, + 'maintainers.active': true + }) + .then(); + + const objects = namespaces.map(ns => ns.name).sort(); + return response.json({ objects }); +} + +// Invite a maintainer. Maintainers are namespaces, which might be a single user or a group of users. +// More correctly: maintainership is a relationship between a namespace and a package. +async function invite(context, { namespace, host, name, invitee }) { + if (!context.pkg) { + return response.error(`"${namespace}@${host}/${name}" not found.`, 404); + } + + if (!context.invitee) { + return response.error(`${invitee} not found.`, 404); + } + + const existing = await Maintainer.objects + .get({ + namespace: context.invitee, + package: context.pkg + }) + .catch(Maintainer.objects.NotFound, () => null); + if (existing) { + if (existing.active === false) { + return response.message( + `${invitee} has already declined to maintain ${namespace}@${host}/${name}.` + ); + } + if (existing.accepted === false) { + return response.message( + `${invitee} has already been invited to maintain ${namespace}@${host}/${name}.` + ); + } + } + + await Maintainer.objects.create({ + namespace: context.invitee, + package: context.pkg, + accepted: false, + active: true + }); + + context.logger.info( + `${invitee} invited to join the maintainers of ${namespace}@${host}/${name} by ${ + context.user.name + }` + ); + return response.message( + `${invitee} invited to join the maintainers of ${namespace}@${host}/${name}.` + ); +} + +async function remove(context, { namespace, host, name, invitee }) { + if (!context.pkg) { + return response.error( + `"${namespace}@${host}/${name}" does not exist.`, + 404 + ); + } + + if (!context.invitee) { + return response.error(`${invitee} does not exist.`, 404); + } + + const maintainership = await Maintainer.objects + .filter({ + package_id: context.pkg.id, + namespace_id: context.invitee.id, + active: true + }) + .slice(0, 1) + .update({ + modified: new Date(), + active: false + }) + .then(); + + if (maintainership.length === 0) { + return response.message( + `${invitee} was not a maintainer of ${namespace}@${host}/${name}.` + ); + } + context.logger.info( + `${invitee} removed as maintainer of ${namespace}@${host}/${name} by ${ + context.user.name + }` + ); + + return response.message( + `${invitee} removed as maintainer of ${namespace}@${host}/${name}.` + ); +} + +async function accept(context, { namespace, host, name, member }) { + const invitation = await Maintainer.objects + .get({ + namespace_id: context.member.id, + package_id: context.pkg.id, + active: true, + accepted: false + }) + .catch(Maintainer.objects.NotFound, () => null); + + if (!invitation) { + return response.error('invitation not found', 404); + } + + await Maintainer.objects + .filter({ + id: invitation.id + }) + .update({ + modified: new Date(), + accepted: true + }); + + context.logger.info( + `${ + context.user.name + } accepted the invitation for ${member} to join ${namespace}@${host}/${name}` + ); + return response.message( + `${member} is now a maintainer for ${namespace}@${host}/${name}` + ); +} + +async function decline(context, { namespace, host, name, member }) { + const invitation = await Maintainer.objects + .get({ + namespace_id: context.member.id, + package_id: context.pkg.id, + active: true, + accepted: false + }) + .catch(Maintainer.objects.NotFound, () => null); + + if (!invitation) { + return response.error('invitation not found', 404); + } + + await Maintainer.objects + .filter({ + id: invitation.id + }) + .update({ + modified: new Date(), + active: false + }); + + context.logger.info( + `${ + context.user.name + } declined the invitation for ${member} to join ${namespace}@${host}/${name}` + ); + return response.message( + `You have declined the invitation for ${member} to join ${namespace}@${host}/${name}` + ); +} diff --git a/services/storage/handlers/namespaces.js b/services/storage/handlers/namespaces.js new file mode 100644 index 00000000..bf6414aa --- /dev/null +++ b/services/storage/handlers/namespaces.js @@ -0,0 +1,352 @@ +'use strict'; + +const NamespaceMember = require('../models/namespace-member'); +const isLoggedIn = require('../decorators/is-logged-in'); +const Namespace = require('../models/namespace'); +const { response, fork } = require('boltzmann'); +const Package = require('../models/package'); +const User = require('../models/user'); + +module.exports = [ + fork.get('/v1/namespaces', namespaces), + fork.get('/v1/namespaces/namespace/:namespace([^@]+)@:host/members', members), + fork.post( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/members/:invitee', + findUser(canChangeNamespace(invite)) + ), + fork.del( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/members/:invitee', + findUser(canChangeNamespace(remove)) + ), + fork.post( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/members/invitation', + findNamespace(accept) + ), + fork.del( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/members/invitation', + findNamespace(decline) + ), + fork.get( + '/v1/users/user/:namespace([^@]+)@:host/memberships/pending', + findUser(pendingMemberships) + ), + fork.get('/v1/users/user/:namespace([^@]+)@:host/memberships', memberships), + fork.get( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/memberships', + memberships + ), + fork.get( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/maintainerships/pending', + findNamespace(pendingMaintainerships) + ), + // probably belongs in the packages file, but whatever + fork.get( + '/v1/namespaces/namespace/:namespace([^@]+)@:host/maintainerships', + findNamespace(maintainerships) + ) +]; + +function findUser(next) { + return async (context, params) => { + const user = await User.objects + .get({ + active: true, + name: params.invitee + }) + .catch(User.objects.NotFound, () => null); + + context.invitee = user; + return next(context, params); + }; +} + +function findNamespace(next) { + return async (context, params) => { + const ns = await Namespace.objects + .get({ + active: true, + name: params.namespace, + 'host.name': params.host + }) + .catch(Namespace.objects.NotFound, () => null); + + context.namespace = ns; + return next(context, params); + }; +} + +// This is identical to isNameSpaceMember except for the parameters read. +// This one pays attention to the host. +function canChangeNamespace(next) { + return async (context, params) => { + if (!context.user) { + return response.error( + 'You must be logged in to perform this action', + 403 + ); + } + + const ns = await Namespace.objects + .get({ + active: true, + name: params.namespace, + 'host.name': params.host, + 'namespace_members.active': true, + 'namespace_members.user_id': context.user.id + }) + .catch(Namespace.objects.NotFound, () => null); + + if (!ns) { + return response.error( + `You cannot act on behalf of ${params.namespace}@${params.host}`, + 403 + ); + } + + context.namespace = ns; + return next(context, params); + }; +} + +async function namespaces(context, params) { + const namespaces = await Namespace.objects + .filter({ + active: true + }) + .values('name') + .then(); + const objects = namespaces.map(ns => ns.name).sort(); + return response.json({ objects }); +} + +async function members(context, { namespace, host }) { + const ns = await Namespace.objects + .get({ + active: true, + name: namespace, + 'host.name': host + }) + .catch(Namespace.objects.NotFound, () => null); + + if (!ns) { + return response.error(`${namespace}@${host} does not exist.`, 404); + } + const users = await User.objects + .filter({ + 'namespace_members.namespace_id': ns.id, + 'namespace_members.active': true, + 'namespace_members.accepted': true + }) + .then(); + + const objects = users.map(users => users.name).sort(); + return response.json({ objects }); +} + +async function invite(context, { invitee, namespace, host }) { + if (!context.invitee) { + return response.error(`${invitee} not found.`, 404); + } + + const existing = await NamespaceMember.objects + .get({ user: context.invitee, namespace: context.namespace }) + .catch(NamespaceMember.objects.NotFound, () => null); + + if (existing) { + let msg; + if (existing.active) { + msg = `${invitee} is already a member of ${namespace}@${host}.`; + } else { + msg = `${invitee} has already been invited to join ${namespace}@${host}.`; + } + return response.message(msg); + } + + await NamespaceMember.objects.create({ + namespace: context.namespace, + user: context.invitee, + accepted: false, + active: true + }); + + context.logger.info( + `${invitee} invited to join ${namespace}@${host} by ${context.user.name}` + ); + return response.message(`${invitee} invited to join ${namespace}@${host}.`); +} + +async function remove(context, { invitee, namespace, host }) { + if (!context.invitee) { + return response.error(`${invitee} does not exist.`, 404); + } + + const membership = await NamespaceMember.objects + .filter({ + user_id: context.invitee.id, + namespace_id: context.namespace.id, + active: true + }) + .slice(0, 1) + .update({ + modified: new Date(), + active: false + }) + .then(); + + if (membership.length === 0) { + return response.message( + `${invitee} was not a member of ${namespace}@${host}.` + ); + } + context.logger.info( + `${invitee} removed from ${namespace}@${host} by ${context.user.name}` + ); + + return response.message(`${invitee} removed from ${namespace}@${host}.`); +} + +async function accept(context, { namespace, host }) { + const invitation = await NamespaceMember.objects + .filter({ + namespace_id: context.namespace.id, + user_id: context.user.id, + accepted: false, + active: true + }) + .update({ + accepted: true + }) + .catch(NamespaceMember.objects.NotFound, () => null); + + if (!invitation) { + return response.error('invitation not found', 404); + } + + context.logger.info( + `${context.user.name} accepted the invitation to join ${namespace}@${host}` + ); + return response.message( + `${context.user.name} is now a member of ${namespace}@${host}` + ); +} + +async function decline(context, { namespace, host }) { + const invitation = await NamespaceMember.objects + .get({ + namespace_id: context.namespace.id, + user_id: context.user.id, + active: true, + accepted: false + }) + .catch(NamespaceMember.objects.NotFound, () => null); + + if (!invitation) { + return response.error('invitation not found', 404); + } + + await NamespaceMember.objects + .filter({ + id: invitation.id + }) + .update({ + active: false + }); + + context.logger.info( + `${context.user.name} declined the invitation to join ${namespace}@${host}` + ); + return response.message( + `You have declined the invitation to join ${namespace}@${host}` + ); +} + +async function pendingMemberships(context, { invitee }) { + if (!context.invitee) { + return response.error(`${invitee} does not exist.`, 404); + } + + const memberships = await Namespace.objects + .filter({ + 'namespace_members.accepted': false, + 'namespace_members.active': true, + 'namespace_members.user_id': context.invitee.id, + active: true + }) + .then(); + + const objects = []; + for (const ns of memberships) { + objects.push(ns); + } + + return response.json({ objects }); +} + +async function memberships(context, { host, namespace }) { + const user = await User.objects + .get({ + active: true, + name: namespace + }) + .catch(User.objects.NotFound, () => null); + + if (!user) { + return response.error(`${namespace}@${host} not found`, 404); + } + + const memberships = await Namespace.objects + .filter({ + 'namespace_members.user_id': user.id, + 'namespace_members.active': true, + 'namespace_members.accepted': true, + active: true + }) + .values('name') + .then(); + + const objects = []; + for (const ns of memberships) { + objects.push(ns); + } + + return response.json({ objects }); +} + +async function pendingMaintainerships(context, params) { + const pkgInvitations = await Package.objects + .filter({ + 'maintainers.accepted': false, + 'maintainers.active': true, + 'maintainers.namespace_id': context.namespace.id + }) + .then(); + + console.log(pkgInvitations); + + const objects = []; + for (const pkg of pkgInvitations) { + objects.push(await pkg.serialize()); + } + + return response.json({ objects }); +} + +async function maintainerships(context, params) { + const pkgInvitations = await Package.objects + .filter({ + 'maintainers.accepted': true, + 'maintainers.active': true, + 'maintainers.namespace_id': context.namespace.id, + active: true, + 'namespace.active': true, + 'namespace.host.active': true + }) + .then(); + + const objects = []; + for (const pkg of pkgInvitations) { + objects.push(await pkg.serialize()); + } + + return response.json({ objects }); +} diff --git a/services/storage/handlers/packages.js b/services/storage/handlers/packages.js new file mode 100644 index 00000000..f6889c12 --- /dev/null +++ b/services/storage/handlers/packages.js @@ -0,0 +1,509 @@ +'use strict'; + +const { Response } = require('node-fetch'); +const { markdown } = require('markdown'); +const { Transform } = require('stream'); +const { Form } = require('multiparty'); +const { json } = require('micro'); +const semver = require('semver'); +const zlib = require('zlib'); + +const PackageVersion = require('../models/package-version'); +const canWrite = require('../decorators/can-write-package'); +const clone = require('../lib/clone-legacy-package'); +const Maintainer = require('../models/maintainer'); +const Namespace = require('../models/namespace'); +const { response, fork } = require('boltzmann'); +const Package = require('../models/package'); +const check = require('../lib/validations'); + +// Set these env vars to "Infinity" if you'd like to turn these checks off. +const MAX_DEPENDENCIES = Number(process.env.MAX_DEPENDENCIES) || 1024; +const MAX_FILES = Number(process.env.MAX_FILES) || 2000000; + +module.exports = [ + fork.get('/v1/packages', packageList), + fork.get('/v1/packages/package/:namespace([^@]+)@:host/:name', packageDetail), + fork.put( + '/v1/packages/package/:namespace([^@]+)@:host/:name', + canWrite(packageCreate) + ), + fork.del( + '/v1/packages/package/:namespace([^@]+)@:host/:name', + canWrite(packageDelete) + ), + + fork.get( + '/v1/packages/package/:namespace([^@]+)@:host/:name/versions/:version', + versionDetail + ), + fork.put( + '/v1/packages/package/:namespace([^@]+)@:host/:name/versions/:version', + canWrite(versionCreate) + ), + fork.del( + '/v1/packages/package/:namespace([^@]+)@:host/:name/versions/:version', + canWrite(versionDelete) + ), + + fork.get('/v1/objects/object/:algo/*', getObject) +]; + +async function packageList(context) { + const packages = await Package.objects + .filter({ + active: true, + 'namespace.active': true, + 'namespace.host.active': true + }) + .then(); + + const objects = []; + for (const pkg of packages) { + objects.push(await pkg.serialize()); + } + + return response.json({ objects }); +} + +async function packageDetail( + context, + { host, namespace, name, retry = false } +) { + const pkg = await Package.objects + .get({ + active: true, + name, + 'namespace.host.name': host, + 'namespace.host.active': true, + 'namespace.active': true, + 'namespace.name': namespace + }) + .catch(Package.objects.NotFound, () => null); + + if (!pkg) { + if ( + namespace === 'legacy' && + host === process.env.EXTERNAL_HOST.replace(/^https?:\/\//, '') && + !retry + ) { + const client = await context.getPostgresClient(); + + await client.query('BEGIN'); + try { + await clone(name, context.storage); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + return packageDetail(context, { host, namespace, name, retry: true }); + } + + return response.error(`Could not find "${namespace}@${host}/${name}"`, 404); + } + + return response.json(await pkg.serialize()); +} + +async function packageCreate( + context, + { host, namespace: namespaceName, name } +) { + const namespace = await Namespace.objects + .get({ + name: namespaceName, + 'host.name': host, + 'host.active': true, + active: true + }) + .catch(Namespace.objects.NotFound, () => null); + + if (!namespace) { + return response.error(`Could not find namespace "${namespaceName}"`); + } + + if (namespaceName !== 'legacy' && name[0] === '@') { + return response.error( + `Invalid package name "${name}": name cannot be scoped` + ); + } + + const error = check.packageNameOK(name, namespaceName); + if (error) { + return response.error(`Invalid package name "${name}": ${error}`); + } + + const { require_tfa = null } = await json(context.request); + const update = { + ...(require_tfa !== null ? { require_tfa: Boolean(require_tfa) } : {}), + modified: new Date() + }; + + if (update.require_tfa && !context.user.tfa_active) { + return response.error( + `You cannot require 2fa on a package without activating it for your account`, + 400 + ); + } + + let result = null; + if (context.pkg) { + await Package.objects + .filter({ + id: context.pkg.id + }) + .update(update); + + result = await Package.objects.get({ + namespace, + name, + active: true + }); + } else { + result = await Package.objects.create({ + name, + namespace, + ...update + }); + + await Maintainer.objects.create({ + namespace, + package: result, + accepted: true + }); + } + + context.logger.info( + `${namespaceName}@${host}/${name} created by ${context.user.name}` + ); + + return response.json(await result.serialize(), context.pkg ? 200 : 201); +} + +async function packageDelete(context, { host, namespace, name }) { + // yank the package. Transfer it to "abandonware" and mark it yanked. A + // yanked package can still be downloaded, but it won't be displayed in any + // lists, and should emit a warning when people use it. + // + // Support users can transfer the package to a new user using the usual + // package transfer machinery. + if (!context.pkg) { + return response.error( + `"${namespace}@${host}/${name}" does not exist.`, + 404 + ); + } + + const modified = new Date(); + + await Maintainer.objects + .filter({ + package: context.pkg, + active: true + }) + .update({ + modified, + active: false + }); + + // XXX: Should yanking a package yank all versions? + await PackageVersion.objects + .filter({ + parent: context.pkg, + yanked: false + }) + .update({ + modified, + yanked: true + }); + + await Package.objects + .filter({ + id: context.pkg.id + }) + .update({ + modified, + yanked: true + }); + + await Maintainer.objects.create({ + namespace: await Namespace.objects.get({ + active: true, + 'host.name': process.env.EXTERNAL_HOST.replace(/^https?:\/\//, ''), + 'host.active': true, + name: 'abandonware' + }), + package: context.pkg, + accepted: true + }); + + context.logger.info( + `${namespace}@${host}/${name} marked as abandonware by ${context.user.name}` + ); + + return response.text('', 204); +} + +async function versionDetail(context, { host, namespace, name, version }) { + const v = await PackageVersion.objects + .get({ + 'parent.namespace.host.name': host, + 'parent.namespace.host.active': true, + 'parent.namespace.name': namespace, + 'parent.namespace.active': true, + 'parent.active': true, + 'parent.name': name, + active: true, + version + }) + .catch(PackageVersion.objects.NotFound, () => null); + + if (!v) { + return response.error( + `Could not find "${namespace}@${host}/${name} at ${version}"`, + 404 + ); + } + + return response.json(await v.serialize()); +} + +async function versionCreate(context, { host, namespace, name, version }) { + // does a package with this version currently exist? + // if it does, that's a 409 + // is the version valid semver? if not, that's a 400 + if (!context.pkg) { + return response.error( + `"${namespace}@${host}/${name} does not exist. Create it!`, + 404 + ); + } + + const cleaned = semver.clean(version); + if (cleaned !== version) { + return response.error( + `"${version}" is not valid semver; try "${cleaned}" instead.`, + 400 + ); + } + + if (!semver.valid(version)) { + return response.error(`"${version}" is not valid semver`, 400); + } + + const [any = null] = await PackageVersion.objects + .filter({ + 'parent.namespace.name': namespace, + 'parent.namespace.active': true, + 'parent.name': name, + 'parent.active': true, + active: true, + version + }) + .values('id') + .slice(0, 1) + .then(); + + if (any) { + return response.error( + `Cannot publish over previously-published "${namespace}@${host}/${name} at ${version}".`, + 409 + ); + } + + // Ceej notes this generosity as a potential memory usage problem down the road. + const form = new Form({ maxFields: 2 * MAX_FILES }); + let validationError = null; + + const oncomplete = new Promise((resolve, reject) => { + form.once('error', reject); + form.once('close', resolve); + }); + + const formdata = { + signatures: [], + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + bundledDependencies: {}, + files: {}, + derivedFiles: {} + }; + + form.on('field', (key, value) => { + if (validationError) { + return; + } + + switch (key) { + case 'signature': + formdata.signatures.push(value); + break; + case 'dependencies': + case 'devDependencies': + case 'optionalDependencies': + case 'peerDependencies': + // case 'bundledDependencies': + try { + value = JSON.parse(value); + } catch { + validationError = new Error(`expected "${key}" to be JSON`); + } + + const outgoing = {}; + for (const dep in value) { + const warnings = []; + const validated = check.validDependencyName(dep, warnings); + if (!validated) { + validationError = new Error(warnings.join(', ')); + break; + } + const { canonical } = validated; + + // XXX: how do we validate npm-style short deps like `github/bloo`? + if ( + typeof value[dep] !== 'string' || + !semver.validRange(value[dep]) + ) { + validationError = new Error( + `invalid semver range in "${key}" for "${dep}": "${value[dep]}"` + ); + } + + outgoing[canonical] = value[dep]; + } + + formdata[key] = outgoing; + break; + } + }); + + let filecount = 0; + form.on('part', part => { + if (validationError) { + part.resume(); + return; + } + + ++filecount; + part.on('error', err => { + validationError = err; + }); + + const filename = + './' + decodeURIComponent(String(part.filename)).replace(/^\/+/g, ''); + formdata.files[filename] = context.storage.add(part); + + if (/^\.\/package\/readme(\.(md|markdown))?/i.test(filename)) { + const chunks = []; + formdata.derivedFiles['./readme.html'] = context.storage.add( + part.pipe( + new Transform({ + transform(chunk, enc, ready) { + chunks.push(chunk); + return ready(); + }, + flush(ready) { + try { + const readme = Buffer.concat(chunks); // TODO: utf16 is important! + const md = markdown.toHTML(String(readme)); + this.push(md); + } finally { + ready(); + } + } + }) + ) + ); + } + }); + + if (context.request.headers['content-encoding'] === 'deflate') { + const pipe = context.request.pipe; + context.request.pipe = (...args) => { + return pipe.call(context.request, zlib.createInflate()).pipe(...args); + }; + } + + form.parse(context.request); + try { + await oncomplete; + if (validationError) { + throw validationError; + } + } catch (err) { + return response.error(err.message, 400); + } + + // leaving bundledDeps out of the deps count. + if ( + Object.keys(formdata.dependencies).length + + Object.keys(formdata.optionalDependencies).length + + Object.keys(formdata.devDependencies).length + + Object.keys(formdata.peerDependencies).length > + MAX_DEPENDENCIES + ) { + return response.error(`Exceeded maximum number of dependencies.`, 400); + } + + if (filecount > MAX_FILES) { + return response.error( + `Exceeded maximum number of files in a version.`, + 400 + ); + } + + await Promise.all( + Object.keys(formdata.files).map(filename => { + return formdata.files[filename].then( + integrity => (formdata.files[filename] = integrity) + ); + }) + ); + + await Promise.all( + Object.keys(formdata.derivedFiles).map(filename => { + return formdata.derivedFiles[filename].then( + integrity => (formdata.derivedFiles[filename] = integrity) + ); + }) + ); + + const pkgVersion = await PackageVersion.objects.create({ + ...formdata, + version, + parent: context.pkg + }); + + const [integrity, data] = await pkgVersion.toSSRI(); + await context.storage.addBuffer(integrity, Buffer.from(data)); + + const versions = await context.pkg.versions(); + + await Package.objects.filter({ id: context.pkg.id }).update({ + modified: pkgVersion.modified, + tags: { ...(context.pkg.tags || {}), latest: version }, + version_integrities: versions + }); + + context.logger.info( + `${namespace}@${host}/${name} at ${version} published by ${ + context.user.name + }` + ); + + return response.json(await pkgVersion.serialize(), 201); +} + +async function versionDelete(context, { host, namespace, name, version }) {} + +async function getObject(context, { algo, '*': digest }) { + return new Response(await context.storage.strategy.get(algo, digest), { + status: 200, + headers: { + 'content-type': 'application/octet-stream' + } + }); +} diff --git a/services/web/.env-example b/services/web/.env-example new file mode 100644 index 00000000..9c157504 --- /dev/null +++ b/services/web/.env-example @@ -0,0 +1 @@ +REDIS_URL=redis://redis:6379 diff --git a/services/registry/handlers/www.js b/services/web/handlers/auth.js similarity index 76% rename from services/registry/handlers/www.js rename to services/web/handlers/auth.js index 9ae64cc9..f591028f 100644 --- a/services/registry/handlers/www.js +++ b/services/web/handlers/auth.js @@ -9,23 +9,20 @@ const { text } = require('micro'); const { URL } = require('url'); const CSRF = require('csrf'); -const Authentication = require('../models/authentication'); const { response, fork } = require('boltzmann'); -const Token = require('../models/token'); -const User = require('../models/user'); const TOKENS = new CSRF(); module.exports = [ fork.get( - '/www/login/providers/:provider/callback', + '/login/providers/:provider/callback', redirectAuthenticated(oauthCallback) ), - fork.get('/www/login', handleCLISession(redirectAuthenticated(login))), - fork.get('/www/signup', redirectAuthenticated(signup)), - fork.post('/www/signup', redirectAuthenticated(signupAction)), - fork.get('/www/tokens', seasurf(redirectUnauthenticated(tokens))), - fork.post('/www/tokens', seasurf(redirectUnauthenticated(handleTokenAction))) + fork.get('/login', handleCLISession(redirectAuthenticated(login))), + fork.get('/signup', redirectAuthenticated(signup)), + fork.post('/signup', redirectAuthenticated(signupAction)), + fork.get('/tokens', seasurf(redirectUnauthenticated(tokens))), + fork.post('/tokens', seasurf(redirectUnauthenticated(handleTokenAction))) ]; async function login(context) { @@ -34,13 +31,15 @@ async function login(context) { context.session.delete('remoteAuth'); context.session.set('state', state); + const providers = await context.storageApi.getProviders() + return response.html(`

So, you're thinking of logging in.