git ssb

3+

cel / ssb-npm-registry



Tree: 36918ca7e12ec5fbe6f1a05db3564ced92e04e6c

Files: 36918ca7e12ec5fbe6f1a05db3564ced92e04e6c / index.js

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

Built with git-ssb-web