git ssb

30+

cel / git-ssb-web



Tree: 4f5323ab2d208ccdc1bd0837f332f0f560bfcecf

Files: 4f5323ab2d208ccdc1bd0837f332f0f560bfcecf / index.js

35800 bytesRaw
1var fs = require('fs')
2var http = require('http')
3var path = require('path')
4var url = require('url')
5var qs = require('querystring')
6var proc = require('child_process')
7var util = require('util')
8var ref = require('ssb-ref')
9var pull = require('pull-stream')
10var ssbGit = require('ssb-git-repo')
11var toPull = require('stream-to-pull-stream')
12var cat = require('pull-cat')
13var GitRepo = require('pull-git-repo')
14var u = require('./lib/util')
15var markdown = require('./lib/markdown')
16var paginate = require('pull-paginate')
17var asyncMemo = require('asyncmemo')
18var multicb = require('multicb')
19var schemas = require('ssb-msg-schemas')
20var Issues = require('ssb-issues')
21var PullRequests = require('ssb-pull-requests')
22var paramap = require('pull-paramap')
23var Mentions = require('ssb-mentions')
24var many = require('pull-many')
25var ident = require('pull-identify-filetype')
26var mime = require('mime-types')
27var moment = require('moment')
28var LRUCache = require('hashlru')
29
30var hlCssPath = path.resolve(require.resolve('highlight.js'), '../../styles')
31
32function ParamError(msg) {
33 var err = Error.call(this, msg)
34 err.name = ParamError.name
35 return err
36}
37util.inherits(ParamError, Error)
38
39function parseAddr(str, def) {
40 if (!str) return def
41 var i = str.lastIndexOf(':')
42 if (~i) return {host: str.substr(0, i), port: Number(str.substr(i+1))}
43 if (isNaN(str)) return {host: str, port: def.port}
44 return {host: def.host, port: Number(str)}
45}
46
47function tryDecodeURIComponent(str) {
48 if (!str || (str[0] == '%' && ref.isBlobId(str)))
49 return str
50 try {
51 str = decodeURIComponent(str)
52 } finally {
53 return str
54 }
55}
56
57function getContentType(filename) {
58 var ext = u.getExtension(filename)
59 return contentTypes[ext] || u.imgMimes[ext] || 'text/plain; charset=utf-8'
60}
61
62var contentTypes = {
63 css: 'text/css'
64}
65
66function readReqForm(req, cb) {
67 pull(
68 toPull(req),
69 pull.collect(function (err, bufs) {
70 if (err) return cb(err)
71 var data
72 try {
73 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
74 } catch(e) {
75 return cb(e)
76 }
77 cb(null, data)
78 })
79 )
80}
81
82var msgTypes = {
83 'git-repo': true,
84 'git-update': true,
85 'issue': true,
86 'pull-request': true
87}
88
89module.exports = {
90 name: 'git-ssb-web',
91 version: require('./package').version,
92 manifest: {},
93 init: function (ssb, config) {
94 var web = new GitSSBWeb(ssb, config)
95 return {}
96 }
97}
98
99function GitSSBWeb(ssb, config) {
100 this.ssb = ssb
101 this.config = config
102
103 if (config.logging && config.logging.level)
104 this.logLevel = this.logLevels.indexOf(config.logging.level)
105 this.ssbAppname = config.appname || 'ssb'
106 this.isPublic = config.public
107 this.getVotes = require('./lib/votes')(ssb)
108 this.getMsg = asyncMemo({cache: new LRUCache(100)}, this.getMsgRaw)
109 this.issues = Issues.init(ssb)
110 this.pullReqs = PullRequests.init(ssb)
111 this.getRepo = asyncMemo({
112 cache: new LRUCache(32)
113 }, function (id, cb) {
114 if (id[0] === '#') return ssbGit.getRepo(ssb, id, {live: true}, cb)
115 this.getMsg(id, function (err, msg) {
116 if (err) return cb(err)
117 if (msg.private && this.isPublic) return cb(new Error('Private Repo'))
118 ssbGit.getRepo(ssb, msg, {live: true}, cb)
119 })
120 })
121
122 this.about = function (id, cb) { cb(null, {name: id}) }
123 ssb.whoami(function (err, feed) {
124 this.myId = feed.id
125 this.about = require('./lib/about')(ssb, this.myId)
126 }.bind(this))
127
128 this.i18n = require('./lib/i18n')(path.join(__dirname, 'locale'), 'en')
129 this.users = require('./lib/users')(this)
130 this.repos = require('./lib/repos')(this)
131
132 var webConfig = config['git-ssb-web'] || {}
133
134 if (webConfig.computeIssueCounts) {
135 this.indexCache = require('./lib/index-cache')(ssb)
136 }
137
138 this.serveAcmeChallenge = require('./lib/acme-challenge')(ssb)
139
140 var addr = parseAddr(config.listenAddr, {
141 host: webConfig.host || 'localhost',
142 port: Number(webConfig.port) || 7718
143 })
144 this.listenHost = addr.host
145 this.listenPort = addr.port
146 this.listen(addr.host, addr.port)
147
148 this.monitorSsbClient()
149}
150
151var G = GitSSBWeb.prototype
152
153G.logLevels = ['error', 'warning', 'notice', 'info']
154G.logLevel = G.logLevels.indexOf('notice')
155
156G.log = function (level) {
157 if (this.logLevels.indexOf(level) > this.logLevel) return
158 console.log.apply(console, [].slice.call(arguments, 1))
159}
160
161G.listen = function (host, port) {
162 this.httpServer = http.createServer(G_onRequest.bind(this))
163 this.httpServer.listen(port, host, function () {
164 var hostName = ~host.indexOf(':') ? '[' + host + ']' : host
165 this.log('notice', 'Listening on http://' + hostName + ':' + port + '/')
166 }.bind(this))
167}
168
169G.getRepoName = function (ownerId, repoId, cb) {
170 if (!repoId) return cb(null, '?')
171 if (repoId[0] === '#') return cb(null, repoId)
172 this.about.getName({
173 owner: ownerId,
174 target: repoId
175 }, cb)
176}
177
178G.getRepoFullName = function (author, repoId, cb) {
179 var done = multicb({ pluck: 1, spread: true })
180 this.getRepoName(author, repoId, done())
181 this.about.getName(author, done())
182 done(cb)
183}
184
185G.addAuthorName = function () {
186 var about = this.about
187 return paramap(function (msg, cb) {
188 var author = msg && msg.value && msg.value.author
189 if (!author) return cb(null, msg)
190 about.getName(author, function (err, authorName) {
191 msg.authorName = authorName
192 cb(err, msg)
193 })
194 }, 8)
195}
196
197/* Serving a request */
198
199function serve(req, res) {
200 return pull(
201 pull.filter(function (data) {
202 if (Array.isArray(data)) {
203 res.writeHead.apply(res, data)
204 return false
205 }
206 return true
207 }),
208 toPull(res)
209 )
210}
211
212function G_onRequest(req, res) {
213 this.log('info', req.method, req.url)
214
215 if (req.url.startsWith('/.well-known/acme-challenge'))
216 return this.serveAcmeChallenge(req, res)
217
218 req._u = url.parse(req.url, true)
219
220 if (!this.isHostAllowed(req.headers.host)) {
221 res.writeHead(403)
222 return res.end('403 Forbidden')
223 }
224
225 var locale = req._u.query.locale ||
226 (/locale=([^;]*)/.exec(req.headers.cookie) || [])[1]
227 var reqLocales = req.headers['accept-language']
228 var locales = reqLocales ? reqLocales.split(/, */).map(function (item) {
229 return item.split(';')[0]
230 }) : []
231 req._locale = locale || locales[0] || this.i18n.fallback
232
233 this.i18n.pickCatalog(reqLocales, req._locale, function (err, t) {
234 if (err) return pull(this.serveError(req, err, 500), serve(req, res))
235 req._t = t
236 pull(this.handleRequest(req), serve(req, res))
237 }.bind(this))
238}
239
240G.isHostAllowed = function (hostname) {
241 if (this.isPublic) return true
242 if (!hostname) return false
243 var addr = parseAddr(hostname, {port: 80})
244 if (addr.port !== this.listenPort) return false
245 var host = addr.host
246 return host === 'localhost' ||
247 host === '[::1]' ||
248 host === '127.0.0.1' ||
249 host === this.listenHost
250}
251
252var unsafePathRegex = /^\/(?:&|%26)|^%[^\/]+\/raw\//
253
254G.isRefererAllowed = function (referer) {
255 if (this.isPublic) return true
256 if (!referer) return false
257 var u = url.parse(referer)
258 return u.protocol === 'http:'
259 && (Number(u.port) || 80) === this.listenPort
260 && (u.hostname === this.listenHost
261 || u.hostname === 'localhost'
262 || u.hostname === '::1'
263 || u.hostname === '127.0.0.1')
264 && u.pathname && !unsafePathRegex.test(u.pathname)
265}
266
267G.handleRequest = function (req) {
268 var path = req._u.pathname.slice(1)
269 var dirs = ref.isLink(path) ? [path] :
270 path.split(/\/+/).map(tryDecodeURIComponent)
271 var dir = dirs[0]
272
273 if (req.method == 'POST')
274 return this.handlePOST(req, dir)
275
276 if (dir == '')
277 return this.serveIndex(req)
278 else if (dir == 'search')
279 return this.serveSearch(req)
280 else if (dir[0] === '#')
281 return this.serveChannel(req, dir, dirs.slice(1))
282 else if (ref.isBlobId(dir))
283 return this.serveBlob(req, dir)
284 else if (ref.isMsgId(dir))
285 return this.serveMessage(req, dir, dirs.slice(1))
286 else if (ref.isFeedId(dir))
287 return this.users.serveUserPage(req, dir, dirs.slice(1))
288 else if (dir == 'static')
289 return this.serveFile(req, dirs)
290 else if (dir == 'highlight')
291 return this.serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
292 else
293 return this.serve404(req)
294}
295
296G.handlePOST = function (req, dir) {
297 var self = this
298 if (self.isPublic)
299 return self.serveBuffer(405, req._t('error.POSTNotAllowed'))
300
301 if (!self.isRefererAllowed(req.headers.referer)) {
302 return this.serveBuffer(403, 'Referer not allowed')
303 }
304
305 return u.readNext(function (cb) {
306 readReqForm(req, function (err, data) {
307 if (err) return cb(null, self.serveError(req, err, 400))
308 if (!data) return cb(null, self.serveError(req,
309 new ParamError(req._t('error.MissingData')), 400))
310
311 try {switch (data.action) {
312 case 'fork-prompt':
313 return cb(null, self.serveRedirect(req,
314 u.encodeLink([data.id, 'fork'])))
315
316 case 'fork':
317 if (!data.id)
318 return cb(null, self.serveError(req,
319 new ParamError(req._t('error.MissingId')), 400))
320 return ssbGit.createRepo(self.ssb, {upstream: data.id},
321 function (err, repo) {
322 if (err) return cb(null, self.serveError(req, err))
323 cb(null, self.serveRedirect(req, u.encodeLink(repo.id)))
324 })
325
326 case 'vote':
327 var voteValue = +data.value || 0
328 if (!data.id)
329 return cb(null, self.serveError(req,
330 new ParamError(req._t('error.MissingId')), 400))
331 var msg = schemas.vote(data.id, voteValue)
332 return self.ssb.publish(msg, function (err) {
333 if (err) return cb(null, self.serveError(req, err))
334 cb(null, self.serveRedirect(req, req.url))
335 })
336
337 case 'repo-name':
338 if (!data.id)
339 return cb(null, self.serveError(req,
340 new ParamError(req._t('error.MissingId')), 400))
341 if (!data.name)
342 return cb(null, self.serveError(req,
343 new ParamError(req._t('error.MissingName')), 400))
344 var msg = schemas.name(data.id, data.name)
345 return self.ssb.publish(msg, function (err) {
346 if (err) return cb(null, self.serveError(req, err))
347 cb(null, self.serveRedirect(req, req.url))
348 })
349
350 case 'comment':
351 if (!data.id)
352 return cb(null, self.serveError(req,
353 new ParamError(req._t('error.MissingId')), 400))
354 var msg = data.text
355 ? schemas.post(data.text, data.id, data.branch || data.id)
356 : {type: 'issue-edit'}
357 msg.issue = data.issue
358 msg.repo = data.repo
359 if (data.open != null)
360 Issues.schemas.reopens(msg, data.id)
361 if (data.close != null)
362 Issues.schemas.closes(msg, data.id)
363 var mentions = Mentions(data.text)
364 if (mentions.length)
365 msg.mentions = mentions
366 return self.ssb.publish(msg, function (err) {
367 if (err) return cb(null, self.serveError(req, err))
368 cb(null, self.serveRedirect(req, req.url))
369 })
370
371 case 'line-comment':
372 if (!data.repo)
373 return cb(null, self.serveError(req,
374 new ParamError('missing repo id'), 400))
375 if (!data.commitId)
376 return cb(null, self.serveError(req,
377 new ParamError('missing commit id'), 400))
378 if (!data.updateId)
379 return cb(null, self.serveError(req,
380 new ParamError('missing update id'), 400))
381 if (!data.filePath)
382 return cb(null, self.serveError(req,
383 new ParamError('missing file path'), 400))
384 if (!data.line)
385 return cb(null, self.serveError(req,
386 new ParamError('missing line number'), 400))
387 var lineNumber = Number(data.line)
388 if (isNaN(lineNumber))
389 return cb(null, self.serveError(req,
390 new ParamError('bad line number'), 400))
391 var msg = {
392 type: 'line-comment',
393 text: data.text,
394 repo: data.repo,
395 updateId: data.updateId,
396 commitId: data.commitId,
397 filePath: data.filePath,
398 line: lineNumber,
399 }
400 msg.issue = data.issue
401 var mentions = Mentions(data.text)
402 if (mentions.length)
403 msg.mentions = mentions
404 return self.ssb.publish(msg, function (err) {
405 if (err) return cb(null, self.serveError(req, err))
406 cb(null, self.serveRedirect(req, req.url))
407 })
408
409 case 'line-comment-reply':
410 if (!data.root)
411 return cb(null, self.serveError(req,
412 new ParamError('missing thread root'), 400))
413 if (!data.branch)
414 return cb(null, self.serveError(req,
415 new ParamError('missing thread branch'), 400))
416 if (!data.text)
417 return cb(null, self.serveError(req,
418 new ParamError('missing post text'), 400))
419 var msg = {
420 type: 'post',
421 root: data.root,
422 branch: data.branch,
423 text: data.text,
424 }
425 var mentions = Mentions(data.text)
426 if (mentions.length)
427 msg.mentions = mentions
428 return self.ssb.publish(msg, function (err) {
429 if (err) return cb(null, self.serveError(req, err))
430
431 cb(null, self.serveRedirect(req, req.url))
432 })
433
434 case 'new-issue':
435 var msg = Issues.schemas.new(dir, data.text)
436 var mentions = Mentions(data.text)
437 if (mentions.length)
438 msg.mentions = mentions
439 return self.ssb.publish(msg, function (err, msg) {
440 if (err) return cb(null, self.serveError(req, err))
441 cb(null, self.serveRedirect(req, u.encodeLink(msg.key)))
442 })
443
444 case 'new-pull':
445 var msg = PullRequests.schemas.new(dir, data.branch,
446 data.head_repo, data.head_branch, data.text)
447 var mentions = Mentions(data.text)
448 if (mentions.length)
449 msg.mentions = mentions
450 return self.ssb.publish(msg, function (err, msg) {
451 if (err) return cb(null, self.serveError(req, err))
452 cb(null, self.serveRedirect(req, u.encodeLink(msg.key)))
453 })
454
455 case 'markdown':
456 return cb(null, self.serveMarkdown(data.text, {id: data.repo}))
457
458 default:
459 cb(null, self.serveBuffer(400, req._t('error.UnknownAction', data)))
460 }} catch(e) {
461 cb(null, self.serveError(req, e))
462 }
463 })
464 })
465}
466
467G.serveFile = function (req, dirs, outside) {
468 var filename = path.resolve.apply(path, [__dirname].concat(dirs))
469 // prevent escaping base dir
470 if (!outside && filename.indexOf('../') === 0)
471 return this.serveBuffer(403, req._t("error.403Forbidden"))
472
473 return u.readNext(function (cb) {
474 fs.stat(filename, function (err, stats) {
475 cb(null, err ?
476 err.code == 'ENOENT' ? this.serve404(req)
477 : this.serveBuffer(500, err.message)
478 : u.ifModifiedSince(req, stats.mtime) ?
479 pull.once([304])
480 : stats.isDirectory() ?
481 this.serveBuffer(403, req._t('error.DirectoryNotListable'))
482 : cat([
483 pull.once([200, {
484 'Content-Type': getContentType(filename),
485 'Content-Length': stats.size,
486 'Last-Modified': stats.mtime.toGMTString()
487 }]),
488 toPull(fs.createReadStream(filename))
489 ]))
490 }.bind(this))
491 }.bind(this))
492}
493
494G.serveBuffer = function (code, buf, contentType, headers) {
495 headers = headers || {}
496 headers['Content-Type'] = contentType || 'text/plain; charset=utf-8'
497 headers['Content-Length'] = Buffer.byteLength(buf)
498 return pull.values([
499 [code, headers],
500 buf
501 ])
502}
503
504G.serve404 = function (req) {
505 return this.serveBuffer(404, req._t("error.404NotFound"))
506}
507
508G.serveRedirect = function (req, path) {
509 return this.serveBuffer(302,
510 '<!doctype><html><head>' +
511 '<title>' + req._t('Redirect') + '</title></head><body>' +
512 '<p><a href="' + u.escape(path) + '">' +
513 req._t('Continue') + '</a></p>' +
514 '</body></html>', 'text/html; charset=utf-8', {Location: path})
515}
516
517G.serveMarkdown = function (text, repo) {
518 return this.serveBuffer(200, markdown(text, repo),
519 'text/html; charset=utf-8')
520}
521
522G.renderError = function (err, tag) {
523 tag = tag || 'h3'
524 return '<' + tag + '>' + err.name + '</' + tag + '>' +
525 '<pre>' + u.escape(err.stack) + '</pre>'
526}
527
528G.renderTry = function (read) {
529 var self = this
530 var ended
531 return function (end, cb) {
532 if (ended) return cb(ended)
533 read(end, function (err, data) {
534 if (err === true)
535 cb(true)
536 else if (err) {
537 ended = true
538 cb(null, self.renderError(err))
539 } else
540 cb(null, data)
541 })
542 }
543}
544
545G.serveTemplate = function (req, title, code, read) {
546 var self = this
547 if (read === undefined)
548 return this.serveTemplate.bind(this, req, title, code)
549 var q = req._u.query.q && u.escape(req._u.query.q) || ''
550 var app = 'git ssb'
551 var appName = this.ssbAppname
552 if (typeof req._t === 'function') app = req._t(app)
553 else console.trace('Missing locale data')
554 return cat([
555 pull.values([
556 [code || 200, {
557 'Content-Type': 'text/html'
558 }],
559 '<!doctype html><html><head><meta charset=utf-8>',
560 '<title>' + app + (title != undefined ? ' - ' + title : '') + '</title>',
561 '<link rel=stylesheet href="/static/styles.css"/>',
562 '<link rel=stylesheet href="/highlight/foundation.css"/>',
563 '</head>\n',
564 '<body>',
565 '<header>'
566 ]),
567 pull.once(
568 '<h1><a href="/">' + app +
569 (appName == 'ssb' ? '' : ' <sub>' + appName + '</sub>') +
570 '</a></h1> ' +
571 '<form action="/search" method="get">' +
572 '<input class="search-bar" name="q" size="60"' +
573 ' placeholder="Search" value="' + q + '" />' +
574 '</form>'),
575 self.isPublic ? null : u.readOnce(function (cb) {
576 self.about(self.myId, function (err, about) {
577 if (err) return cb(err)
578 cb(null,
579 '<a class="profile-icon" href="' + u.encodeLink(self.myId) + '">' +
580 (about.image ?
581 '<img class="profile-icon"' +
582 ' src="/' + encodeURIComponent(about.image) + '"' +
583 ' alt="' + u.escape(about.name) + '">' : u.escape(about.name)) +
584 '</a>')
585 })
586 }),
587 pull.once(
588 '</header>' +
589 '<article>'),
590 this.renderTry(read),
591 pull.once('<p style="font-size: .8em;">Built with <a href="http://git-ssb.celehner.com">git-ssb-web</a></p></article></body></html>')
592 ])
593}
594
595G.serveError = function (req, err, status) {
596 return pull(
597 pull.once(this.renderError(err, 'h2')),
598 this.serveTemplate(req, err.name, status || 500)
599 )
600}
601
602G.renderManual = function (buf, ext, cb) {
603 try {
604 proc.execFile('mandoc', ['-T', 'html'], function (err, stdout, stderr) {
605 if (err) return cb(null, renderCodeTable(buf, ext))
606 cb(null, stdout)
607 }).stdin.end(buf)
608 } catch(e) {
609 cb(null, renderCodeTable(buf, ext))
610 }
611}
612
613G.renderObjectData = function (obj, filename, repo, rev, path) {
614 var self = this
615 var ext = u.getExtension(filename)
616 return u.readOnce(function (cb) {
617 u.readObjectString(obj, function (err, buf) {
618 buf = buf.toString('utf8')
619 if (err) return cb(err)
620 if (ext == 'md' || ext == 'markdown') return cb(null,
621 markdown(buf, {repo: repo, rev: rev, path: path}))
622 if (/^[0-9]+/.exec(ext)) return self.renderManual(buf, ext, cb)
623 if (buf.length > 1000000) return cb(null, '')
624 cb(null, renderCodeTable(buf, ext))
625 })
626 })
627}
628
629function renderCodeTable(buf, ext) {
630 return '<pre><table class="code">' +
631 u.highlight(buf, ext).split('\n').map(function (line, i) {
632 i++
633 return '<tr id="L' + i + '">' +
634 '<td class="code-linenum">' + '<a href="#L' + i + '">' + i + '</td>' +
635 '<td class="code-text">' + line + '</td></tr>'
636 }).join('') +
637 '</table></pre>'
638}
639
640/* Feed */
641
642G.renderFeed = function (req, feedId, filter) {
643 var query = req._u.query
644 var opts = {
645 reverse: !query.forwards,
646 lt: query.lt && +query.lt || Date.now(),
647 gt: query.gt ? +query.gt : -Infinity,
648 values: true,
649 id: feedId
650 }
651
652 // use git index, if present
653 var source
654 if (this.ssb.gitindex) {
655 source = pull(
656 feedId ? this.ssb.gitindex.author(opts) : this.ssb.gitindex.read(opts),
657 pull.map(function (msg) { return msg.value })
658 )
659 } else {
660 source = feedId ? this.ssb.createUserStream(opts) : this.ssb.createFeedStream(opts)
661 }
662
663 return pull(
664 source,
665 u.decryptMessages(this.ssb),
666 u.readableMessages(),
667 pull.filter(function (msg) {
668 var c = msg.value.content
669 return c.type in msgTypes
670 || (c.type == 'post' && c.repo && c.issue)
671 }),
672 typeof filter == 'function' ? filter(opts) : filter,
673 pull.take(100),
674 this.addAuthorName(),
675 query.forwards && u.pullReverse(),
676 paginate(
677 function (first, cb) {
678 if (!query.lt && !query.gt) return cb(null, '')
679 var gt = feedId ? first.value.sequence : first.value.timestamp + 1
680 query.gt = gt
681 query.forwards = 1
682 delete query.lt
683 cb(null, '<a href="?' + qs.stringify(query) + '">' +
684 req._t('Next') + '</a>')
685 },
686 paramap(this.renderFeedItem.bind(this, req), 8),
687 function (last, cb) {
688 query.lt = feedId ? last.value.sequence : last.value.timestamp - 1
689 delete query.gt
690 delete query.forwards
691 cb(null, '<a href="?' + qs.stringify(query) + '">' +
692 req._t('Previous') + '</a>')
693 },
694 function (cb) {
695 if (query.forwards) {
696 delete query.gt
697 delete query.forwards
698 query.lt = opts.gt + 1
699 } else {
700 delete query.lt
701 query.gt = opts.lt - 1
702 query.forwards = 1
703 }
704 cb(null, '<a href="?' + qs.stringify(query) + '">' +
705 req._t(query.forwards ? 'Older' : 'Newer') + '</a>')
706 }
707 )
708 )
709}
710
711G.renderFeedItem = function (req, msg, cb) {
712 var self = this
713 var c = msg.value.content
714 var msgDate = moment(new Date(msg.value.timestamp)).fromNow()
715 var msgDateLink = u.link([msg.key], msgDate, false, 'class="date"')
716 var author = msg.value.author
717 var authorLink = u.link([msg.value.author], msg.authorName)
718 var privateIconMaybe = msg.value.private ? ' ' + u.privateIcon(req) : ''
719 switch (c.type) {
720 case 'git-repo':
721 var done = multicb({ pluck: 1, spread: true })
722 self.getRepoName(author, msg.key, done())
723 if (c.upstream) {
724 return self.getMsg(c.upstream, function (err, upstreamMsg) {
725 if (err) return cb(null, self.serveError(req, err))
726 self.getRepoName(upstreamMsg.value.author, c.upstream, done())
727 done(function (err, repoName, upstreamName) {
728 cb(null, '<section class="collapse">' +
729 req._t('Forked', {
730 name: authorLink,
731 upstream: u.link([c.upstream], upstreamName),
732 repo: u.link([msg.key], repoName)
733 }) + ' ' + msgDateLink + privateIconMaybe + '</section>')
734 })
735 })
736 } else {
737 return done(function (err, repoName) {
738 if (err) return cb(err)
739 var repoLink = u.link([msg.key], repoName)
740 cb(null, '<section class="collapse">' +
741 req._t('CreatedRepo', {
742 name: authorLink,
743 repo: repoLink
744 }) + ' ' + msgDateLink + privateIconMaybe +
745 (msg.value.private ?
746 '<br>' + req._t('repo.Recipients') + '<ul>' +
747 (Array.isArray(c.recps) ? c.recps : []).map(function (feed) {
748 return '<li>' + u.link([feed], feed) + '</li>'
749 }).join('') + '</ul>'
750 : '') +
751 '</section>')
752 })
753 }
754 case 'git-update':
755 return self.getRepoName(author, c.repo, function (err, repoName) {
756 if (err) return cb(err)
757 var repoLink = u.link([c.repo], repoName)
758 cb(null, '<section class="collapse">' +
759 req._t('Pushed', {
760 name: authorLink,
761 repo: repoLink
762 }) + ' ' + msgDateLink + privateIconMaybe + '</section>')
763 })
764 case 'issue':
765 case 'pull-request':
766 var issueLink = u.link([msg.key], u.messageTitle(msg))
767 // TODO: handle hashtag in project property
768 return self.getMsg(c.project, function (err, projectMsg) {
769 if (err) return cb(null,
770 self.repos.serveRepoNotFound(req, c.repo, err))
771 self.getRepoName(projectMsg.value.author, c.project,
772 function (err, repoName) {
773 if (err) return cb(err)
774 var repoLink = u.link([c.project], repoName)
775 cb(null, '<section class="collapse">' +
776 req._t('OpenedIssue', {
777 name: authorLink,
778 type: req._t(c.type == 'pull-request' ?
779 'pull request' : 'issue.'),
780 title: issueLink,
781 project: repoLink
782 }) + ' ' + msgDateLink + privateIconMaybe + '</section>')
783 })
784 })
785 case 'about':
786 return cb(null, '<section class="collapse">' +
787 req._t('Named', {
788 author: authorLink,
789 target: '<tt>' + u.escape(c.about) + '</tt>',
790 name: u.link([c.about], c.name)
791 }) + ' ' + msgDateLink + privateIconMaybe + '</section>')
792 case 'post':
793 return this.pullReqs.get(c.issue, function (err, pr) {
794 if (err) return cb(err)
795 var type = pr.msg.value.content.type == 'pull-request' ?
796 'pull request' : 'issue.'
797 var changed = self.issues.isStatusChanged(msg, pr)
798 return cb(null, '<section class="collapse">' +
799 req._t(changed == null ? 'CommentedOn' :
800 changed ? 'ReopenedIssue' : 'ClosedIssue', {
801 name: authorLink,
802 type: req._t(type),
803 title: u.link([pr.id], pr.title, true)
804 }) + ' ' + msgDateLink + privateIconMaybe +
805 (c.text ? '<blockquote>' + markdown(c.text) + '</blockquote>' : '') +
806 '</section>')
807 })
808 default:
809 return cb(null, u.json(msg))
810 }
811}
812
813/* Index */
814
815G.serveIndex = function (req) {
816 return this.serveTemplate(req)(this.renderFeed(req))
817}
818
819G.serveChannel = function (req, id, path) {
820 var self = this
821 return u.readNext(function (cb) {
822 self.getRepo(id, function (err, repo) {
823 if (err) return cb(null, self.serveError(req, err))
824 cb(null, self.repos.serveRepoPage(req, GitRepo(repo), path))
825 })
826 })
827}
828
829G.serveMessage = function (req, id, path) {
830 var self = this
831 return u.readNext(function (cb) {
832 self.getMsg(id, function (err, msg) {
833 if (err) return cb(null, self.serveError(req, err))
834 var c = msg && msg.value && msg.value.content || {}
835 switch (c.type) {
836 case 'git-repo':
837 return self.getRepo(id, function (err, repo) {
838 if (err) return cb(null, self.serveError(req, err))
839 cb(null, self.repos.serveRepoPage(req, GitRepo(repo), path))
840 })
841 case 'git-update':
842 return self.getRepo(c.repo, function (err, repo) {
843 if (err) return cb(null,
844 self.repos.serveRepoNotFound(req, c.repo, err))
845 cb(null, self.repos.serveRepoUpdate(req,
846 GitRepo(repo), msg, path))
847 })
848 case 'issue':
849 return self.getRepo(c.project, function (err, repo) {
850 if (err) return cb(null,
851 self.repos.serveRepoNotFound(req, c.project, err))
852 self.issues.get(id, function (err, issue) {
853 if (err) return cb(null, self.serveError(req, err))
854 cb(null, self.repos.issues.serveRepoIssue(req,
855 GitRepo(repo), issue, path))
856 })
857 })
858 case 'pull-request':
859 return self.getRepo(c.repo, function (err, repo) {
860 if (err) return cb(null,
861 self.repos.serveRepoNotFound(req, c.project, err))
862 self.pullReqs.get(id, function (err, pr) {
863 if (err) return cb(null, self.serveError(req, err))
864 cb(null, self.repos.pulls.serveRepoPullReq(req,
865 GitRepo(repo), pr, path))
866 })
867 })
868 case 'line-comment':
869 return self.getRepo(c.repo, function (err, repo) {
870 if (err) return cb(null,
871 self.repos.serveRepoNotFound(req, c.repo, err))
872 return cb(null,
873 self.repos.serveRepoCommit(req, GitRepo(repo), c.commitId, c.filename))
874 })
875 case 'issue-edit':
876 if (ref.isMsgId(c.issue)) {
877 return self.pullReqs.get(c.issue, function (err, issue) {
878 if (err) return cb(err)
879 self.getRepo(issue.project, function (err, repo) {
880 if (err) {
881 if (!repo) return cb(null,
882 self.repos.serveRepoNotFound(req, c.repo, err))
883 return cb(null, self.serveError(req, err))
884 }
885 cb(null, self.repos.serveIssueOrPullRequest(req, GitRepo(repo),
886 issue, path, id))
887 })
888 })
889 }
890 // fallthrough
891 case 'post':
892 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
893 // comment on an issue
894 var done = multicb({ pluck: 1, spread: true })
895 self.getRepo(c.repo, done())
896 self.pullReqs.get(c.issue, done())
897 return done(function (err, repo, issue) {
898 if (err) {
899 if (!repo) return cb(null,
900 self.repos.serveRepoNotFound(req, c.repo, err))
901 return cb(null, self.serveError(req, err))
902 }
903 cb(null, self.repos.serveIssueOrPullRequest(req, GitRepo(repo),
904 issue, path, id))
905 })
906 } else if (ref.isMsgId(c.root)) {
907 // comment on issue from patchwork?
908 return self.getMsg(c.root, function (err, root) {
909 var rc = root.value && root.value.content && root.value.content
910 if (err) return cb(null, self.serveError(req, err))
911 var repoId = rc.repo || rc.project
912 if (!ref.isMsgId(repoId))
913 return cb(null, self.serveGenericMessage(req, msg, path))
914 self.getRepo(repoId, function (err, repo) {
915 if (err) return cb(null, self.serveError(req, err))
916 switch (rc && rc.type) {
917 case 'issue':
918 return self.issues.get(c.root, function (err, issue) {
919 if (err) return cb(null, self.serveError(req, err))
920 return cb(null,
921 self.repos.issues.serveRepoIssue(req,
922 GitRepo(repo), issue, path, id))
923 })
924 case 'pull-request':
925 return self.pullReqs.get(c.root, function (err, pr) {
926 if (err) return cb(null, self.serveError(req, err))
927 return cb(null,
928 self.repos.pulls.serveRepoPullReq(req,
929 GitRepo(repo), pr, path, id))
930 })
931 case 'line-comment':
932 return cb(null,
933 self.repos.serveRepoCommit(req, GitRepo(repo), rc.commitId, rc.filename))
934 default:
935 return cb(null, self.serveGenericMessage(req, msg, path))
936 }
937 })
938 })
939 }
940 // fallthrough
941 default:
942 if (ref.isMsgId(c.repo))
943 return self.getRepo(c.repo, function (err, repo) {
944 if (err) return cb(null,
945 self.repos.serveRepoNotFound(req, c.repo, err))
946 cb(null, self.repos.serveRepoSomething(req,
947 GitRepo(repo), id, msg, path))
948 })
949 else
950 return cb(null, self.serveGenericMessage(req, msg, path))
951 }
952 })
953 })
954}
955
956G.serveGenericMessage = function (req, msg, path) {
957 return this.serveTemplate(req, msg.key)(pull.once(
958 '<section><h2>' + u.link([msg.key]) + '</h2>' +
959 u.json(msg.value) +
960 '</section>'))
961}
962
963/* Search */
964
965G.serveSearch = function (req) {
966 var self = this
967 var q = String(req._u.query.q || '')
968 if (!q) return this.serveIndex(req)
969 var qId = q.replace(/^ssb:\/*/, '')
970 if (ref.type(qId))
971 return this.serveRedirect(req, encodeURIComponent(qId))
972
973 var search = new RegExp(q, 'i')
974 return this.serveTemplate(req, req._t('Search') + ' &middot; ' + q, 200)(
975 this.renderFeed(req, null, function (opts) {
976 return function (read) {
977 return pull(
978 many([
979 self.getMsgs('about', opts),
980 read
981 ]),
982 pull.filter(function (msg) {
983 var c = msg.value.content
984 return (
985 search.test(msg.key) ||
986 c.text && search.test(c.text) ||
987 c.name && search.test(c.name) ||
988 c.title && search.test(c.title))
989 })
990 )
991 }
992 })
993 )
994}
995
996G.getMsgRaw = function (key, cb) {
997 var self = this
998 this.ssb.get(key, function (err, value) {
999 if (err) return cb(err)
1000 u.decryptMessage(self.ssb, {key: key, value: value}, cb)
1001 })
1002}
1003
1004G.getMsgs = function (type, opts) {
1005 return this.ssb.messagesByType({
1006 type: type,
1007 reverse: opts.reverse,
1008 lt: opts.lt,
1009 gt: opts.gt,
1010 })
1011}
1012
1013G.serveBlobNotFound = function (req, repoId, err) {
1014 return this.serveTemplate(req, req._t('error.BlobNotFound'), 404)(pull.once(
1015 '<h2>' + req._t('error.BlobNotFound') + '</h2>' +
1016 '<p>' + req._t('error.BlobNotFoundInRepo', {
1017 repo: u.link([repoId])
1018 }) + '</p>' +
1019 '<pre>' + u.escape(err.stack) + '</pre>'
1020 ))
1021}
1022
1023G.serveRaw = function (length, contentType) {
1024 var headers = {
1025 'Content-Type': contentType || 'text/plain; charset=utf-8',
1026 'Cache-Control': 'max-age=31536000'
1027 }
1028 if (length != null)
1029 headers['Content-Length'] = length
1030 return function (read) {
1031 return cat([pull.once([200, headers]), read])
1032 }
1033}
1034
1035G.getBlob = function (req, key, cb) {
1036 var blobs = this.ssb.blobs
1037 // use size to check for blob's presence, since has or want may broadcast
1038 blobs.size(key, function (err, size) {
1039 if (typeof size === 'number') cb(null, blobs.get(key))
1040 else blobs.want(key, function (err, got) {
1041 if (err) cb(err)
1042 else if (!got) cb(new Error(req._t('error.MissingBlob', {key: key})))
1043 else cb(null, blobs.get(key))
1044 })
1045 })
1046}
1047
1048G.serveBlob = function (req, key) {
1049 var self = this
1050 return u.readNext(function (cb) {
1051 self.getBlob(req, key, function (err, read) {
1052 if (err) cb(null, self.serveError(req, err))
1053 else if (!read) cb(null, self.serve404(req))
1054 else cb(null, identToResp(read))
1055 })
1056 })
1057}
1058
1059function identToResp(read) {
1060 var ended, type, queue
1061 var id = ident(function (_type) {
1062 type = _type && mime.lookup(_type)
1063 })(read)
1064 return function (end, cb) {
1065 if (ended) return cb(ended)
1066 if (end) id(end, function (end) {
1067 cb(end === true ? null : end)
1068 })
1069 else if (queue) {
1070 var _queue = queue
1071 queue = null
1072 cb(null, _queue)
1073 }
1074 else if (!type)
1075 id(null, function (end, data) {
1076 if (ended = end) return cb(end)
1077 queue = data
1078 cb(null, [200, {
1079 'Content-Type': type || 'text/plain; charset=utf-8',
1080 'Cache-Control': 'max-age=31536000'
1081 }])
1082 })
1083 else
1084 id(null, cb)
1085 }
1086}
1087
1088G.monitorSsbClient = function () {
1089 pull(
1090 function (abort, cb) {
1091 if (abort) throw abort
1092 setTimeout(function () {
1093 cb(null, 'keepalive')
1094 }, 15e3)
1095 },
1096 this.ssb.gossip.ping(),
1097 pull.drain(null, function (err) {
1098 // exit when the rpc connection ends
1099 if (err) console.error(err)
1100 console.error('sbot client connection closed. aborting')
1101 process.exit(1)
1102 })
1103 )
1104}
1105

Built with git-ssb-web