git ssb

30+

cel / git-ssb-web



Tree: fd1acb17f9a97b7f8b7d3fac39f92f5ca08402ee

Files: fd1acb17f9a97b7f8b7d3fac39f92f5ca08402ee / index.js

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

Built with git-ssb-web