diff --git a/lib/utils.js b/lib/utils.js index 4c80a4b58..f7ccaaf5c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -36,18 +36,14 @@ function validate_name(name) { name = name.toLowerCase(); // all URL-safe characters and "@" for issue #75 - if (!name.match(/^[-a-zA-Z0-9_.!~*'()@]+$/) + return !(!name.match(/^[-a-zA-Z0-9_.!~*'()@]+$/) || name.charAt(0) === '.' // ".bin", etc. || name.charAt(0) === '-' // "-" is reserved by couchdb || name === 'node_modules' || name === '__proto__' || name === 'package.json' || name === 'favicon.ico' - ) { - return false; - } else { - return true; - } + ); } /** diff --git a/lib/web/api/api.js b/lib/web/api/api.js index 46893e0c1..834fc1f89 100644 --- a/lib/web/api/api.js +++ b/lib/web/api/api.js @@ -1,24 +1,25 @@ 'use strict'; -let Cookies = require('cookies'); -let express = require('express'); -let bodyParser = require('body-parser'); -let Error = require('http-errors'); -let Path = require('path'); -let Middleware = require('../middleware'); -let Notify = require('../../notify'); -let Utils = require('../../utils'); -let expect_json = Middleware.expect_json; -let match = Middleware.match; -let media = Middleware.media; -let validate_name = Middleware.validate_name; -let validate_pkg = Middleware.validate_package; +const express = require('express'); +const bodyParser = require('body-parser'); +const Middleware = require('../middleware'); +const match = Middleware.match; +const validate_name = Middleware.validate_name; +const validate_pkg = Middleware.validate_package; +const encodeScopePackage = Middleware.encodeScopePackage; + +const whoami = require('./endpoint/whoami'); +const ping = require('./endpoint/ping'); +const user = require('./endpoint/user'); +const distTags = require('./endpoint/dist-tags'); +const publish = require('./endpoint/publish'); +const search = require('./endpoint/search'); +const pkg = require('./endpoint/package'); module.exports = function(config, auth, storage) { /* eslint new-cap:off */ const app = express.Router(); - const can = Middleware.allow(auth); - const notify = Notify.notify; + /* eslint new-cap:off */ // validate all of these params as a package name // this might be too harsh, so ask if it causes trouble @@ -40,453 +41,22 @@ module.exports = function(config, auth, storage) { app.use(Middleware.anti_loop(config)); // encode / in a scoped package name to be matched as a single parameter in routes - app.use(function(req, res, next) { - if (req.url.indexOf('@') != -1) { - // e.g.: /@org/pkg/1.2.3 -> /@org%2Fpkg/1.2.3, /@org%2Fpkg/1.2.3 -> /@org%2Fpkg/1.2.3 - req.url = req.url.replace(/^(\/@[^\/%]+)\/(?!$)/, '$1%2F'); - } - next(); - }); + app.use(encodeScopePackage); // for "npm whoami" - app.get('/whoami', function(req, res, next) { - if (req.headers.referer === 'whoami') { - next({username: req.remote_user.name}); - } else { - next('route'); - } - }); - app.get('/-/whoami', function(req, res, next) { - next({username: req.remote_user.name}); - }); + whoami(app); - // TODO: anonymous user? - app.get('/:package/:version?', can('access'), function(req, res, next) { - storage.get_package(req.params.package, {req: req}, function(err, info) { - if (err) return next(err); - info = Utils.filter_tarball_urls(info, req, config); + pkg(app, auth, storage, config); - let version = req.params.version; - if (!version) return next(info); + search(app, auth, storage); - let t = Utils.get_version(info, version); - if (t != null) return next(t); + user(app, auth); - if (info['dist-tags'] != null) { - if (info['dist-tags'][version] != null) { - version = info['dist-tags'][version]; - t = Utils.get_version(info, version); - if (t != null) return next(t); - } - } + distTags(app, auth, storage); - return next( Error[404]('version not found: ' + req.params.version) ); - }); - }); + publish(app, auth, storage, config); - app.get('/:package/-/:filename', can('access'), function(req, res, next) { - let stream = storage.get_tarball(req.params.package, req.params.filename); - stream.on('content-length', function(v) { - res.header('Content-Length', v); - }); - stream.on('error', function(err) { - return res.report_error(err); - }); - res.header('Content-Type', 'application/octet-stream'); - stream.pipe(res); - }); - - // searching packages - app.get('/-/all(\/since)?', function(req, res, next) { - let received_end = false; - let response_finished = false; - let processing_pkgs = 0; - let firstPackage = true; - - res.status(200); - - /* - * Offical NPM registry (registry.npmjs.org) no longer return whole database, - * They only return packages matched with keyword in `referer: search pkg-name`, - * And NPM client will request server in every search. - * - * The magic number 99999 was sent by NPM registry. Modify it may caused strange - * behaviour in the future. - * - * BTW: NPM will not return result if user-agent does not contain string 'npm', - * See: method 'request' in up-storage.js - * - * If there is no cache in local, NPM will request /-/all, then get response with - * _updated: 99999, 'Date' in response header was Mon, 10 Oct 1983 00:12:48 GMT, - * this will make NPM always query from server - * - * Data structure also different, whel request /-/all, response is an object, but - * when request /-/all/since, response is an array - */ - const respShouldBeArray = req.path.endsWith('/since'); - res.set('Date', 'Mon, 10 Oct 1983 00:12:48 GMT'); - const check_finish = function() { - if (!received_end) { - return; - } - if (processing_pkgs) { - return; - } - if (response_finished) { - return; - } - response_finished = true; - if (respShouldBeArray) { - res.end(']\n'); - } else { - res.end('}\n'); - } - }; - - if (respShouldBeArray) { - res.write('['); - } else { - res.write('{"_updated":' + 99999); - } - - let stream = storage.search(req.query.startkey || 0, {req: req}); - - stream.on('data', function each(pkg) { - processing_pkgs++; - - auth.allow_access(pkg.name, req.remote_user, function(err, allowed) { - processing_pkgs--; - - if (err) { - if (err.status && String(err.status).match(/^4\d\d$/)) { - // auth plugin returns 4xx user error, - // that's equivalent of !allowed basically - allowed = false; - } else { - stream.abort(err); - } - } - - if (allowed) { - if (respShouldBeArray) { - res.write(`${firstPackage ? '' : ','}${JSON.stringify(pkg)}\n`); - if (firstPackage) { - firstPackage = false; - } - } else { - res.write(',\n' + JSON.stringify(pkg.name) + ':' + JSON.stringify(pkg)); - } - } - - check_finish(); - }); - }); - - stream.on('error', function(_err) { - res.socket.destroy(); - }); - - stream.on('end', function() { - received_end = true; - check_finish(); - }); - }); - - // placeholder 'cause npm require to be authenticated to publish - // we do not do any real authentication yet - app.post('/_session', Cookies.express(), function(req, res, next) { - res.cookies.set('AuthSession', String(Math.random()), { - // npmjs.org sets 10h expire - expires: new Date(Date.now() + 10*60*60*1000), - }); - next({ok: true, name: 'somebody', roles: []}); - }); - - app.get('/-/user/:org_couchdb_user', function(req, res, next) { - res.status(200); - next({ - ok: 'you are authenticated as "' + req.remote_user.name + '"', - }); - }); - - app.put('/-/user/:org_couchdb_user/:_rev?/:revision?', function(req, res, next) { - let token = (req.body.name && req.body.password) - ? auth.aes_encrypt(req.body.name + ':' + req.body.password).toString('base64') - : undefined; - if (req.remote_user.name != null) { - res.status(201); - return next({ - ok: 'you are authenticated as \'' + req.remote_user.name + '\'', - // token: auth.issue_token(req.remote_user), - token: token, - }); - } else { - auth.add_user(req.body.name, req.body.password, function(err, user) { - if (err) { - if (err.status >= 400 && err.status < 500) { - // With npm registering is the same as logging in, - // and npm accepts only an 409 error. - // So, changing status code here. - return next( Error[409](err.message) ); - } - return next(err); - } - - req.remote_user = user; - res.status(201); - return next({ - ok: 'user \'' + req.body.name + '\' created', - // token: auth.issue_token(req.remote_user), - token: token, - }); - }); - } - }); - - app.delete('/-/user/token/*', function(req, res, next) { - res.status(200); - next({ - ok: 'Logged out', - }); - }); - - const tag_package_version = function(req, res, next) { - if (typeof(req.body) !== 'string') { - return next('route'); - } - - let tags = {}; - tags[req.params.tag] = req.body; - storage.merge_tags(req.params.package, tags, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'package tagged'}); - }); - }; - - // tagging a package - app.put('/:package/:tag', - can('publish'), media('application/json'), tag_package_version); - - app.post('/-/package/:package/dist-tags/:tag', - can('publish'), media('application/json'), tag_package_version); - - app.put('/-/package/:package/dist-tags/:tag', - can('publish'), media('application/json'), tag_package_version); - - app.delete('/-/package/:package/dist-tags/:tag', can('publish'), function(req, res, next) { - let tags = {}; - tags[req.params.tag] = null; - storage.merge_tags(req.params.package, tags, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'tag removed'}); - }); - }); - - app.get('/-/package/:package/dist-tags', can('access'), function(req, res, next) { - storage.get_package(req.params.package, {req: req}, function(err, info) { - if (err) return next(err); - - next(info['dist-tags']); - }); - }); - - app.post('/-/package/:package/dist-tags', can('publish'), media('application/json'), expect_json, - function(req, res, next) { - storage.merge_tags(req.params.package, req.body, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'tags updated'}); - }); - }); - - app.put('/-/package/:package/dist-tags', can('publish'), media('application/json'), expect_json, - function(req, res, next) { - storage.replace_tags(req.params.package, req.body, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'tags updated'}); - }); - }); - - app.delete('/-/package/:package/dist-tags', can('publish'), media('application/json'), - function(req, res, next) { - storage.replace_tags(req.params.package, {}, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'tags removed'}); - }); - }); - - // publishing a package - app.put('/:package/:_rev?/:revision?', can('publish'), media('application/json'), expect_json, function(req, res, next) { - let name = req.params.package; - let metadata; - const create_tarball = function(filename, data, cb) { - let stream = storage.add_tarball(name, filename); - stream.on('error', function(err) { - cb(err); - }); - stream.on('success', function() { - cb(); - }); - - // this is dumb and memory-consuming, but what choices do we have? - stream.end(new Buffer(data.data, 'base64')); - stream.done(); - }; - - const create_version = function(version, data, cb) { - storage.add_version(name, version, data, null, cb); - }; - - const add_tags = function(tags, cb) { - storage.merge_tags(name, tags, cb); - }; - - const after_change = function(err, ok_message) { - // old npm behaviour - if (metadata._attachments == null) { - if (err) return next(err); - res.status(201); - return next({ok: ok_message, success: true}); - } - - // npm-registry-client 0.3+ embeds tarball into the json upload - // https://github.com/isaacs/npm-registry-client/commit/e9fbeb8b67f249394f735c74ef11fe4720d46ca0 - // issue https://github.com/rlidwka/sinopia/issues/31, dealing with it here: - - if (typeof(metadata._attachments) !== 'object' - || Object.keys(metadata._attachments).length !== 1 - || typeof(metadata.versions) !== 'object' - || Object.keys(metadata.versions).length !== 1) { - // npm is doing something strange again - // if this happens in normal circumstances, report it as a bug - return next( Error[400]('unsupported registry call') ); - } - - if (err && err.status != 409) { - return next(err); - } - - // at this point document is either created or existed before - const t1 = Object.keys(metadata._attachments)[0]; - create_tarball(Path.basename(t1), metadata._attachments[t1], function(err) { - if (err) { - return next(err); - } - - const t2 = Object.keys(metadata.versions)[0]; - metadata.versions[t2].readme = metadata.readme != null ? String(metadata.readme) : ''; - create_version(t2, metadata.versions[t2], function(err) { - if (err) { - return next(err); - } - - add_tags(metadata['dist-tags'], function(err) { - if (err) { - return next(err); - } - notify(metadata, config); - res.status(201); - return next({ok: ok_message, success: true}); - }); - }); - }); - }; - - if (Object.keys(req.body).length == 1 && Utils.is_object(req.body.users)) { - // 501 status is more meaningful, but npm doesn't show error message for 5xx - return next( Error[404]('npm star|unstar calls are not implemented') ); - } - - try { - metadata = Utils.validate_metadata(req.body, name); - } catch(err) { - return next( Error[422]('bad incoming package data') ); - } - - if (req.params._rev) { - storage.change_package(name, metadata, req.params.revision, function(err) { - after_change(err, 'package changed'); - }); - } else { - storage.addPackage(name, metadata, function(err) { - after_change(err, 'created new package'); - }); - } -}); - - // unpublishing an entire package - app.delete('/:package/-rev/*', can('publish'), function(req, res, next) { - storage.remove_package(req.params.package, function(err) { - if (err) { - return next(err); - } - res.status(201); - return next({ok: 'package removed'}); - }); - }); - - // removing a tarball - app.delete('/:package/-/:filename/-rev/:revision', can('publish'), function(req, res, next) { - storage.remove_tarball(req.params.package, req.params.filename, req.params.revision, function(err) { - if (err) { - return next(err); - } - res.status(201); - return next({ok: 'tarball removed'}); - }); - }); - - // uploading package tarball - app.put('/:package/-/:filename/*', can('publish'), media('application/octet-stream'), function(req, res, next) { - const name = req.params.package; - const stream = storage.add_tarball(name, req.params.filename); - req.pipe(stream); - - // checking if end event came before closing - let complete = false; - req.on('end', function() { - complete = true; - stream.done(); - }); - req.on('close', function() { - if (!complete) { - stream.abort(); - } - }); - - stream.on('error', function(err) { - return res.report_error(err); - }); - stream.on('success', function() { - res.status(201); - return next({ - ok: 'tarball uploaded successfully', - }); - }); - }); - - // adding a version - app.put('/:package/:version/-tag/:tag', can('publish'), media('application/json'), expect_json, function(req, res, next) { - let name = req.params.package; - let version = req.params.version; - let tag = req.params.tag; - - storage.add_version(name, version, req.body, tag, function(err) { - if (err) return next(err); - res.status(201); - return next({ok: 'package published'}); - }); - }); - - // npm ping - app.get('/-/ping', function(req, res, next) { - next({}); - }); + ping(app); return app; }; - diff --git a/lib/web/api/endpoint/dist-tags.js b/lib/web/api/endpoint/dist-tags.js new file mode 100644 index 000000000..a21bbc96e --- /dev/null +++ b/lib/web/api/endpoint/dist-tags.js @@ -0,0 +1,83 @@ +'use strict'; + +const Middleware = require('../../middleware'); +const constant = require('../../utils/const'); + +const media = Middleware.media; +const expect_json = Middleware.expect_json; + +module.exports = function(route, auth, storage) { + const can = Middleware.allow(auth); + const tag_package_version = function(req, res, next) { + if (typeof(req.body) !== 'string') { + return next('route'); + } + + let tags = {}; + tags[req.params.tag] = req.body; + storage.merge_tags(req.params.package, tags, function(err) { + if (err) return next(err); + res.status(201); + return next({ok: 'package tagged'}); + }); + }; + + // tagging a package + route.put('/:package/:tag', + can('publish'), media(constant.CONTENT_JSON), tag_package_version); + + route.post('/-/package/:package/dist-tags/:tag', + can('publish'), media(constant.CONTENT_JSON), tag_package_version); + + route.put('/-/package/:package/dist-tags/:tag', + can('publish'), media(constant.CONTENT_JSON), tag_package_version); + + route.delete('/-/package/:package/dist-tags/:tag', can('publish'), function(req, res, next) { + let tags = {}; + tags[req.params.tag] = null; + storage.merge_tags(req.params.package, tags, function(err) { + if (err) { + return next(err); + } + res.status(201); + return next({ + ok: 'tag removed', + }); + }); + }); + + route.get('/-/package/:package/dist-tags', can('access'), function(req, res, next) { + storage.get_package(req.params.package, {req: req}, function(err, info) { + if (err) return next(err); + + next(info['dist-tags']); + }); + }); + + route.post('/-/package/:package/dist-tags', can('publish'), media(constant.CONTENT_JSON), expect_json, + function(req, res, next) { + storage.merge_tags(req.params.package, req.body, function(err) { + if (err) return next(err); + res.status(201); + return next({ok: 'tags updated'}); + }); + }); + + route.put('/-/package/:package/dist-tags', can('publish'), media(constant.CONTENT_JSON), expect_json, + function(req, res, next) { + storage.replace_tags(req.params.package, req.body, function(err) { + if (err) return next(err); + res.status(201); + return next({ok: 'tags updated'}); + }); + }); + + route.delete('/-/package/:package/dist-tags', can('publish'), media(constant.CONTENT_JSON), + function(req, res, next) { + storage.replace_tags(req.params.package, {}, function(err) { + if (err) return next(err); + res.status(201); + return next({ok: 'tags removed'}); + }); + }); +}; diff --git a/lib/web/api/endpoint/package.js b/lib/web/api/endpoint/package.js new file mode 100644 index 000000000..87caacb0d --- /dev/null +++ b/lib/web/api/endpoint/package.js @@ -0,0 +1,54 @@ +'use strict'; + +const _ = require('lodash'); +const createError = require('http-errors'); + +const Middleware = require('../../middleware'); +const Utils = require('../../../utils'); + +module.exports = function(route, auth, storage, config) { + const can = Middleware.allow(auth); + // TODO: anonymous user? + route.get('/:package/:version?', can('access'), function(req, res, next) { + storage.get_package(req.params.package, {req: req}, function(err, info) { + if (err) { + return next(err); + } + info = Utils.filter_tarball_urls(info, req, config); + + let version = req.params.version; + if (_.isNil(version)) { + return next(info); + } + + let t = Utils.get_version(info, version); + if (_.isNil(t) === false) { + return next(t); + } + + if (_.isNil(info['dist-tags']) === false) { + if (_.isNil(info['dist-tags'][version]) === false) { + version = info['dist-tags'][version]; + t = Utils.get_version(info, version); + if (_.isNil(t)) { + return next(t); + } + } + } + + return next( createError[404]('version not found: ' + req.params.version) ); + }); + }); + + route.get('/:package/-/:filename', can('access'), function(req, res) { + const stream = storage.get_tarball(req.params.package, req.params.filename); + stream.on('content-length', function(v) { + res.header('Content-Length', v); + }); + stream.on('error', function(err) { + return res.report_error(err); + }); + res.header('Content-Type', 'application/octet-stream'); + stream.pipe(res); + }); +}; diff --git a/lib/web/api/endpoint/ping.js b/lib/web/api/endpoint/ping.js new file mode 100644 index 000000000..b7f8efcbd --- /dev/null +++ b/lib/web/api/endpoint/ping.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = function(route) { + route.get('/-/ping', function(req, res, next) { + next({}); + }); +}; diff --git a/lib/web/api/endpoint/publish.js b/lib/web/api/endpoint/publish.js new file mode 100644 index 000000000..23281418d --- /dev/null +++ b/lib/web/api/endpoint/publish.js @@ -0,0 +1,188 @@ +'use strict'; + +const _ = require('lodash'); +const Path = require('path'); +const createError = require('http-errors'); + +const Middleware = require('../../middleware'); +const Notify = require('../../../notify'); +const Utils = require('../../../utils'); +const constant = require('../../utils/const'); + +const media = Middleware.media; +const expect_json = Middleware.expect_json; +const notify = Notify.notify; + +module.exports = function(router, auth, storage, config) { + const can = Middleware.allow(auth); + + // publishing a package + router.put('/:package/:_rev?/:revision?', can('publish'), media(constant.CONTENT_JSON), expect_json, function(req, res, next) { + const name = req.params.package; + let metadata; + const create_tarball = function(filename, data, cb) { + let stream = storage.add_tarball(name, filename); + stream.on('error', function(err) { + cb(err); + }); + stream.on('success', function() { + cb(); + }); + + // this is dumb and memory-consuming, but what choices do we have? + stream.end(new Buffer(data.data, 'base64')); + stream.done(); + }; + + const create_version = function(version, data, cb) { + storage.add_version(name, version, data, null, cb); + }; + + const add_tags = function(tags, cb) { + storage.merge_tags(name, tags, cb); + }; + + const after_change = function(err, ok_message) { + // old npm behaviour + if (_.isNil(metadata._attachments)) { + if (err) return next(err); + res.status(201); + return next({ + ok: ok_message, + success: true, + }); + } + + // npm-registry-client 0.3+ embeds tarball into the json upload + // https://github.com/isaacs/npm-registry-client/commit/e9fbeb8b67f249394f735c74ef11fe4720d46ca0 + // issue https://github.com/rlidwka/sinopia/issues/31, dealing with it here: + + if (typeof(metadata._attachments) !== 'object' + || Object.keys(metadata._attachments).length !== 1 + || typeof(metadata.versions) !== 'object' + || Object.keys(metadata.versions).length !== 1) { + // npm is doing something strange again + // if this happens in normal circumstances, report it as a bug + return next( createError[400]('unsupported registry call') ); + } + + if (err && err.status != 409) { + return next(err); + } + + // at this point document is either created or existed before + const t1 = Object.keys(metadata._attachments)[0]; + create_tarball(Path.basename(t1), metadata._attachments[t1], function(err) { + if (err) { + return next(err); + } + + const t2 = Object.keys(metadata.versions)[0]; + metadata.versions[t2].readme = _.isNil(metadata.readme) === false ? String(metadata.readme) : ''; + create_version(t2, metadata.versions[t2], function(err) { + if (err) { + return next(err); + } + + add_tags(metadata['dist-tags'], function(err) { + if (err) { + return next(err); + } + notify(metadata, config); + res.status(201); + return next({ok: ok_message, success: true}); + }); + }); + }); + }; + + if (Object.keys(req.body).length === 1 && Utils.is_object(req.body.users)) { + // 501 status is more meaningful, but npm doesn't show error message for 5xx + return next( createError[404]('npm star|unstar calls are not implemented') ); + } + + try { + metadata = Utils.validate_metadata(req.body, name); + } catch(err) { + return next( createError[422]('bad incoming package data') ); + } + + if (req.params._rev) { + storage.change_package(name, metadata, req.params.revision, function(err) { + after_change(err, 'package changed'); + }); + } else { + storage.addPackage(name, metadata, function(err) { + after_change(err, 'created new package'); + }); + } + }); + + // unpublishing an entire package + router.delete('/:package/-rev/*', can('publish'), function(req, res, next) { + storage.remove_package(req.params.package, function(err) { + if (err) { + return next(err); + } + res.status(201); + return next({ok: 'package removed'}); + }); + }); + + // removing a tarball + router.delete('/:package/-/:filename/-rev/:revision', can('publish'), function(req, res, next) { + storage.remove_tarball(req.params.package, req.params.filename, req.params.revision, function(err) { + if (err) { + return next(err); + } + res.status(201); + return next({ok: 'tarball removed'}); + }); + }); + + // uploading package tarball + router.put('/:package/-/:filename/*', can('publish'), media('application/octet-stream'), function(req, res, next) { + const name = req.params.package; + const stream = storage.add_tarball(name, req.params.filename); + req.pipe(stream); + + // checking if end event came before closing + let complete = false; + req.on('end', function() { + complete = true; + stream.done(); + }); + req.on('close', function() { + if (!complete) { + stream.abort(); + } + }); + + stream.on('error', function(err) { + return res.report_error(err); + }); + stream.on('success', function() { + res.status(201); + return next({ + ok: 'tarball uploaded successfully', + }); + }); + }); + + // adding a version + router.put('/:package/:version/-tag/:tag', can('publish'), media(constant.CONTENT_JSON), expect_json, function(req, res, next) { + let name = req.params.package; + let version = req.params.version; + let tag = req.params.tag; + + storage.add_version(name, version, req.body, tag, function(err) { + if (err) { + return next(err); + } + res.status(201); + return next({ + ok: 'package published', + }); + }); + }); +}; diff --git a/lib/web/api/endpoint/search.js b/lib/web/api/endpoint/search.js new file mode 100644 index 000000000..eb6c18593 --- /dev/null +++ b/lib/web/api/endpoint/search.js @@ -0,0 +1,99 @@ +'use strict'; + +module.exports = function(route, auth, storage) { + // searching packages + route.get('/-/all(\/since)?', function(req, res) { + let received_end = false; + let response_finished = false; + let processing_pkgs = 0; + let firstPackage = true; + + res.status(200); + + /* + * Offical NPM registry (registry.npmjs.org) no longer return whole database, + * They only return packages matched with keyword in `referer: search pkg-name`, + * And NPM client will request server in every search. + * + * The magic number 99999 was sent by NPM registry. Modify it may caused strange + * behaviour in the future. + * + * BTW: NPM will not return result if user-agent does not contain string 'npm', + * See: method 'request' in up-storage.js + * + * If there is no cache in local, NPM will request /-/all, then get response with + * _updated: 99999, 'Date' in response header was Mon, 10 Oct 1983 00:12:48 GMT, + * this will make NPM always query from server + * + * Data structure also different, whel request /-/all, response is an object, but + * when request /-/all/since, response is an array + */ + const respShouldBeArray = req.path.endsWith('/since'); + res.set('Date', 'Mon, 10 Oct 1983 00:12:48 GMT'); + const check_finish = function() { + if (!received_end) { + return; + } + if (processing_pkgs) { + return; + } + if (response_finished) { + return; + } + response_finished = true; + if (respShouldBeArray) { + res.end(']\n'); + } else { + res.end('}\n'); + } + }; + + if (respShouldBeArray) { + res.write('['); + } else { + res.write('{"_updated":' + 99999); + } + + let stream = storage.search(req.query.startkey || 0, {req: req}); + + stream.on('data', function each(pkg) { + processing_pkgs++; + + auth.allow_access(pkg.name, req.remote_user, function(err, allowed) { + processing_pkgs--; + + if (err) { + if (err.status && String(err.status).match(/^4\d\d$/)) { + // auth plugin returns 4xx user error, + // that's equivalent of !allowed basically + allowed = false; + } else { + stream.abort(err); + } + } + + if (allowed) { + if (respShouldBeArray) { + res.write(`${firstPackage ? '' : ','}${JSON.stringify(pkg)}\n`); + if (firstPackage) { + firstPackage = false; + } + } else { + res.write(',\n' + JSON.stringify(pkg.name) + ':' + JSON.stringify(pkg)); + } + } + + check_finish(); + }); + }); + + stream.on('error', function(_err) { + res.socket.destroy(); + }); + + stream.on('end', function() { + received_end = true; + check_finish(); + }); + }); +}; diff --git a/lib/web/api/endpoint/user.js b/lib/web/api/endpoint/user.js new file mode 100644 index 000000000..cacdbce50 --- /dev/null +++ b/lib/web/api/endpoint/user.js @@ -0,0 +1,70 @@ +'use strict'; + +const _ = require('lodash'); +const Cookies = require('cookies'); +const createError = require('http-errors'); + +module.exports = function(route, auth) { + route.get('/-/user/:org_couchdb_user', function(req, res, next) { + res.status(200); + next({ + ok: 'you are authenticated as "' + req.remote_user.name + '"', + }); + }); + + route.put('/-/user/:org_couchdb_user/:_rev?/:revision?', function(req, res, next) { + let token = (req.body.name && req.body.password) + ? auth.aes_encrypt(req.body.name + ':' + req.body.password).toString('base64') + : undefined; + if (_.isNil(req.remote_user.name) === false) { + res.status(201); + return next({ + ok: 'you are authenticated as \'' + req.remote_user.name + '\'', + // token: auth.issue_token(req.remote_user), + token: token, + }); + } else { + auth.add_user(req.body.name, req.body.password, function(err, user) { + if (err) { + if (err.status >= 400 && err.status < 500) { + // With npm registering is the same as logging in, + // and npm accepts only an 409 error. + // So, changing status code here. + return next( createError[409](err.message) ); + } + return next(err); + } + + req.remote_user = user; + res.status(201); + return next({ + ok: 'user \'' + req.body.name + '\' created', + // token: auth.issue_token(req.remote_user), + token: token, + }); + }); + } + }); + + route.delete('/-/user/token/*', function(req, res, next) { + res.status(200); + next({ + ok: 'Logged out', + }); + }); + + + // placeholder 'cause npm require to be authenticated to publish + // we do not do any real authentication yet + route.post('/_session', Cookies.express(), function(req, res, next) { + res.cookies.set('AuthSession', String(Math.random()), { + // npmjs.org sets 10h expire + expires: new Date(Date.now() + 10 * 60 * 60 * 1000), + }); + next({ + ok: true, + name: 'somebody', + roles: [], + }); + }); +}; diff --git a/lib/web/api/endpoint/whoami.js b/lib/web/api/endpoint/whoami.js new file mode 100644 index 000000000..38499e6a4 --- /dev/null +++ b/lib/web/api/endpoint/whoami.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = function(route) { + route.get('/whoami', function(req, res, next) { + if (req.headers.referer === 'whoami') { + next({username: req.remote_user.name}); + } else { + next('route'); + } + }); + + route.get('/-/whoami', function(req, res, next) { + next({username: req.remote_user.name}); + }); +}; diff --git a/lib/web/index.js b/lib/web/index.js index e2c7364cd..2f189592e 100644 --- a/lib/web/index.js +++ b/lib/web/index.js @@ -1,55 +1,57 @@ 'use strict'; -let async = require('async'); -let bodyParser = require('body-parser'); -let Cookies = require('cookies'); -let express = require('express'); -let fs = require('fs'); -let Handlebars = require('handlebars'); -let renderReadme = require('render-readme'); -let Search = require('../search'); -let Middleware = require('./middleware'); -let Utils = require('../utils'); -let match = Middleware.match; -let validate_name = Middleware.validate_name; -let validate_pkg = Middleware.validate_package; +const async = require('async'); +const bodyParser = require('body-parser'); +const Cookies = require('cookies'); +const escape = require('js-string-escape'); +const express = require('express'); +const fs = require('fs'); +const Handlebars = require('handlebars'); +const marked = require('marked'); +const Search = require('../search'); +const Middleware = require('./middleware'); +const Utils = require('../utils'); +const match = Middleware.match; +const validateName = Middleware.validate_name; +const validatePkg = Middleware.validate_package; +const securityIframe = Middleware.securityIframe; module.exports = function(config, auth, storage) { + Search.configureStorage(storage); /* eslint new-cap:off */ - let app = express.Router(); - let can = Middleware.allow(auth); + const app = express.Router(); + /* eslint new-cap:off */ + const can = Middleware.allow(auth); + let template; // validate all of these params as a package name // this might be too harsh, so ask if it causes trouble - app.param('package', validate_pkg); - app.param('filename', validate_name); - app.param('version', validate_name); + app.param('package', validatePkg); + app.param('filename', validateName); + app.param('version', validateName); app.param('anything', match(/.*/)); app.use(Cookies.express()); app.use(bodyParser.urlencoded({extended: false})); app.use(auth.cookie_middleware()); - app.use(function(req, res, next) { - // disable loading in frames (clickjacking, etc.) - res.header('X-Frame-Options', 'deny'); - next(); - }); - - Search.configureStorage(storage); + app.use(securityIframe); Handlebars.registerPartial('entry', fs.readFileSync(require.resolve('./ui/entry.hbs'), 'utf8')); - let template; + if (config.web && config.web.template) { template = Handlebars.compile(fs.readFileSync(config.web.template, 'utf8')); } else { template = Handlebars.compile(fs.readFileSync(require.resolve('./ui/index.hbs'), 'utf8')); } + app.get('/', function(req, res, next) { let base = Utils.combineBaseUrl(Utils.getWebProtocol(req), req.get('host'), config.url_prefix); res.setHeader('Content-Type', 'text/html'); storage.get_local(function(err, packages) { - if (err) throw err; // that function shouldn't produce any + if (err) { + throw err; + } // that function shouldn't produce any async.filterSeries(packages, function(pkg, cb) { auth.allow_access(pkg.name, req.remote_user, function(err, allowed) { setImmediate(function() { @@ -69,13 +71,15 @@ module.exports = function(config, auth, storage) { return 1; } }); - - next(template({ - name: config.web && config.web.title ? config.web.title : 'Verdaccio', - tagline: config.web && config.web.tagline ? config.web.tagline : '', + let json = { packages: packages, + tagline: config.web && config.web.tagline ? config.web.tagline : '', baseUrl: base, username: req.remote_user.name, + }; + next(template({ + name: config.web && config.web.title ? config.web.title : 'Verdaccio', + data: escape(JSON.stringify(json)), })); }); }); @@ -85,7 +89,9 @@ module.exports = function(config, auth, storage) { app.get('/-/static/:filename', function(req, res, next) { let file = __dirname + '/static/' + req.params.filename; res.sendFile(file, function(err) { - if (!err) return; + if (!err) { + return; + } if (err.status === 404) { next(); } else { @@ -101,10 +107,9 @@ module.exports = function(config, auth, storage) { }); app.post('/-/login', function(req, res, next) { - auth.authenticate(req.body.user, req.body.pass, function(err, user) { + auth.authenticate(req.body.user, req.body.pass, (err, user) => { if (!err) { req.remote_user = user; - // res.cookies.set('token', auth.issue_token(req.remote_user)) let str = req.body.user + ':' + req.body.pass; res.cookies.set('token', auth.aes_encrypt(str).toString('base64')); @@ -127,10 +132,12 @@ module.exports = function(config, auth, storage) { const packages = []; const getData = function(i) { - storage.get_package(results[i].ref, function(err, entry) { + storage.get_package(results[i].ref, (err, entry) => { if (!err && entry) { auth.allow_access(entry.name, req.remote_user, function(err, allowed) { // TODO: This may cause performance issue? - if (err || !allowed) return; + if (err || !allowed) { + return; + } packages.push(entry.versions[entry['dist-tags'].latest]); }); @@ -153,10 +160,15 @@ module.exports = function(config, auth, storage) { app.get('/-/readme(/@:scope?)?/:package/:version?', can('access'), function(req, res, next) { let packageName = req.params.package; - if (req.params.scope) packageName = '@'+ req.params.scope + '/' + packageName; + if (req.params.scope) { + packageName = `@${req.params.scope}/${packageName}`; + } storage.get_package(packageName, {req: req}, function(err, info) { - if (err) return next(err); - next( renderReadme(info.readme || 'ERROR: No README data found!') ); + if (err) { + return next(err); + } + res.set('Content-Type', 'text/plain'); + next( marked(info.readme || 'ERROR: No README data found!') ); }); }); return app; diff --git a/lib/web/middleware.js b/lib/web/middleware.js index 5644a151d..80bb1649a 100644 --- a/lib/web/middleware.js +++ b/lib/web/middleware.js @@ -3,12 +3,14 @@ 'use strict'; const crypto = require('crypto'); -const Error = require('http-errors'); +const _ = require('lodash'); +const createError = require('http-errors'); const utils = require('../utils'); const Logger = require('../logger'); + module.exports.match = function match(regexp) { - return function(req, res, next, value, name) { + return function(req, res, next, value) { if (regexp.exec(value)) { next(); } else { @@ -17,6 +19,12 @@ module.exports.match = function match(regexp) { }; }; +module.exports.securityIframe = function securityIframe(req, res, next) { + // disable loading in frames (clickjacking, etc.) + res.header('X-Frame-Options', 'deny'); + next(); +}; + module.exports.validate_name = function validate_name(req, res, next, value, name) { if (value.charAt(0) === '-') { // special case in couchdb usually @@ -24,7 +32,7 @@ module.exports.validate_name = function validate_name(req, res, next, value, nam } else if (utils.validate_name(value)) { next(); } else { - next( Error[403]('invalid ' + name) ); + next( createError[403]('invalid ' + name) ); } }; @@ -35,24 +43,32 @@ module.exports.validate_package = function validate_package(req, res, next, valu } else if (utils.validate_package(value)) { next(); } else { - next( Error[403]('invalid ' + name) ); + next( createError[403]('invalid ' + name) ); } }; module.exports.media = function media(expect) { return function(req, res, next) { if (req.headers['content-type'] !== expect) { - next( Error[415]('wrong content-type, expect: ' + expect - + ', got: '+req.headers['content-type']) ); + next( createError[415]('wrong content-type, expect: ' + expect + + ', got: '+req.headers['content-type']) ); } else { next(); } }; }; +module.exports.encodeScopePackage = function(req, res, next) { + if (req.url.indexOf('@') !== -1) { + // e.g.: /@org/pkg/1.2.3 -> /@org%2Fpkg/1.2.3, /@org%2Fpkg/1.2.3 -> /@org%2Fpkg/1.2.3 + req.url = req.url.replace(/^(\/@[^\/%]+)\/(?!$)/, '$1%2F'); + } + next(); +}; + module.exports.expect_json = function expect_json(req, res, next) { if (!utils.is_object(req.body)) { - return next( Error[400]('can\'t parse incoming json') ); + return next( createError[400]('can\'t parse incoming json') ); } next(); }; @@ -65,7 +81,7 @@ module.exports.anti_loop = function(config) { for (let i=0; i