index.jsView |
---|
1 | 1 | var http = require('http') |
2 | 2 | var os = require('os') |
3 | 3 | var path = require('path') |
4 | 4 | var fs = require('fs') |
| 5 | +var crypto = require('crypto') |
| 6 | +var pkg = require('./package') |
5 | 7 | |
6 | 8 | function pullOnce(data) { |
7 | 9 | var ended |
8 | 10 | return function (abort, cb) { |
11 | 13 | cb(null, data) |
12 | 14 | } |
13 | 15 | } |
14 | 16 | |
| 17 | +function escapeHTML(str) { |
| 18 | + return String(str) |
| 19 | + .replace(/</g, '<') |
| 20 | + .replace(/>/g, '>') |
| 21 | +} |
| 22 | + |
| 23 | +function onceify(fn, self) { |
| 24 | + var cbs = [], err, data |
| 25 | + return function (cb) { |
| 26 | + if (fn) { |
| 27 | + cbs.push(cb) |
| 28 | + fn.call(self, function (_err, _data) { |
| 29 | + err = _err, data = _data |
| 30 | + var _cbs = cbs |
| 31 | + cbs = null |
| 32 | + while (_cbs.length) _cbs.shift()(err, data) |
| 33 | + }) |
| 34 | + fn = null |
| 35 | + } else if (cbs) { |
| 36 | + cbs.push(cb) |
| 37 | + } else { |
| 38 | + cb(err, data) |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +function pkgLockToRegistryPkgs(pkgLock, wsPort) { |
| 44 | + |
| 45 | + var hasNonBlobUrl = false |
| 46 | + var blobUrlRegex = new RegExp('^http://localhost:' + wsPort + '/blobs/get/&') |
| 47 | + var pkgs = {} |
| 48 | + var queue = [pkgLock, pkgLock.name] |
| 49 | + while (queue.length) { |
| 50 | + var dep = queue.shift(), name = queue.shift() |
| 51 | + if (name) { |
| 52 | + var pkg = pkgs[name] || (pkgs[name] = { |
| 53 | + _id: name, |
| 54 | + name: name, |
| 55 | + versions: {} |
| 56 | + }) |
| 57 | + if (dep.version && dep.integrity && dep.resolved) { |
| 58 | + if (!hasNonBlobUrl && !blobUrlRegex.test(dep.resolved)) hasNonBlobUrl = true |
| 59 | + pkg.versions[dep.version] = { |
| 60 | + dist: { |
| 61 | + integrity: dep.integrity, |
| 62 | + tarball: dep.resolved |
| 63 | + } |
| 64 | + } |
| 65 | + } |
| 66 | + } |
| 67 | + if (dep.dependencies) for (var depName in dep.dependencies) { |
| 68 | + queue.push(dep.dependencies[depName], depName) |
| 69 | + } |
| 70 | + } |
| 71 | + pkgs._hasNonBlobUrl = hasNonBlobUrl |
| 72 | + return pkgs |
| 73 | +} |
| 74 | + |
| 75 | +function npmLogin(registryAddress, cb) { |
| 76 | + var tokenLine = registryAddress.replace(/^http:/, '') + ':_authToken=1' |
| 77 | + var filename = path.join(os.homedir(), '.npmrc') |
| 78 | + fs.readFile(filename, 'utf8', function (err, data) { |
| 79 | + if (err && err.code === 'ENOENT') data = '' |
| 80 | + else if (err) return cb(new Error(err.stack)) |
| 81 | + var lines = data ? data.split('\n') : [] |
| 82 | + if (lines.indexOf(tokenLine) > -1) return cb() |
| 83 | + var trailingNewline = (lines.length === 0 || lines[lines.length-1] === '') |
| 84 | + var line = trailingNewline ? tokenLine + '\n' : '\n' + tokenLine |
| 85 | + fs.appendFile(filename, line, cb) |
| 86 | + }) |
| 87 | +} |
| 88 | + |
| 89 | +function formatHost(host) { |
| 90 | + return /^[^\[]:.*:.*:/.test(host) ? '[' + host + ']' : host |
| 91 | +} |
| 92 | + |
15 | 93 | exports.name = 'npm-registry' |
16 | 94 | exports.version = '1.0.0' |
17 | 95 | exports.manifest = { |
18 | 96 | getAddress: 'async' |
19 | 97 | } |
20 | 98 | exports.init = function (sbot, config) { |
21 | 99 | var port = config.npm ? config.npm.port : 8043 |
22 | | - var host = config.npm && config.npm.host || 'localhost' |
23 | | - var registryAddress |
| 100 | + var host = config.npm && config.npm.host || null |
| 101 | + var autoAuth = config.npm && config.npm.autoAuth !== false |
24 | 102 | var getAddressCbs = [] |
25 | 103 | |
26 | 104 | var server = http.createServer(exports.respond(sbot, config)) |
27 | | - server.listen(port, host, function () { |
28 | | - registryAddress = 'http://' + host + ':' + this.address().port + '/' |
29 | | - if (config.npm && config.npm.autoAuth !== false) login() |
30 | | - console.log('[npm-registry] Listening on ' + registryAddress) |
31 | | - while (getAddressCbs.length) getAddressCbs.shift()(null, registryAddress) |
| 105 | + var getAddress = onceify(function (cb) { |
| 106 | + server.on('error', cb) |
| 107 | + server.listen(port, host, function () { |
| 108 | + server.removeListener('error', cb) |
| 109 | + var regHost = formatHost(host || 'localhost') |
| 110 | + var regUrl = 'http://' + regHost + ':' + this.address().port + '/' |
| 111 | + if (autoAuth) npmLogin(regUrl, next) |
| 112 | + else next() |
| 113 | + function next(err) { |
| 114 | + cb(err, regUrl) |
| 115 | + } |
| 116 | + }) |
| 117 | + sbot.on('close', function () { |
| 118 | + server.close() |
| 119 | + }) |
32 | 120 | }) |
33 | | - sbot.on('close', function () { |
34 | | - server.close() |
| 121 | + |
| 122 | + getAddress(function (err, addr) { |
| 123 | + if (err) return console.error(err) |
| 124 | + console.log('[npm-registry] Listening on ' + addr) |
35 | 125 | }) |
36 | 126 | |
37 | | - function login() { |
38 | | - var filename = path.join(os.homedir(), '.npmrc') |
39 | | - var tokenLine = registryAddress.replace(/^http:/, '') + ':_authToken=1' |
40 | | - var lines = fs.readFileSync(filename, 'utf8').split('\n') |
41 | | - if (lines.indexOf(tokenLine) === -1) { |
42 | | - fs.appendFileSync(filename, (lines.pop() ? '' : '\n') + tokenLine) |
43 | | - } |
44 | | - } |
45 | | - |
46 | 127 | return { |
47 | | - getAddress: function (cb) { |
48 | | - if (registryAddress) cb(null, registryAddress) |
49 | | - else getAddressCbs.push(cb) |
50 | | - } |
| 128 | + getAddress: getAddress |
51 | 129 | } |
52 | 130 | } |
53 | 131 | |
54 | 132 | exports.respond = function (sbot, config) { |
107 | 185 | this.sbot = sbot |
108 | 186 | this.config = config |
109 | 187 | this.links2 = sbot.links2 |
110 | 188 | if (!this.links2) throw new Error('missing ssb-links2 scuttlebot plugin') |
| 189 | + this.wsPort = config.ws && Number(config.ws.port) || '8989' |
111 | 190 | this.blobsPrefix = 'http://' + (config.host || 'localhost') + ':' |
112 | | - + (config.ws && config.ws.port || '8989') + '/blobs/get/' |
| 191 | + + this.wsPort + '/blobs/get/' |
| 192 | + this.getBootstrapInfo = onceify(this.getBootstrapInfo, this) |
113 | 193 | } |
114 | 194 | |
115 | 195 | SsbNpmRegistryServer.prototype = Object.create(http.Server.prototype) |
116 | 196 | SsbNpmRegistryServer.prototype.constructor = SsbNpmRegistryServer |
126 | 206 | }) |
127 | 207 | })(0) |
128 | 208 | } |
129 | 209 | |
130 | | -SsbNpmRegistryServer.prototype.blobUrl = function (id) { |
131 | | - return this.blobsPrefix + id |
| 210 | +SsbNpmRegistryServer.prototype.blobDist = function (id) { |
| 211 | + var m = /^&([^.]+)\.([a-z0-9]+)$/.exec(id) |
| 212 | + if (!m) throw new Error('bad blob id: ' + id) |
| 213 | + return { |
| 214 | + integrity: m[2] + '-' + m[1], |
| 215 | + tarball: 'http://localhost:' + this.wsPort + '/blobs/get/' + id |
| 216 | + } |
132 | 217 | } |
133 | 218 | |
134 | 219 | SsbNpmRegistryServer.prototype.getMentions = function (name) { |
135 | 220 | return this.links2.read({ |
146 | 231 | ] |
147 | 232 | }) |
148 | 233 | } |
149 | 234 | |
| 235 | +SsbNpmRegistryServer.prototype.getBootstrapInfo = function (cb) { |
| 236 | + var self = this |
| 237 | + if (!self.sbot.bootstrap) return cb(new Error('missing sbot bootstrap plugin')) |
| 238 | + |
| 239 | + self.sbot.bootstrap.getPackageLock(function (err, sbotPkgLock) { |
| 240 | + if (err) return cb(new Error(err.stack || err)) |
| 241 | + var pkgs = pkgLockToRegistryPkgs(sbotPkgLock, self.wsPort) |
| 242 | + if (pkgs._hasNonBlobUrl) { |
| 243 | + console.error('[npm-registry] Warning: package-lock.json has non-blob URLs. Bootstrap installation may not be fully peer-to-peer.') |
| 244 | + } |
| 245 | + |
| 246 | + if (!sbotPkgLock.name) console.trace('missing pkg lock name') |
| 247 | + if (!sbotPkgLock.version) console.trace('missing pkg lock version') |
| 248 | + |
| 249 | + self.sbot.blobs.add(function (err, id) { |
| 250 | + if (err) return cb(new Error(err.stack || err)) |
| 251 | + var pkg = pkgs[sbotPkgLock.name] || (pkgs[sbotPkgLock.name] = {}) |
| 252 | + var versions = pkg.versions || (pkg.versions = {}) |
| 253 | + pkg.versions[sbotPkgLock.version] = { |
| 254 | + dist: self.blobDist(id) |
| 255 | + } |
| 256 | + var distTags = pkg['dist-tags'] || (pkg['dist-tags'] = {}) |
| 257 | + distTags.latest = sbotPkgLock.version |
| 258 | + next() |
| 259 | + })(self.sbot.bootstrap.pack()) |
| 260 | + |
| 261 | + function next() { |
| 262 | + fs.readFile(path.join(__dirname, 'bootstrap.js'), { |
| 263 | + encoding: 'utf8' |
| 264 | + }, function (err, bootstrapScript) { |
| 265 | + if (err) return cb(err) |
| 266 | + var script = bootstrapScript + '\n' + |
| 267 | + 'exports.pkgs = ' + JSON.stringify(pkgs, 0, 2) |
| 268 | + |
| 269 | + self.sbot.blobs.add(function (err, id) { |
| 270 | + if (err) return cb(new Error(err.stack || err)) |
| 271 | + var m = /^&([^.]+)\.([a-z0-9]+)$/.exec(id) |
| 272 | + if (!m) return cb(new Error('bad blob id: ' + id)) |
| 273 | + cb(null, { |
| 274 | + name: sbotPkgLock.name, |
| 275 | + blob: id, |
| 276 | + hashType: m[2], |
| 277 | + hashBuf: Buffer.from(m[1], 'base64'), |
| 278 | + }) |
| 279 | + })(pullOnce(script)) |
| 280 | + }) |
| 281 | + } |
| 282 | + }) |
| 283 | +} |
| 284 | + |
| 285 | +SsbNpmRegistryServer.prototype.getBootstrapScriptHash = function (cb) { |
| 286 | + var hasher = crypto.createHash('sha256') |
| 287 | + hasher.update(data) |
| 288 | + var hash = hasher.digest() |
| 289 | + getBootstrapScriptHash = function (cb) { |
| 290 | + return cb(null, hash) |
| 291 | + } |
| 292 | + getBootstrapScriptHash(cb) |
| 293 | +} |
| 294 | + |
150 | 295 | function Req(server, req, res) { |
151 | 296 | this.server = server |
152 | 297 | this.req = req |
153 | 298 | this.res = res |
154 | 299 | this.blobsToPush = [] |
155 | 300 | } |
156 | 301 | |
157 | 302 | Req.prototype.serve = function () { |
158 | | - |
| 303 | + console.log(this.req.method, this.req.url, this.req.socket.remoteAddress) |
159 | 304 | var pathname = this.req.url.replace(/\?.*/, '') |
| 305 | + if (pathname === '/') return this.serveHome() |
| 306 | + if (pathname === '/bootstrap') return this.serveBootstrap() |
160 | 307 | if (pathname === '/-/whoami') return this.serveWhoami() |
161 | 308 | if (pathname === '/-/ping') return this.respond(200, true) |
162 | 309 | if (pathname === '/-/user/org.couchdb.user:1') return this.serveUser1() |
163 | 310 | if (!/^\/-\//.test(pathname)) return this.servePkg(pathname.substr(1)) |
172 | 319 | Req.prototype.respondError = function (status, message) { |
173 | 320 | this.respond(status, {error: message}) |
174 | 321 | } |
175 | 322 | |
| 323 | +var bootstrapName = 'ssb-npm-bootstrap' |
| 324 | + |
| 325 | +Req.prototype.serveHome = function () { |
| 326 | + var self = this |
| 327 | + self.res.writeHead(200, {'content-type': 'text/html'}) |
| 328 | + var port = 8044 |
| 329 | + self.res.end('<!doctype html><html><head><meta charset=utf-8>' + |
| 330 | + '<title>' + escapeHTML(pkg.name) + '</title></head><body>' + |
| 331 | + '<h1>' + escapeHTML(pkg.name) + '</h1>\n' + |
| 332 | + '<p><a href="/bootstrap">Bootstrap</a></p>\n' + |
| 333 | + '</body></html>') |
| 334 | +} |
| 335 | + |
| 336 | +Req.prototype.serveBootstrap = function () { |
| 337 | + var self = this |
| 338 | + self.server.getBootstrapInfo(function (err, info) { |
| 339 | + if (err) return this.respondError(err.stack || err) |
| 340 | + var pkgNameText = info.name |
| 341 | + var pkgTmpText = '/tmp/' + bootstrapName + '.js' |
| 342 | + var host = String(self.req.headers.host).replace(/:[0-9]*$/, '') || self.req.socket.localAddress |
| 343 | + var httpHost = /^[^\[]:.*:.*:/.test(host) ? '[' + host + ']' : host |
| 344 | + var blobsHostname = httpHost + ':' + self.server.wsPort |
| 345 | + var tarballLink = 'http://' + blobsHostname + '/blobs/get/' + info.blob |
| 346 | + var pkgHashText = info.hashBuf.toString('hex') |
| 347 | + var hashCmd = info.hashType + 'sum' |
| 348 | + |
| 349 | + var script = |
| 350 | + 'wget \'' + tarballLink + '\' -O ' + pkgTmpText + ' &&\n' + |
| 351 | + 'echo ' + pkgHashText + ' ' + pkgTmpText + ' | ' + hashCmd + ' -c &&\n' + |
| 352 | + 'node ' + pkgTmpText + ' --blobs-remote ' + blobsHostname + ' -- ' + |
| 353 | + 'npm install -g ' + info.name + ' &&\n' + |
| 354 | + 'sbot server' |
| 355 | + |
| 356 | + self.res.writeHead(200, {'content-type': 'text/plain'}) |
| 357 | + self.res.end(script) |
| 358 | + }) |
| 359 | +} |
| 360 | + |
176 | 361 | Req.prototype.serveWhoami = function () { |
177 | 362 | var self = this |
178 | 363 | self.server.sbot.whoami(function (err, feed) { |
179 | 364 | if (err) return self.respondError(err.stack || err) |
213 | 398 | if (err === true) return self.respond(200, obj) |
214 | 399 | if (err) return self.respondError(500, err.stack || err) |
215 | 400 | var data = decodeName(mention.name) |
216 | 401 | if (!data.version) return |
217 | | - var m = /^&([^.]+)\.([a-z0-9]+)$/.exec(mention.link) |
218 | | - if (!m) return |
219 | 402 | if (data.distTag) obj['dist-tags'][data.distTag] = data.version |
220 | 403 | obj.versions[data.version] = { |
221 | 404 | author: { |
222 | 405 | url: mention.author |
223 | 406 | }, |
224 | | - dist: { |
225 | | - integrity: m[2] + '-' + m[1], |
226 | | - tarball: self.server.blobUrl(mention.link) |
227 | | - } |
| 407 | + dist: self.server.blobDist(mention.link) |
228 | 408 | } |
229 | 409 | getMention(null, next) |
230 | 410 | }) |
231 | 411 | } |
232 | 412 | |
| 413 | +var localhosts = { |
| 414 | + '::1': true, |
| 415 | + '127.0.0.1': true, |
| 416 | + '::ffff:127.0.0.1': true, |
| 417 | +} |
| 418 | + |
233 | 419 | Req.prototype.publishPkg = function (pkgName) { |
| 420 | + var self = this |
| 421 | + var remoteAddress = self.req.socket.remoteAddress |
| 422 | + if (!(remoteAddress in localhosts)) { |
| 423 | + return self.respondError(403, 'You may not publish as this user.') |
| 424 | + } |
| 425 | + |
234 | 426 | var chunks = [] |
235 | | - var self = this |
236 | 427 | self.req.on('data', function (data) { |
237 | 428 | chunks.push(data) |
238 | 429 | }) |
239 | 430 | self.req.on('end', function () { |
248 | 439 | } |
249 | 440 | |
250 | 441 | Req.prototype.publishPkg2 = function (name, data) { |
251 | 442 | var self = this |
252 | | - if (data.users) console.trace('[npm-registry users property is not supported') |
| 443 | + if (data.users) console.trace('[npm-registry] users property is not supported') |
253 | 444 | var attachments = data._attachments || {} |
254 | 445 | var links = {} |
255 | 446 | var waiting = 0 |
256 | 447 | Object.keys(attachments).forEach(function (filename) { |