'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; module.exports = function(config, auth, storage) { /* eslint new-cap:off */ const app = express.Router(); const can = Middleware.allow(auth); const notify = Notify.notify; // 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('tag', validate_name); app.param('version', validate_name); app.param('revision', validate_name); app.param('token', validate_name); // these can't be safely put into express url for some reason app.param('_rev', match(/^-rev$/)); app.param('org_couchdb_user', match(/^org\.couchdb\.user:/)); app.param('anything', match(/.*/)); app.use(auth.basic_middleware()); // app.use(auth.bearer_middleware()) app.use(bodyParser.json({strict: false, limit: config.max_body_size || '10mb'})); 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(); }); // 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}); }); // 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); let version = req.params.version; if (!version) return next(info); let t = Utils.get_version(info, version); if (t != null) return next(t); 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); } } return next( Error[404]('version not found: ' + req.params.version) ); }); }); 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({}); }); return app; };