git ssb

3+

cel / ssb-npm-registry



Tree: 0d073639080ba4eb082eaa6bd4fa4bd415ed2525

Files: 0d073639080ba4eb082eaa6bd4fa4bd415ed2525 / index.js

21355 bytesRaw
1var http = require('http')
2var os = require('os')
3var path = require('path')
4var fs = require('fs')
5var crypto = require('crypto')
6var pkg = require('./package')
7var semver = require('semver')
8var toPull = require('stream-to-pull-stream')
9var proc = require('child_process')
10var pull = require('pull-stream')
11
12function pullOnce(data) {
13 var ended
14 return function (abort, cb) {
15 if (ended || (ended = abort)) return cb(ended)
16 ended = true
17 cb(null, data)
18 }
19}
20
21function escapeHTML(str) {
22 return String(str)
23 .replace(/</g, '&lt;')
24 .replace(/>/g, '&gt;')
25}
26
27function onceify(fn, self) {
28 var cbs = [], err, data
29 return function (cb) {
30 if (fn) {
31 cbs.push(cb)
32 fn.call(self, function (_err, _data) {
33 err = _err, data = _data
34 var _cbs = cbs
35 cbs = null
36 while (_cbs.length) _cbs.shift()(err, data)
37 })
38 fn = null
39 } else if (cbs) {
40 cbs.push(cb)
41 } else {
42 cb(err, data)
43 }
44 }
45}
46
47function once(cb) {
48 var done
49 return function (err, result) {
50 if (done) {
51 if (err) console.trace(err)
52 } else {
53 done = true
54 cb(err, result)
55 }
56 }
57}
58
59function pkgLockToRegistryPkgs(pkgLock, wsPort) {
60 // convert a package-lock.json file into data for serving as an npm registry
61 var hasNonBlobUrl = false
62 var blobUrlRegex = new RegExp('^http://localhost:' + wsPort + '/blobs/get/&')
63 var pkgs = {}
64 var queue = [pkgLock, pkgLock.name]
65 while (queue.length) {
66 var dep = queue.shift(), name = queue.shift()
67 if (name) {
68 var pkg = pkgs[name] || (pkgs[name] = {
69 _id: name,
70 name: name,
71 versions: {}
72 })
73 if (dep.version && dep.integrity && dep.resolved) {
74 if (!hasNonBlobUrl && !blobUrlRegex.test(dep.resolved)) hasNonBlobUrl = true
75 pkg.versions[dep.version] = {
76 dist: {
77 integrity: dep.integrity,
78 tarball: dep.resolved
79 }
80 }
81 }
82 }
83 if (dep.dependencies) for (var depName in dep.dependencies) {
84 queue.push(dep.dependencies[depName], depName)
85 }
86 }
87 pkgs._hasNonBlobUrl = hasNonBlobUrl
88 return pkgs
89}
90
91function npmLogin(registryAddress, cb) {
92 var tokenLine = registryAddress.replace(/^http:/, '') + ':_authToken=1'
93 var filename = path.join(os.homedir(), '.npmrc')
94 fs.readFile(filename, 'utf8', function (err, data) {
95 if (err && err.code === 'ENOENT') data = ''
96 else if (err) return cb(new Error(err.stack))
97 var lines = data ? data.split('\n') : []
98 if (lines.indexOf(tokenLine) > -1) return cb()
99 var trailingNewline = (lines.length === 0 || lines[lines.length-1] === '')
100 var line = trailingNewline ? tokenLine + '\n' : '\n' + tokenLine
101 fs.appendFile(filename, line, cb)
102 })
103}
104
105function formatHost(host) {
106 return /^[^\[]:.*:.*:/.test(host) ? '[' + host + ']' : host
107}
108
109exports.name = 'npm-registry'
110exports.version = '1.0.0'
111exports.manifest = {
112 getAddress: 'async'
113}
114exports.init = function (sbot, config) {
115 var port = config.npm ? config.npm.port : 8043
116 var host = config.npm && config.npm.host || null
117 var autoAuth = config.npm && config.npm.autoAuth !== false
118
119 var server = http.createServer(exports.respond(sbot, config))
120 var getAddress = onceify(function (cb) {
121 server.on('error', cb)
122 server.listen(port, host, function () {
123 server.removeListener('error', cb)
124 var regHost = formatHost(host || 'localhost')
125 var regPort = this.address().port
126 var regUrl = 'http://' + regHost + ':' + regPort + '/'
127 if (autoAuth) npmLogin(regUrl, next)
128 else next()
129 function next(err) {
130 cb(err, regUrl)
131 }
132 })
133 sbot.on('close', function () {
134 server.close()
135 })
136 })
137
138 getAddress(function (err, addr) {
139 if (err) return console.error(err)
140 console.log('[npm-registry] Listening on ' + addr)
141 })
142
143 return {
144 getAddress: getAddress
145 }
146}
147
148exports.respond = function (sbot, config) {
149 var reg = new SsbNpmRegistryServer(sbot, config)
150 return function (req, res) {
151 new Req(reg, req, res).serve()
152 }
153}
154
155function publishMsg(sbot, value, cb) {
156 var gotExpectedPrevious = false
157 sbot.publish(value, function next(err, msg) {
158 if (err && /^expected previous:/.test(err.message)) {
159 // retry once on this error
160 if (gotExpectedPrevious) return cb(err)
161 gotExpectedPrevious = true
162 return sbot.publish(value, next)
163 }
164 cb(err, msg)
165 })
166}
167
168function publishMentions(sbot, mentions, cb) {
169 // console.error("publishing %s mentions", mentions.length)
170 if (mentions.length === 0) return cb(new Error('Empty mentions list'))
171 publishMsg(sbot, {
172 type: 'npm-packages',
173 mentions: mentions,
174 }, cb)
175}
176
177exports.publishPkgMentions = function (sbot, mentions, cb) {
178 // try to fit the mentions into as few messages as possible,
179 // while fitting under the message size limit.
180 var msgs = []
181 ;(function next(i, chunks) {
182 if (i >= mentions.length) return cb(null, msgs)
183 var chunkLen = Math.ceil(mentions.length / chunks)
184 publishMentions(sbot, mentions.slice(i, i + chunkLen), function (err, msg) {
185 if (err && /must not be large/.test(err.message)) return next(i, chunks + 1)
186 if (err && msgs.length) return onPartialPublish(err)
187 if (err) return cb(err)
188 msgs.push(msg)
189 next(i + chunkLen, chunks)
190 })
191 })(0, 1)
192 function onPartialPublish(err) {
193 var remaining = mentions.length - i
194 return cb(new Error('Published messages ' +
195 msgs.map(function (msg) { return msg.key }).join(', ') + ' ' +
196 'but failed to publish remaining ' + remaining + ': ' + (err.stack || err)))
197 }
198}
199
200function SsbNpmRegistryServer(sbot, config) {
201 this.sbot = sbot
202 this.config = config
203 this.npmConfig = config.npm || {}
204 this.host = this.npmConfig.host || 'localhost'
205 this.links2 = sbot.links2
206 if (!this.links2) throw new Error('missing ssb-links2 scuttlebot plugin')
207 this.wsPort = config.ws && Number(config.ws.port) || '8989'
208 this.blobsPrefix = 'http://' + (config.host || 'localhost') + ':'
209 + this.wsPort + '/blobs/get/'
210 this.getBootstrapInfo = onceify(this.getBootstrapInfo, this)
211}
212
213SsbNpmRegistryServer.prototype = Object.create(http.Server.prototype)
214SsbNpmRegistryServer.prototype.constructor = SsbNpmRegistryServer
215
216SsbNpmRegistryServer.prototype.pushBlobs = function (ids, cb) {
217 var self = this
218 if (!self.sbot.blobs.push) return cb(new Error('missing blobs.push'))
219 ;(function next(i) {
220 if (i >= ids.length) return cb()
221 self.sbot.blobs.push(ids[i], function (err) {
222 if (err) return cb(err)
223 next(i+1)
224 })
225 })(0)
226}
227
228SsbNpmRegistryServer.prototype.getBlob = function (id, cb) {
229 var blobs = this.sbot.blobs
230 blobs.size(id, function (err, size) {
231 if (typeof size === 'number') cb(null, blobs.get(id))
232 else blobs.want(id, function (err, got) {
233 if (err) cb(err)
234 else if (!got) cb('missing blob ' + id)
235 else cb(null, blobs.get(id))
236 })
237 })
238}
239
240SsbNpmRegistryServer.prototype.blobDist = function (id) {
241 var m = /^&([^.]+)\.([a-z0-9]+)$/.exec(id)
242 if (!m) throw new Error('bad blob id: ' + id)
243 return {
244 ssbBlobId: id,
245 integrity: m[2] + '-' + m[1],
246 tarball: 'http://localhost:' + this.wsPort + '/blobs/get/' + id
247 }
248}
249
250SsbNpmRegistryServer.prototype.getMentions = function (name) {
251 return this.links2.read({
252 query: [
253 {$filter: {rel: ['mentions', name, {$gt: true}]}},
254 {$filter: {dest: {$prefix: '&'}}},
255 {$map: {
256 name: ['rel', 1],
257 size: ['rel', 2],
258 link: 'dest',
259 author: 'source',
260 ts: 'ts'
261 }}
262 ]
263 })
264}
265
266SsbNpmRegistryServer.prototype.getLocalPrebuildsLinks = function (cb) {
267 var self = this
268 var prebuildsDir = path.join(os.homedir(), '.npm', '_prebuilds')
269 var ids = {}
270 var nameRegex = new RegExp('^http-' + self.host.replace(/\./g, '.') + '-(?:[0-9]+)-prebuild-(.*)$')
271 fs.readdir(prebuildsDir, function (err, filenames) {
272 if (err) return cb(new Error(err.stack || err))
273 ;(function next(i) {
274 if (i >= filenames.length) return cb(null, ids)
275 var m = nameRegex.exec(filenames[i])
276 if (!m) return next(i+1)
277 var name = m[1]
278 fs.readFile(path.join(prebuildsDir, filenames[i]), function (err, data) {
279 if (err) return cb(new Error(err.stack || err))
280 self.sbot.blobs.add(function (err, id) {
281 if (err) return cb(new Error(err.stack || err))
282 ids[name] = id
283 next(i+1)
284 })(pullOnce(data))
285 })
286 })(0)
287 })
288}
289
290SsbNpmRegistryServer.prototype.getBootstrapInfo = function (cb) {
291 var self = this
292 if (!self.sbot.bootstrap) return cb(new Error('missing sbot bootstrap plugin'))
293
294 self.sbot.bootstrap.getPackageLock(function (err, sbotPkgLock) {
295 if (err) return cb(new Error(err.stack || err))
296 var pkgs = pkgLockToRegistryPkgs(sbotPkgLock, self.wsPort)
297 if (pkgs._hasNonBlobUrl) {
298 console.error('[npm-registry] Warning: package-lock.json has non-blob URLs. Bootstrap installation may not be fully peer-to-peer.')
299 }
300
301 if (!sbotPkgLock.name) console.trace('missing pkg lock name')
302 if (!sbotPkgLock.version) console.trace('missing pkg lock version')
303
304 var waiting = 2
305
306 self.sbot.blobs.add(function (err, id) {
307 if (err) return next(new Error(err.stack || err))
308 var pkg = pkgs[sbotPkgLock.name] || (pkgs[sbotPkgLock.name] = {})
309 var versions = pkg.versions || (pkg.versions = {})
310 pkg.versions[sbotPkgLock.version] = {
311 dist: self.blobDist(id)
312 }
313 var distTags = pkg['dist-tags'] || (pkg['dist-tags'] = {})
314 distTags.latest = sbotPkgLock.version
315 next()
316 })(self.sbot.bootstrap.pack())
317
318 var prebuilds
319 self.getLocalPrebuildsLinks(function (err, _prebuilds) {
320 if (err) return next(err)
321 prebuilds = _prebuilds
322 next()
323 })
324
325 function next(err) {
326 if (err) return waiting = 0, cb(err)
327 if (--waiting) return
328 fs.readFile(path.join(__dirname, 'bootstrap.js'), {
329 encoding: 'utf8'
330 }, function (err, bootstrapScript) {
331 if (err) return cb(err)
332 var script = bootstrapScript + '\n' +
333 'exports.pkgs = ' + JSON.stringify(pkgs, 0, 2) + '\n' +
334 'exports.prebuilds = ' + JSON.stringify(prebuilds, 0, 2)
335
336 self.sbot.blobs.add(function (err, id) {
337 if (err) return cb(new Error(err.stack || err))
338 var m = /^&([^.]+)\.([a-z0-9]+)$/.exec(id)
339 if (!m) return cb(new Error('bad blob id: ' + id))
340 cb(null, {
341 name: sbotPkgLock.name,
342 blob: id,
343 hashType: m[2],
344 hashBuf: Buffer.from(m[1], 'base64'),
345 })
346 })(pullOnce(script))
347 })
348 }
349 })
350}
351
352function Req(server, req, res) {
353 this.server = server
354 this.req = req
355 this.res = res
356 this.blobsToPush = []
357}
358
359Req.prototype.serve = function () {
360 console.log(this.req.method, this.req.url, this.req.socket.remoteAddress.replace(/^::ffff:/, ''))
361 var pathname = this.req.url.replace(/\?.*/, '')
362 var m
363 if (pathname === '/') return this.serveHome()
364 if (pathname === '/bootstrap') return this.serveBootstrap()
365 if (pathname === '/-/whoami') return this.serveWhoami()
366 if (pathname === '/-/ping') return this.respond(200, true)
367 if (pathname === '/-/user/org.couchdb.user:1') return this.serveUser1()
368 if ((m = /^\/-\/prebuild\/(.*)$/.exec(pathname))) return this.servePrebuild(m[1])
369 if (!/^\/-\//.test(pathname)) return this.servePkg(pathname.substr(1))
370 return this.respond(404)
371}
372
373Req.prototype.respond = function (status, message) {
374 this.res.writeHead(status, {'content-type': 'application/json'})
375 this.res.end(message && JSON.stringify(message, 0, 2))
376}
377
378Req.prototype.respondError = function (status, message) {
379 this.respond(status, {error: message})
380}
381
382var bootstrapName = 'ssb-npm-bootstrap'
383
384Req.prototype.serveHome = function () {
385 var self = this
386 self.res.writeHead(200, {'content-type': 'text/html'})
387 var port = 8044
388 self.res.end('<!doctype html><html><head><meta charset=utf-8>' +
389 '<title>' + escapeHTML(pkg.name) + '</title></head><body>' +
390 '<h1>' + escapeHTML(pkg.name) + '</h1>\n' +
391 '<p><a href="/bootstrap">Bootstrap</a></p>\n' +
392 '</body></html>')
393}
394
395Req.prototype.serveBootstrap = function () {
396 var self = this
397 self.server.getBootstrapInfo(function (err, info) {
398 if (err) return this.respondError(err.stack || err)
399 var pkgNameText = info.name
400 var pkgTmpText = '/tmp/' + bootstrapName + '.js'
401 var host = String(self.req.headers.host).replace(/:[0-9]*$/, '') || self.req.socket.localAddress
402 var httpHost = /^[^\[]:.*:.*:/.test(host) ? '[' + host + ']' : host
403 var blobsHostname = httpHost + ':' + self.server.wsPort
404 var tarballLink = 'http://' + blobsHostname + '/blobs/get/' + info.blob
405 var pkgHashText = info.hashBuf.toString('hex')
406 var hashCmd = info.hashType + 'sum'
407
408 var script =
409 'wget \'' + tarballLink + '\' -O ' + pkgTmpText + ' &&\n' +
410 'echo ' + pkgHashText + ' ' + pkgTmpText + ' | ' + hashCmd + ' -c &&\n' +
411 'node ' + pkgTmpText + ' --blobs-remote ' + blobsHostname + ' -- ' +
412 'npm install -g ' + info.name + ' &&\n' +
413 'sbot server'
414
415 self.res.writeHead(200, {'content-type': 'text/plain'})
416 self.res.end(script)
417 })
418}
419
420Req.prototype.serveWhoami = function () {
421 var self = this
422 self.server.sbot.whoami(function (err, feed) {
423 if (err) return self.respondError(err.stack || err)
424 self.respond(200, {username: feed.id})
425 })
426}
427
428Req.prototype.serveUser1 = function () {
429 this.respond(this.req.method === 'PUT' ? 201 : 200, {token: '1'})
430}
431
432function decodeName(name) {
433 var parts = name.replace(/\.tgz$/, '').split(':')
434 return {
435 name: parts[1],
436 version: parts[2],
437 distTag: parts[3],
438 }
439}
440
441Req.prototype.servePkg = function (pathname) {
442 var self = this
443 var parts = pathname.split('/')
444 var pkgName = parts.shift()
445 if (parts[0] === '-rev') return this.respondError(501, 'Unpublish is not supported')
446 var spec = parts.shift()
447 if (spec) try { spec = decodeURIComponent(spec) } finally {}
448 if (parts.length > 0) return this.respondError(404)
449 if (self.req.method === 'PUT') return self.publishPkg(pkgName)
450 var obj = {
451 _id: pkgName,
452 name: pkgName,
453 'dist-tags': {},
454 versions: {}
455 }
456 var oldest, newest
457 var getMention = self.server.getMentions({$prefix: 'npm:' + pkgName + ':'})
458 getMention(null, function next(err, mention) {
459 if (err) return done(err === true ? null : err)
460 var data = decodeName(mention.name)
461 if (!data.version) return
462 if (data.distTag) obj['dist-tags'][data.distTag] = data.version
463 obj.versions[data.version] = {
464 author: {
465 url: mention.author
466 },
467 dist: self.server.blobDist(mention.link)
468 }
469 getMention(null, next)
470 })
471 function done(err) {
472 if (err) return self.respondError(500, err.stack || err)
473 if (!spec) self.respond(200, obj)
474 var version = obj['dist-tags'][spec]
475 || semver.maxSatisfying(Object.keys(obj.versions), spec)
476 obj = obj.versions[version]
477 if (!obj) return self.respondError(404, 'version not found: ' + spec)
478 // get dependency info, for dep(1)
479 self.getPackageJsonFromTarballBlob(obj.dist.ssbBlobId, function (err, pkg) {
480 if (err) return self.respondError(500, err.stack || err)
481 if (pkg) {
482 pkg.dist = obj.dist
483 if (!pkg.author) pkg.author = obj.author
484 obj = pkg
485 }
486 self.respond(200, obj)
487 })
488 }
489}
490
491Req.prototype.servePrebuild = function (name) {
492 var self = this
493 var getMention = self.server.getMentions('prebuild:' + name)
494 var blobsByAuthor = {/* <author>: BlobId */}
495 getMention(null, function next(err, link) {
496 if (err === true) return done()
497 if (err) return self.respondError(500, err.stack || err)
498 blobsByAuthor[link.author] = link.link
499 getMention(null, next)
500 })
501 function done() {
502 var authorsByLink = {/* <BlobId>: [FeedId...] */}
503 var blobId
504 for (var feed in blobsByAuthor) {
505 var blob = blobId = blobsByAuthor[feed]
506 var feeds = authorsByLink[blob] || (authorsByLink[blob] = [])
507 feeds.push(feed)
508 }
509 switch (Object.keys(authorsByLink).length) {
510 case 0:
511 return self.respondError(404, 'Not Found')
512 case 1:
513 self.res.writeHead(303, {Location: self.server.blobsPrefix + blobId})
514 return self.res.end()
515 default:
516 return self.respond(300, {choices: authorsByLink})
517 }
518 }
519}
520
521var localhosts = {
522 '::1': true,
523 '127.0.0.1': true,
524 '::ffff:127.0.0.1': true,
525}
526
527Req.prototype.publishPkg = function (pkgName) {
528 var self = this
529 var remoteAddress = self.req.socket.remoteAddress
530 if (!(remoteAddress in localhosts)) {
531 return self.respondError(403, 'You may not publish as this user.')
532 }
533
534 var chunks = []
535 self.req.on('data', function (data) {
536 chunks.push(data)
537 })
538 self.req.on('end', function () {
539 var data
540 try {
541 data = JSON.parse(Buffer.concat(chunks))
542 } catch(e) {
543 return self.respondError(400, e.stack)
544 }
545 return self.publishPkg2(pkgName, data || {})
546 })
547}
548
549Req.prototype.publishPkg2 = function (name, data) {
550 var self = this
551 if (data.users) console.trace('[npm-registry] users property is not supported')
552 var attachments = data._attachments || {}
553 var links = {/* <name>-<version>.tgz: {link: <BlobId>, size: number} */}
554 var waiting = 0
555 Object.keys(attachments).forEach(function (filename) {
556 waiting++
557 var tarball = new Buffer(attachments[filename].data, 'base64')
558 var length = attachments[filename].length
559 if (length && length !== tarball.length) return self.respondError(400,
560 'Length mismatch for attachment \'' + filename + '\'')
561 self.server.sbot.blobs.add(function (err, id) {
562 if (err) return self.respondError(500,
563 'Adding attachment \'' + filename + '\' as blob failed')
564 self.blobsToPush.push(id)
565 links[filename] = {link: id, size: tarball.length}
566 if (!--waiting) next()
567 })(pullOnce(tarball))
568 })
569 function next() {
570 try {
571 self.publishPkg3(name, data, links)
572 } catch(e) {
573 self.respondError(500, e.stack || e)
574 }
575 }
576}
577
578Req.prototype.publishPkg3 = function (name, data, links) {
579 var self = this
580 var versions = data.versions || {}
581 var linksByVersion = {/* <version>: link */}
582
583 // associate tarball blobs with versions
584 for (var version in versions) {
585 var pkg = versions[version]
586 if (!pkg) return self.respondError(400, 'Bad package object')
587 if (!pkg.dist) return self.respondError(400, 'Missing package dist property')
588 if (!pkg.dist.tarball) return self.respondError(400, 'Missing dist.tarball property')
589 if (pkg.deprecated) return self.respondError(501, 'Deprecation is not supported')
590 var m = /\/-\/([^\/]+)$/.exec(pkg.dist.tarball)
591 if (!m) return self.respondError(400, 'Bad tarball URL \'' + pkg.dist.tarball + '\'')
592 var filename = m[1]
593 var link = links[filename]
594 if (!link) return self.respondError(501, 'Unable to find attachment \'' + filename + '\'')
595 // TODO?: try to find missing tarball mentioned in other messages
596 if (pkg.version && pkg.version !== version)
597 return self.respondError(400, 'Mismatched package version: ' + [pkg.version, version])
598 linksByVersion[version] = link
599 link.version = version
600 }
601
602 // associate blobs with dist-tags
603 var tags = data['dist-tags'] || {}
604 for (var tag in tags) {
605 var version = tags[tag]
606 var link = linksByVersion[version]
607 if (!link) return self.respondError(501, 'Setting a dist-tag for a version not being published is not supported.')
608 // TODO?: support setting dist-tag without version,
609 // by looking up a tarball blob for the version
610 link.tag = tag
611 }
612
613 // compute blob links to publish
614 var mentions = []
615 for (var filename in links) {
616 var link = links[filename] || {}
617 if (!link.version) return self.respondError(400, 'Attachment ' + filename + ' was not linked to in the package metadata')
618 mentions.push({
619 name: 'npm:' + name + ':' + link.version + (link.tag ? ':' + link.tag : ''),
620 link: link.link,
621 size: link.size,
622 })
623 }
624 return self.publishPkgs(mentions)
625}
626
627Req.prototype.publishPkgs = function (mentions) {
628 var self = this
629 exports.publishPkgMentions(self.server.sbot, mentions, function (err, msgs) {
630 if (err) self.respondError(500, err.stack || err)
631 self.server.pushBlobs(self.blobsToPush, function (err) {
632 if (err) console.error('[npm-registry] Failed to push blob ' + id + ': ' + (err.stack || err))
633 self.respond(201)
634 console.log(msgs.map(function (msg) { return msg.key }).join('\n'))
635 })
636 })
637}
638
639Req.prototype.getPackageJsonFromTarballBlob = function (id, cb) {
640 var self = this
641 self.server.getBlob(id, function (err, readBlob) {
642 if (err) return cb(err)
643 cb = once(cb)
644 var tar = proc.spawn('tar', ['-zxO', 'package/package.json'], {
645 stdio: ['pipe', 'pipe', 'ignore']
646 })
647 tar.on('error', cb)
648 tar.on('exit', function (code) {
649 if (code) return cb(new Error('tar error: ' + code))
650 })
651 pull(readBlob, toPull(tar.stdin))
652 pull(toPull(tar.stdout), pull.collect(function (err, bufs) {
653 if (err) return cb(err)
654 var pkg
655 try { pkg = JSON.parse(Buffer.concat(bufs)) }
656 catch(e) { return cb(e) }
657 cb(null, pkg)
658 }))
659 })
660}
661

Built with git-ssb-web