git ssb

30+

cel / git-ssb-web



Tree: 6d3a8a6342ec134487fe48ba802ee255960dabf0

Files: 6d3a8a6342ec134487fe48ba802ee255960dabf0 / index.js

90020 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 Repo = require('pull-git-repo')
13var ssbAbout = require('./about')
14var ssbVotes = require('./votes')
15var i18n = require('./i18n')
16var marked = require('ssb-marked')
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 gitPack = require('pull-git-pack')
24var Mentions = require('ssb-mentions')
25var Highlight = require('highlight.js')
26var JsDiff = require('diff')
27var many = require('pull-many')
28
29var hlCssPath = path.resolve(require.resolve('highlight.js'), '../../styles')
30
31// render links to git objects and ssb objects
32var blockRenderer = new marked.Renderer()
33blockRenderer.urltransform = function (url) {
34 if (ref.isLink(url))
35 return encodeLink(url)
36 if (/^[0-9a-f]{40}$/.test(url) && this.options.repo)
37 return encodeLink([this.options.repo.id, 'commit', url])
38 return url
39}
40
41blockRenderer.image = function (href, title, text) {
42 href = href.replace(/^&/, '&')
43 var url
44 if (ref.isBlobId(href))
45 url = encodeLink(href)
46 else if (this.options.repo && this.options.rev && this.options.path)
47 url = path.join('/', encodeURIComponent(this.options.repo.id),
48 'raw', this.options.rev, this.options.path.join('/'), href)
49 else
50 return text
51 return '<img src="' + escapeHTML(url) + '" alt="' + text + '"' +
52 (title ? ' title="' + title + '"' : '') + '/>'
53}
54
55function getExtension(filename) {
56 return (/\.([^.]+)$/.exec(filename) || [,filename])[1]
57}
58
59function highlight(code, lang) {
60 try {
61 return lang
62 ? Highlight.highlight(lang, code).value
63 : Highlight.highlightAuto(code).value
64 } catch(e) {
65 if (/^Unknown language/.test(e.message))
66 return escapeHTML(code)
67 throw e
68 }
69}
70
71marked.setOptions({
72 gfm: true,
73 mentions: true,
74 tables: true,
75 breaks: true,
76 pedantic: false,
77 sanitize: true,
78 smartLists: true,
79 smartypants: false,
80 highlight: highlight,
81 renderer: blockRenderer
82})
83
84// hack to make git link mentions work
85var mdRules = new marked.InlineLexer(1, marked.defaults).rules
86mdRules.mention =
87 /^(\s)?([@%&][A-Za-z0-9\._\-+=\/]*[A-Za-z0-9_\-+=\/]|[0-9a-f]{40})/
88mdRules.text = /^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n| [@%&]|[0-9a-f]{40}|$)/
89
90function markdown(text, options, cb) {
91 if (!text) return ''
92 if (typeof text != 'string') text = String(text)
93 if (!options) options = {}
94 else if (options.id) options = {repo: options}
95 if (!options.rev) options.rev = 'HEAD'
96 if (!options.path) options.path = []
97
98 return marked(text, options, cb)
99}
100
101function ParamError(msg) {
102 var err = Error.call(this, msg)
103 err.name = ParamError.name
104 return err
105}
106util.inherits(ParamError, Error)
107
108function parseAddr(str, def) {
109 if (!str) return def
110 var i = str.lastIndexOf(':')
111 if (~i) return {host: str.substr(0, i), port: str.substr(i+1)}
112 if (isNaN(str)) return {host: str, port: def.port}
113 return {host: def.host, port: str}
114}
115
116function isArray(arr) {
117 return Object.prototype.toString.call(arr) == '[object Array]'
118}
119
120function encodeLink(url) {
121 if (!isArray(url)) url = [url]
122 return '/' + url.map(encodeURIComponent).join('/')
123}
124
125function link(parts, text, raw, props) {
126 if (text == null) text = parts[parts.length-1]
127 if (!raw) text = escapeHTML(text)
128 return '<a href="' + encodeLink(parts) + '"' +
129 (props ? ' ' + props : '') +
130 '>' + text + '</a>'
131}
132
133function linkify(text) {
134 // regex is from ssb-ref
135 return text.replace(/(@|%|&|&amp;)[A-Za-z0-9\/+]{43}=\.[\w\d]+/g, function (str) {
136 return '<a href="/' + encodeURIComponent(str) + '">' + str + '</a>'
137 })
138}
139
140function timestamp(time, req) {
141 time = Number(time)
142 var d = new Date(time)
143 return '<span title="' + time + '">' +
144 d.toLocaleString(req._locale) + '</span>'
145}
146
147function pre(text) {
148 return '<pre>' + escapeHTML(text) + '</pre>'
149}
150
151function json(obj) {
152 return linkify(pre(JSON.stringify(obj, null, 2)))
153}
154
155function escapeHTML(str) {
156 return String(str)
157 .replace(/&/g, '&amp;')
158 .replace(/</g, '&lt;')
159 .replace(/>/g, '&gt;')
160 .replace(/"/g, '&quot;')
161}
162
163function table(props) {
164 return function (read) {
165 return cat([
166 pull.once('<table' + (props ? ' ' + props : '') + '>'),
167 pull(
168 read,
169 pull.map(function (row) {
170 return row ? '<tr>' + row.map(function (cell) {
171 return '<td>' + cell + '</td>'
172 }).join('') + '</tr>' : ''
173 })
174 ),
175 pull.once('</table>')
176 ])
177 }
178}
179
180function ul(props) {
181 return function (read) {
182 return cat([
183 pull.once('<ul' + (props ? ' ' + props : '') + '>'),
184 pull(
185 read,
186 pull.map(function (li) {
187 return '<li>' + li + '</li>'
188 })
189 ),
190 pull.once('</ul>')
191 ])
192 }
193}
194
195function nav(links, page, after) {
196 return ['<nav>'].concat(
197 links.map(function (link) {
198 var href = typeof link[0] == 'string' ? link[0] : encodeLink(link[0])
199 var props = link[2] == page ? ' class="active"' : ''
200 return '<a href="' + href + '"' + props + '>' + link[1] + '</a>'
201 }), after || '', '</nav>').join('')
202}
203
204function renderNameForm(req, enabled, id, name, action, inputId, title, header) {
205 if (!inputId) inputId = action
206 return '<form class="petname" action="" method="post">' +
207 (enabled ?
208 '<input type="checkbox" class="name-checkbox" id="' + inputId + '" ' +
209 'onfocus="this.form.name.focus()" />' +
210 '<input name="name" class="name" value="' + escapeHTML(name) + '" ' +
211 'onkeyup="if (event.keyCode == 27) this.form.reset()" />' +
212 '<input type="hidden" name="action" value="' + action + '">' +
213 '<input type="hidden" name="id" value="' +
214 escapeHTML(id) + '">' +
215 '<label class="name-toggle" for="' + inputId + '" ' +
216 'title="' + title + '"><i>โœ</i></label> ' +
217 '<input class="btn name-btn" type="submit" value="' +
218 req._t('Rename') + '">' +
219 header :
220 header + '<br clear="all"/>'
221 ) +
222 '</form>'
223}
224
225function renderPostForm(req, repo, placeholder, rows) {
226 return '<input type="radio" class="tab-radio" id="tab1" name="tab" checked="checked"/>' +
227 '<input type="radio" class="tab-radio" id="tab2" name="tab"/>' +
228 '<div id="tab-links" class="tab-links" style="display:none">' +
229 '<label for="tab1" id="write-tab-link" class="tab1-link">' +
230 req._t('post.Write') + '</label>' +
231 '<label for="tab2" id="preview-tab-link" class="tab2-link">' +
232 req._t('post.Preview') + '</label>' +
233 '</div>' +
234 '<input type="hidden" id="repo-id" value="' + repo.id + '"/>' +
235 '<div id="write-tab" class="tab1">' +
236 '<textarea id="post-text" name="text" class="wide-input"' +
237 ' rows="' + (rows||4) + '" cols="77"' +
238 (placeholder ? ' placeholder="' + placeholder + '"' : '') +
239 '></textarea>' +
240 '</div>' +
241 '<div class="preview-text tab2" id="preview-tab"></div>' +
242 '<script>' + issueCommentScript + '</script>'
243}
244
245function hiddenInputs(values) {
246 return Object.keys(values).map(function (key) {
247 return '<input type="hidden"' +
248 ' name="' + escapeHTML(key) + '"' +
249 ' value="' + escapeHTML(values[key]) + '"/>'
250 }).join('')
251}
252
253function readNext(fn) {
254 var next
255 return function (end, cb) {
256 if (next) return next(end, cb)
257 fn(function (err, _next) {
258 if (err) return cb(err)
259 next = _next
260 next(null, cb)
261 })
262 }
263}
264
265function readOnce(fn) {
266 var ended
267 return function (end, cb) {
268 fn(function (err, data) {
269 if (err || ended) return cb(err || ended)
270 ended = true
271 cb(null, data)
272 })
273 }
274}
275
276function paginate(onFirst, through, onLast, onEmpty) {
277 var ended, last, first = true, queue = []
278 return function (read) {
279 var mappedRead = through(function (end, cb) {
280 if (ended = end) return read(ended, cb)
281 if (queue.length)
282 return cb(null, queue.shift())
283 read(null, function (end, data) {
284 if (end) return cb(end)
285 last = data
286 cb(null, data)
287 })
288 })
289 return function (end, cb) {
290 var tmp
291 if (ended) return cb(ended)
292 if (ended = end) return read(ended, cb)
293 if (first)
294 return read(null, function (end, data) {
295 if (ended = end) {
296 if (end === true && onEmpty)
297 return onEmpty(cb)
298 return cb(ended)
299 }
300 first = false
301 last = data
302 queue.push(data)
303 if (onFirst)
304 onFirst(data, cb)
305 else
306 mappedRead(null, cb)
307 })
308 mappedRead(null, function (end, data) {
309 if (ended = end) {
310 if (end === true && last)
311 return onLast(last, cb)
312 }
313 cb(end, data)
314 })
315 }
316 }
317}
318
319function readObjectString(obj, cb) {
320 pull(obj.read, pull.collect(function (err, bufs) {
321 if (err) return cb(err)
322 cb(null, Buffer.concat(bufs, obj.length).toString('utf8'))
323 }))
324}
325
326function getRepoObjectString(repo, id, cb) {
327 if (!id) return cb(null, '')
328 repo.getObjectFromAny(id, function (err, obj) {
329 if (err) return cb(err)
330 readObjectString(obj, cb)
331 })
332}
333
334function compareMsgs(a, b) {
335 return (a.value.timestamp - b.value.timestamp) || (a.key - b.key)
336}
337
338function pullSort(comparator) {
339 return function (read) {
340 return readNext(function (cb) {
341 pull(read, pull.collect(function (err, items) {
342 if (err) return cb(err)
343 items.sort(comparator)
344 cb(null, pull.values(items))
345 }))
346 })
347 }
348}
349
350function sortMsgs() {
351 return pullSort(compareMsgs)
352}
353
354function pullReverse() {
355 return function (read) {
356 return readNext(function (cb) {
357 pull(read, pull.collect(function (err, items) {
358 cb(err, items && pull.values(items.reverse()))
359 }))
360 })
361 }
362}
363
364function tryDecodeURIComponent(str) {
365 if (!str || (str[0] == '%' && ref.isBlobId(str)))
366 return str
367 try {
368 str = decodeURIComponent(str)
369 } finally {
370 return str
371 }
372}
373
374function getRepoName(about, ownerId, repoId, cb) {
375 about.getName({
376 owner: ownerId,
377 target: repoId,
378 toString: function () {
379 // hack to fit two parameters into asyncmemo
380 return ownerId + '/' + repoId
381 }
382 }, cb)
383}
384
385function getRepoFullName(about, author, repoId, cb) {
386 var done = multicb({ pluck: 1, spread: true })
387 getRepoName(about, author, repoId, done())
388 about.getName(author, done())
389 done(cb)
390}
391
392function addAuthorName(about) {
393 return paramap(function (msg, cb) {
394 var author = msg && msg.value && msg.value.author
395 if (!author) return cb(null, msg)
396 about.getName(author, function (err, authorName) {
397 msg.authorName = authorName
398 cb(err, msg)
399 })
400 }, 8)
401}
402
403function getMention(msg, id) {
404 if (msg.key == id) return msg
405 var mentions = msg.value.content.mentions
406 if (mentions) for (var i = 0; i < mentions.length; i++) {
407 var mention = mentions[i]
408 if (mention.link == id)
409 return mention
410 }
411 return null
412}
413
414var hasOwnProp = Object.prototype.hasOwnProperty
415
416function getContentType(filename) {
417 var ext = getExtension(filename)
418 return contentTypes[ext] || imgMimes[ext] || 'text/plain; charset=utf-8'
419}
420
421var contentTypes = {
422 css: 'text/css'
423}
424
425function readReqForm(req, cb) {
426 pull(
427 toPull(req),
428 pull.collect(function (err, bufs) {
429 if (err) return cb(err)
430 var data
431 try {
432 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
433 } catch(e) {
434 return cb(e)
435 }
436 cb(null, data)
437 })
438 )
439}
440
441var issueCommentScript = '(' + function () {
442 var $ = document.getElementById.bind(document)
443 $('tab-links').style.display = 'block'
444 $('preview-tab-link').onclick = function (e) {
445 with (new XMLHttpRequest()) {
446 open('POST', '', true)
447 onload = function() {
448 $('preview-tab').innerHTML = responseText
449 }
450 send('action=markdown' +
451 '&repo=' + encodeURIComponent($('repo-id').value) +
452 '&text=' + encodeURIComponent($('post-text').value))
453 }
454 }
455}.toString() + ')()'
456
457var msgTypes = {
458 'git-repo': true,
459 'git-update': true,
460 'issue': true,
461 'pull-request': true
462}
463
464var imgMimes = {
465 png: 'image/png',
466 jpeg: 'image/jpeg',
467 jpg: 'image/jpeg',
468 gif: 'image/gif',
469 tif: 'image/tiff',
470 svg: 'image/svg+xml',
471 bmp: 'image/bmp'
472}
473
474module.exports = function (opts, cb) {
475 var ssb, reconnect, myId, getRepo, getVotes, getMsg, issues
476 var about = function (id, cb) { cb(null, {name: id}) }
477 var reqQueue = []
478 var isPublic = opts.public
479 var ssbAppname = opts.appname || 'ssb'
480
481 var addr = parseAddr(opts.listenAddr, {host: 'localhost', port: 7718})
482 http.createServer(onRequest).listen(addr.port, addr.host, onListening)
483
484 var server = {
485 setSSB: function (_ssb, _reconnect) {
486 _ssb.whoami(function (err, feed) {
487 if (err) throw err
488 ssb = _ssb
489 reconnect = _reconnect
490 myId = feed.id
491 about = ssbAbout(ssb, myId)
492 while (reqQueue.length)
493 onRequest.apply(this, reqQueue.shift())
494 getRepo = asyncMemo(function (id, cb) {
495 getMsg(id, function (err, msg) {
496 if (err) return cb(err)
497 ssbGit.getRepo(ssb, {key: id, value: msg}, {live: true}, cb)
498 })
499 })
500 getVotes = ssbVotes(ssb)
501 getMsg = asyncMemo(ssb.get)
502 issues = Issues.init(ssb)
503 pullReqs = PullRequests.init(ssb)
504 })
505 }
506 }
507
508 function onListening() {
509 var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host
510 console.log('Listening on http://' + host + ':' + addr.port + '/')
511 cb(null, server)
512 }
513
514 /* Serving a request */
515
516 function onRequest(req, res) {
517 console.log(req.method, req.url)
518 if (!ssb) return reqQueue.push(arguments)
519 req._u = url.parse(req.url, true)
520 var locale = req._u.query.locale ||
521 (/locale=([^;]*)/.exec(req.headers.cookie) || [])[1]
522 var reqLocales = req.headers['accept-language']
523 i18n.pickCatalog(reqLocales, locale, function (err, t) {
524 if (err) return pull(serveError(req, err, 500), serve(req, res))
525 req._t = t
526 req._locale = t.locale
527 pull(handleRequest(req), serve(req, res))
528 })
529 }
530
531 function serve(req, res) {
532 return pull(
533 pull.filter(function (data) {
534 if (Array.isArray(data)) {
535 res.writeHead.apply(res, data)
536 return false
537 }
538 return true
539 }),
540 toPull(res)
541 )
542 }
543
544 function handleRequest(req) {
545 var path = req._u.pathname.slice(1)
546 var dirs = ref.isLink(path) ? [path] :
547 path.split(/\/+/).map(tryDecodeURIComponent)
548 var dir = dirs[0]
549
550 if (req.method == 'POST') {
551 if (isPublic)
552 return serveBuffer(405, req._t('error.POSTNotAllowed'))
553 return readNext(function (cb) {
554 readReqForm(req, function (err, data) {
555 if (err) return cb(null, serveError(req, err, 400))
556 if (!data) return cb(null, serveError(req,
557 new ParamError(req._t('error.MissingData')), 400))
558
559 switch (data.action) {
560 case 'fork-prompt':
561 return cb(null, serveRedirect(req,
562 encodeLink([data.id, 'fork'])))
563
564 case 'fork':
565 if (!data.id)
566 return cb(null, serveError(req,
567 new ParamError(req._t('error.MissingId')), 400))
568 return ssbGit.createRepo(ssb, {upstream: data.id},
569 function (err, repo) {
570 if (err) return cb(null, serveError(req, err))
571 cb(null, serveRedirect(req, encodeLink(repo.id)))
572 })
573
574 case 'vote':
575 var voteValue = +data.value || 0
576 if (!data.id)
577 return cb(null, serveError(req,
578 new ParamError(req._t('error.MissingId')), 400))
579 var msg = schemas.vote(data.id, voteValue)
580 return ssb.publish(msg, function (err) {
581 if (err) return cb(null, serveError(req, err))
582 cb(null, serveRedirect(req, req.url))
583 })
584
585 case 'repo-name':
586 if (!data.id)
587 return cb(null, serveError(req,
588 new ParamError(req._t('error.MissingId')), 400))
589 if (!data.name)
590 return cb(null, serveError(req,
591 new ParamError(req._t('error.MissingName')), 400))
592 var msg = schemas.name(data.id, data.name)
593 return ssb.publish(msg, function (err) {
594 if (err) return cb(null, serveError(req, err))
595 cb(null, serveRedirect(req, req.url))
596 })
597
598 case 'issue-title':
599 if (!data.id)
600 return cb(null, serveError(req,
601 new ParamError(req._t('error.MissingId')), 400))
602 if (!data.name)
603 return cb(null, serveError(req,
604 new ParamError(req._t('error.MissingName')), 400))
605 var msg = Issues.schemas.edit(data.id, {title: data.name})
606 return ssb.publish(msg, function (err) {
607 if (err) return cb(null, serveError(req, err))
608 cb(null, serveRedirect(req, req.url))
609 })
610
611 case 'comment':
612 if (!data.id)
613 return cb(null, serveError(req,
614 new ParamError(req._t('error.MissingId')), 400))
615 var msg = schemas.post(data.text, data.id, data.branch || data.id)
616 msg.issue = data.issue
617 msg.repo = data.repo
618 if (data.open != null)
619 Issues.schemas.opens(msg, data.id)
620 if (data.close != null)
621 Issues.schemas.closes(msg, data.id)
622 var mentions = Mentions(data.text)
623 if (mentions.length)
624 msg.mentions = mentions
625 return ssb.publish(msg, function (err) {
626 if (err) return cb(null, serveError(req, err))
627 cb(null, serveRedirect(req, req.url))
628 })
629
630 case 'new-issue':
631 var msg = Issues.schemas.new(dir, data.title, data.text)
632 var mentions = Mentions(data.text)
633 if (mentions.length)
634 msg.mentions = mentions
635 return ssb.publish(msg, function (err, msg) {
636 if (err) return cb(null, serveError(req, err))
637 cb(null, serveRedirect(req, encodeLink(msg.key)))
638 })
639
640 case 'new-pull':
641 var msg = PullRequests.schemas.new(dir, data.branch,
642 data.head_repo, data.head_branch, data.title, data.text)
643 var mentions = Mentions(data.text)
644 if (mentions.length)
645 msg.mentions = mentions
646 return ssb.publish(msg, function (err, msg) {
647 if (err) return cb(null, serveError(req, err))
648 cb(null, serveRedirect(req, encodeLink(msg.key)))
649 })
650
651 case 'markdown':
652 return cb(null, serveMarkdown(data.text, {id: data.repo}))
653
654 default:
655 cb(null, serveBuffer(400, req._t('error.UnknownAction', data)))
656 }
657 })
658 })
659 }
660
661 if (dir == '')
662 return serveIndex(req)
663 else if (dir == 'search')
664 return serveSearch(req)
665 else if (ref.isBlobId(dir))
666 return serveBlob(req, dir)
667 else if (ref.isMsgId(dir))
668 return serveMessage(req, dir, dirs.slice(1))
669 else if (ref.isFeedId(dir))
670 return serveUserPage(req, dir, dirs.slice(1))
671 else if (dir == 'static')
672 return serveFile(req, dirs)
673 else if (dir == 'highlight')
674 return serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
675 else
676 return serve404(req)
677 }
678
679 function serveFile(req, dirs, outside) {
680 var filename = path.resolve.apply(path, [__dirname].concat(dirs))
681 // prevent escaping base dir
682 if (!outside && filename.indexOf('../') === 0)
683 return serveBuffer(403, req._t("error.403Forbidden"))
684
685 return readNext(function (cb) {
686 fs.stat(filename, function (err, stats) {
687 cb(null, err ?
688 err.code == 'ENOENT' ? serve404(req)
689 : serveBuffer(500, err.message)
690 : 'if-modified-since' in req.headers &&
691 new Date(req.headers['if-modified-since']) >= stats.mtime ?
692 pull.once([304])
693 : stats.isDirectory() ?
694 serveBuffer(403, req._t('error.DirectoryNotListable'))
695 : cat([
696 pull.once([200, {
697 'Content-Type': getContentType(filename),
698 'Content-Length': stats.size,
699 'Last-Modified': stats.mtime.toGMTString()
700 }]),
701 toPull(fs.createReadStream(filename))
702 ]))
703 })
704 })
705 }
706
707 function servePlainError(code, msg) {
708 return pull.values([
709 [code, {
710 'Content-Length': Buffer.byteLength(msg),
711 'Content-Type': 'text/plain; charset=utf-8'
712 }],
713 msg
714 ])
715 }
716
717 function serveBuffer(code, buf, contentType, headers) {
718 headers = headers || {}
719 headers['Content-Type'] = contentType || 'text/plain; charset=utf-8'
720 headers['Content-Length'] = Buffer.byteLength(buf)
721 return pull.values([
722 [code, headers],
723 buf
724 ])
725 }
726
727 function serve404(req) {
728 return serveBuffer(404, req._t("error.404NotFound"))
729 }
730
731 function serveRedirect(req, path) {
732 return serveBuffer(302,
733 '<!doctype><html><head>' +
734 '<title>' + req._t('Redirect') + '</title></head><body>' +
735 '<p><a href="' + escapeHTML(path) + '">' +
736 req._t('Continue') + '</a></p>' +
737 '</body></html>', 'text/html; charset=utf-8', {Location: path})
738 }
739
740 function serveMarkdown(text, repo) {
741 return serveBuffer(200, markdown(text, repo), 'text/html; charset=utf-8')
742 }
743
744 function renderError(err, tag) {
745 tag = tag || 'h3'
746 return '<' + tag + '>' + err.name + '</' + tag + '>' +
747 '<pre>' + escapeHTML(err.stack) + '</pre>'
748 }
749
750 function renderTry(read) {
751 var ended
752 return function (end, cb) {
753 if (ended) return cb(ended)
754 read(end, function (err, data) {
755 if (err === true)
756 cb(true)
757 else if (err) {
758 ended = true
759 cb(null, renderError(err, 'h3'))
760 } else
761 cb(null, data)
762 })
763 }
764 }
765
766 function serveTemplate(req, title, code, read) {
767 if (read === undefined) return serveTemplate.bind(this, req, title, code)
768 var q = req._u.query.q && escapeHTML(req._u.query.q) || ''
769 var app = 'git ssb'
770 if (req._t) app = req._t(app)
771 return cat([
772 pull.values([
773 [code || 200, {
774 'Content-Type': 'text/html'
775 }],
776 '<!doctype html><html><head><meta charset=utf-8>',
777 '<title>' + (title || app) + '</title>',
778 '<link rel=stylesheet href="/static/styles.css"/>',
779 '<link rel=stylesheet href="/highlight/github.css"/>',
780 '</head>\n',
781 '<body>',
782 '<header><form action="/search" method="get">' +
783 '<h1><a href="/">' + app + '' +
784 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
785 '</a> ' +
786 '<input class="search-bar" name="q" size="60"' +
787 ' placeholder="๐Ÿ”" value="' + q + '" />' +
788 '</h1>',
789 '</form></header>',
790 '<article>']),
791 renderTry(read),
792 pull.once('<hr/></article></body></html>')
793 ])
794 }
795
796 function serveError(req, err, status) {
797 if (err.message == 'stream is closed')
798 reconnect()
799 return pull(
800 pull.once(renderError(err, 'h2')),
801 serveTemplate(req, err.name, status || 500)
802 )
803 }
804
805 function renderObjectData(obj, filename, repo, rev, path) {
806 var ext = getExtension(filename)
807 return readOnce(function (cb) {
808 readObjectString(obj, function (err, buf) {
809 buf = buf.toString('utf8')
810 if (err) return cb(err)
811 cb(null, (ext == 'md' || ext == 'markdown')
812 ? markdown(buf, {repo: repo, rev: rev, path: path})
813 : renderCodeTable(buf, ext))
814 })
815 })
816 }
817
818 function renderCodeTable(buf, ext) {
819 return '<pre><table class="code">' +
820 highlight(buf, ext).split('\n').map(function (line, i) {
821 i++
822 return '<tr id="L' + i + '">' +
823 '<td class="code-linenum">' + '<a href="#L' + i + '">' + i + '</td>' +
824 '<td class="code-text">' + line + '</td></tr>'
825 }).join('') +
826 '</table></pre>'
827 }
828
829 /* Feed */
830
831 function renderFeed(req, feedId, filter) {
832 var query = req._u.query
833 var opts = {
834 reverse: !query.forwards,
835 lt: query.lt && +query.lt || Date.now(),
836 gt: query.gt && +query.gt,
837 id: feedId
838 }
839 return pull(
840 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
841 pull.filter(function (msg) {
842 return msg.value.content.type in msgTypes
843 }),
844 typeof filter == 'function' ? filter(opts) : filter,
845 pull.take(20),
846 addAuthorName(about),
847 query.forwards && pullReverse(),
848 paginate(
849 function (first, cb) {
850 if (!query.lt && !query.gt) return cb(null, '')
851 var gt = feedId ? first.value.sequence : first.value.timestamp + 1
852 query.gt = gt
853 query.forwards = 1
854 delete query.lt
855 cb(null, '<a href="?' + qs.stringify(query) + '">' +
856 req._t('Newer') + '</a>')
857 },
858 paramap(renderFeedItem.bind(null, req), 8),
859 function (last, cb) {
860 query.lt = feedId ? last.value.sequence : last.value.timestamp - 1
861 delete query.gt
862 delete query.forwards
863 cb(null, '<a href="?' + qs.stringify(query) + '">' +
864 req._t('Older') + '</a>')
865 },
866 function (cb) {
867 if (query.forwards) {
868 delete query.gt
869 delete query.forwards
870 query.lt = opts.gt + 1
871 } else {
872 delete query.lt
873 query.gt = opts.lt - 1
874 query.forwards = 1
875 }
876 cb(null, '<a href="?' + qs.stringify(query) + '">' +
877 req._t(query.forwards ? 'Older' : 'Newer') + '</a>')
878 }
879 )
880 )
881 }
882
883 function renderFeedItem(req, msg, cb) {
884 var c = msg.value.content
885 var msgLink = link([msg.key],
886 new Date(msg.value.timestamp).toLocaleString(req._locale))
887 var author = msg.value.author
888 var authorLink = link([msg.value.author], msg.authorName)
889 switch (c.type) {
890 case 'git-repo':
891 var done = multicb({ pluck: 1, spread: true })
892 getRepoName(about, author, msg.key, done())
893 if (c.upstream) {
894 return getMsg(c.upstream, function (err, upstreamMsg) {
895 if (err) return cb(null, serveError(req, err))
896 getRepoName(about, upstreamMsg.author, c.upstream, done())
897 done(function (err, repoName, upstreamName) {
898 cb(null, '<section class="collapse">' + msgLink + '<br>' +
899 req._t('Forked', {
900 name: authorLink,
901 upstream: link([c.upstream], upstreamName),
902 repo: link([msg.key], repoName)
903 }) + '</section>')
904 })
905 })
906 } else {
907 return done(function (err, repoName) {
908 if (err) return cb(err)
909 var repoLink = link([msg.key], repoName)
910 cb(null, '<section class="collapse">' + msgLink + '<br>' +
911 req._t('CreatedRepo', {
912 name: authorLink,
913 repo: repoLink
914 }) + '</section>')
915 })
916 }
917 case 'git-update':
918 return getRepoName(about, author, c.repo, function (err, repoName) {
919 if (err) return cb(err)
920 var repoLink = link([c.repo], repoName)
921 cb(null, '<section class="collapse">' + msgLink + '<br>' +
922 req._t('Pushed', {
923 name: authorLink,
924 repo: repoLink
925 }) + '</section>')
926 })
927 case 'issue':
928 case 'pull-request':
929 var issueLink = link([msg.key], c.title)
930 return getMsg(c.project, function (err, projectMsg) {
931 if (err) return cb(null, serveRepoNotFound(req, c.repo, err))
932 getRepoName(about, projectMsg.author, c.project,
933 function (err, repoName) {
934 if (err) return cb(err)
935 var repoLink = link([c.project], repoName)
936 cb(null, '<section class="collapse">' + msgLink + '<br>' +
937 req._t('OpenedIssue', {
938 name: authorLink,
939 type: req._t(c.type == 'pull-request' ?
940 'pull request' : 'issue.'),
941 title: issueLink,
942 project: repoLink
943 }) + '</section>')
944 })
945 })
946 case 'about':
947 return cb(null, '<section class="collapse">' + msgLink + '<br>' +
948 req._t('Named', {
949 author: authorLink,
950 target: '<tt>' + escapeHTML(c.about) + '</tt>',
951 name: link([c.about], c.name)
952 }) + '</section>')
953 default:
954 return cb(null, json(msg))
955 }
956 }
957
958 /* Index */
959
960 function serveIndex(req) {
961 return serveTemplate(req)(renderFeed(req))
962 }
963
964 function serveUserPage(req, feedId, dirs) {
965 switch (dirs[0]) {
966 case undefined:
967 case '':
968 case 'activity':
969 return serveUserActivity(req, feedId)
970 case 'repos':
971 return serveUserRepos(req, feedId)
972 }
973 }
974
975 function renderUserPage(req, feedId, page, body) {
976 return serveTemplate(req, feedId)(cat([
977 readOnce(function (cb) {
978 about.getName(feedId, function (err, name) {
979 cb(null, '<h2>' + link([feedId], name) +
980 '<code class="user-id">' + feedId + '</code></h2>' +
981 nav([
982 [[feedId], req._t('Activity'), 'activity'],
983 [[feedId, 'repos'], req._t('Repos'), 'repos']
984 ], page))
985 })
986 }),
987 body,
988 ]))
989 }
990
991 function serveUserActivity(req, feedId) {
992 return renderUserPage(req, feedId, 'activity', renderFeed(req, feedId))
993 }
994
995 function serveUserRepos(req, feedId) {
996 return renderUserPage(req, feedId, 'repos', pull(
997 cat([
998 ssb.messagesByType({
999 type: 'git-update',
1000 reverse: true
1001 }),
1002 ssb.messagesByType({
1003 type: 'git-repo',
1004 reverse: true
1005 })
1006 ]),
1007 pull.filter(function (msg) {
1008 return msg.value.author == feedId
1009 }),
1010 pull.unique(function (msg) {
1011 return msg.value.content.repo || msg.key
1012 }),
1013 pull.take(20),
1014 paramap(function (msg, cb) {
1015 var repoId = msg.value.content.repo || msg.key
1016 var done = multicb({ pluck: 1, spread: true })
1017 getRepoName(about, feedId, repoId, done())
1018 getVotes(repoId, done())
1019 done(function (err, repoName, votes) {
1020 if (err) return cb(err)
1021 cb(null, '<section class="collapse">' +
1022 '<span class="right-bar">' +
1023 '<i>โœŒ</i> ' +
1024 link([repoId, 'digs'], votes.upvotes, true,
1025 ' title="' + req._t('Digs') + '"') +
1026 '</span>' +
1027 '<strong>' + link([repoId], repoName) + '</strong>' +
1028 '<div class="date-info">' +
1029 req._t(msg.value.content.type == 'git-update' ?
1030 'UpdatedOnDate' : 'CreatedOnDate',
1031 {
1032 date: timestamp(msg.value.timestamp, req)
1033 }) + '</div>' +
1034 '</section>')
1035 })
1036 }, 8)
1037 ))
1038 }
1039
1040 /* Message */
1041
1042 function serveMessage(req, id, path) {
1043 return readNext(function (cb) {
1044 ssb.get(id, function (err, msg) {
1045 if (err) return cb(null, serveError(req, err))
1046 var c = msg.content || {}
1047 switch (c.type) {
1048 case 'git-repo':
1049 return getRepo(id, function (err, repo) {
1050 if (err) return cb(null, serveError(req, err))
1051 cb(null, serveRepoPage(req, Repo(repo), path))
1052 })
1053 case 'git-update':
1054 return getRepo(c.repo, function (err, repo) {
1055 if (err) return cb(null, serveRepoNotFound(req, c.repo, err))
1056 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
1057 })
1058 case 'issue':
1059 return getRepo(c.project, function (err, repo) {
1060 if (err) return cb(null, serveRepoNotFound(req, c.project, err))
1061 issues.get(id, function (err, issue) {
1062 if (err) return cb(null, serveError(req, err))
1063 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
1064 })
1065 })
1066 case 'pull-request':
1067 return getRepo(c.repo, function (err, repo) {
1068 if (err) return cb(null, serveRepoNotFound(req, c.project, err))
1069 pullReqs.get(id, function (err, pr) {
1070 if (err) return cb(null, serveError(req, err))
1071 cb(null, serveRepoPullReq(req, Repo(repo), pr, path))
1072 })
1073 })
1074 case 'issue-edit':
1075 if (ref.isMsgId(c.issue)) {
1076 return pullReqs.get(c.issue, function (err, issue) {
1077 if (err) return cb(err)
1078 var serve = issue.msg.value.content.type == 'pull-request' ?
1079 serveRepoPullReq : serveRepoIssue
1080 getRepo(issue.project, function (err, repo) {
1081 if (err) {
1082 if (!repo) return cb(null,
1083 serveRepoNotFound(req, c.repo, err))
1084 return cb(null, serveError(req, err))
1085 }
1086 cb(null, serve(req, Repo(repo), issue, path, id))
1087 })
1088 })
1089 }
1090 // fallthrough
1091 case 'post':
1092 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
1093 // comment on an issue
1094 var done = multicb({ pluck: 1, spread: true })
1095 getRepo(c.repo, done())
1096 pullReqs.get(c.issue, done())
1097 return done(function (err, repo, issue) {
1098 if (err) {
1099 if (!repo) return cb(null,
1100 serveRepoNotFound(req, c.repo, err))
1101 return cb(null, serveError(req, err))
1102 }
1103 var serve = issue.msg.value.content.type == 'pull-request' ?
1104 serveRepoPullReq : serveRepoIssue
1105 cb(null, serve(req, Repo(repo), issue, path, id))
1106 })
1107 } else if (ref.isMsgId(c.root)) {
1108 // comment on issue from patchwork?
1109 return getMsg(c.root, function (err, root) {
1110 if (err) return cb(null, serveError(req, err))
1111 var repoId = root.content.repo || root.content.project
1112 if (!ref.isMsgId(repoId))
1113 return cb(null, serveGenericMessage(req, id, msg, path))
1114 getRepo(repoId, function (err, repo) {
1115 if (err) return cb(null, serveError(req, err))
1116 switch (root.content && root.content.type) {
1117 case 'issue':
1118 return issues.get(c.root, function (err, issue) {
1119 if (err) return cb(null, serveError(req, err))
1120 return cb(null,
1121 serveRepoIssue(req, Repo(repo), issue, path, id))
1122 })
1123 case 'pull-request':
1124 pullReqs.get(c.root, function (err, pr) {
1125 if (err) return cb(null, serveError(req, err))
1126 return cb(null,
1127 serveRepoPullReq(req, Repo(repo), pr, path, id))
1128 })
1129 }
1130 })
1131 })
1132 }
1133 // fallthrough
1134 default:
1135 if (ref.isMsgId(c.repo))
1136 return getRepo(c.repo, function (err, repo) {
1137 if (err) return cb(null, serveRepoNotFound(req, c.repo, err))
1138 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
1139 })
1140 else
1141 return cb(null, serveGenericMessage(req, id, msg, path))
1142 }
1143 })
1144 })
1145 }
1146
1147 function serveGenericMessage(req, id, msg, path) {
1148 return serveTemplate(req, id)(pull.once(
1149 '<section><h2>' + link([id]) + '</h2>' +
1150 json(msg) +
1151 '</section>'))
1152 }
1153
1154 /* Repo */
1155
1156 function serveRepoPage(req, repo, path) {
1157 var defaultBranch = 'master'
1158 var query = req._u.query
1159
1160 if (query.rev != null) {
1161 // Allow navigating revs using GET query param.
1162 // Replace the branch in the path with the rev query value
1163 path[0] = path[0] || 'tree'
1164 path[1] = query.rev
1165 req._u.pathname = encodeLink([repo.id].concat(path))
1166 delete req._u.query.rev
1167 delete req._u.search
1168 return serveRedirect(req, url.format(req._u))
1169 }
1170
1171 // get branch
1172 return path[1] ?
1173 serveRepoPage2(req, repo, path) :
1174 readNext(function (cb) {
1175 // TODO: handle this in pull-git-repo or ssb-git-repo
1176 repo.getSymRef('HEAD', true, function (err, ref) {
1177 if (err) return cb(err)
1178 repo.resolveRef(ref, function (err, rev) {
1179 path[1] = rev ? ref : null
1180 cb(null, serveRepoPage2(req, repo, path))
1181 })
1182 })
1183 })
1184 }
1185
1186 function serveRepoPage2(req, repo, path) {
1187 var branch = path[1]
1188 var filePath = path.slice(2)
1189 switch (path[0]) {
1190 case undefined:
1191 case '':
1192 return serveRepoTree(req, repo, branch, [])
1193 case 'activity':
1194 return serveRepoActivity(req, repo, branch)
1195 case 'commits':
1196 return serveRepoCommits(req, repo, branch)
1197 case 'commit':
1198 return serveRepoCommit(req, repo, path[1])
1199 case 'tree':
1200 return serveRepoTree(req, repo, branch, filePath)
1201 case 'blob':
1202 return serveRepoBlob(req, repo, branch, filePath)
1203 case 'raw':
1204 return serveRepoRaw(req, repo, branch, filePath)
1205 case 'digs':
1206 return serveRepoDigs(req, repo)
1207 case 'fork':
1208 return serveRepoForkPrompt(req, repo)
1209 case 'forks':
1210 return serveRepoForks(req, repo)
1211 case 'issues':
1212 switch (path[1]) {
1213 case 'new':
1214 if (filePath.length == 0)
1215 return serveRepoNewIssue(req, repo)
1216 break
1217 default:
1218 return serveRepoIssues(req, repo, false)
1219 }
1220 case 'pulls':
1221 return serveRepoIssues(req, repo, true)
1222 case 'compare':
1223 return serveRepoCompare(req, repo)
1224 case 'comparing':
1225 return serveRepoComparing(req, repo)
1226 default:
1227 return serve404(req)
1228 }
1229 }
1230
1231 function serveRepoNotFound(req, id, err) {
1232 return serveTemplate(req, req._t('error.RepoNotFound'), 404)(pull.values([
1233 '<h2>' + req._t('error.RepoNotFound') + '</h2>',
1234 '<p>' + req._t('error.RepoNameNotFound') + '</p>',
1235 '<pre>' + escapeHTML(err.stack) + '</pre>'
1236 ]))
1237 }
1238
1239 function renderRepoPage(req, repo, page, branch, body) {
1240 var gitUrl = 'ssb://' + repo.id
1241 var gitLink = '<input class="clone-url" readonly="readonly" ' +
1242 'value="' + gitUrl + '" size="45" ' +
1243 'onclick="this.select()"/>'
1244 var digsPath = [repo.id, 'digs']
1245
1246 var done = multicb({ pluck: 1, spread: true })
1247 getRepoName(about, repo.feed, repo.id, done())
1248 about.getName(repo.feed, done())
1249 getVotes(repo.id, done())
1250
1251 if (repo.upstream) {
1252 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1253 about.getName(repo.upstream.feed, done())
1254 }
1255
1256 return readNext(function (cb) {
1257 done(function (err, repoName, authorName, votes,
1258 upstreamName, upstreamAuthorName) {
1259 if (err) return cb(null, serveError(req, err))
1260 var upvoted = votes.upvoters[myId] > 0
1261 var upstreamLink = !repo.upstream ? '' :
1262 link([repo.upstream])
1263 cb(null, serveTemplate(req, repo.id)(cat([
1264 pull.once(
1265 '<div class="repo-title">' +
1266 '<form class="right-bar" action="" method="post">' +
1267 '<button class="btn" name="action" value="vote" ' +
1268 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1269 '<i>โœŒ</i> ' + req._t(!isPublic && upvoted ? 'Undig' : 'Dig') +
1270 '</button>' +
1271 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1272 (upvoted ? '0' : '1') + '">' +
1273 '<input type="hidden" name="id" value="' +
1274 escapeHTML(repo.id) + '">') + ' ' +
1275 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1276 (isPublic ? '' : '<button class="btn" type="submit" ' +
1277 ' name="action" value="fork-prompt">' +
1278 '<i>โ‘‚</i> ' + req._t('Fork') +
1279 '</button>') + ' ' +
1280 link([repo.id, 'forks'], '+', false, ' title="' +
1281 req._t('Forks') + '"') +
1282 '</form>' +
1283 renderNameForm(req, !isPublic, repo.id, repoName, 'repo-name',
1284 null, req._t('repo.Rename'),
1285 '<h2 class="bgslash">' + link([repo.feed], authorName) + ' / ' +
1286 link([repo.id], repoName) + '</h2>') +
1287 '</div>' +
1288 (repo.upstream ? '<small class="bgslash">' + req._t('ForkedFrom', {
1289 repo: link([repo.upstream.feed], upstreamAuthorName) + '/' +
1290 link([repo.upstream.id], upstreamName)
1291 }) + '</small>' : '') +
1292 nav([
1293 [[repo.id], req._t('Code'), 'code'],
1294 [[repo.id, 'activity'], req._t('Activity'), 'activity'],
1295 [[repo.id, 'commits', branch||''], req._t('Commits'), 'commits'],
1296 [[repo.id, 'issues'], req._t('Issues'), 'issues'],
1297 [[repo.id, 'pulls'], req._t('PullRequests'), 'pulls']
1298 ], page, gitLink)),
1299 body
1300 ])))
1301 })
1302 })
1303 }
1304
1305 function serveEmptyRepo(req, repo) {
1306 if (repo.feed != myId)
1307 return renderRepoPage(req, repo, 'code', null, pull.once(
1308 '<section>' +
1309 '<h3>' + req._t('EmptyRepo') + '</h3>' +
1310 '</section>'))
1311
1312 var gitUrl = 'ssb://' + repo.id
1313 return renderRepoPage(req, repo, 'code', null, pull.once(
1314 '<section>' +
1315 '<h3>' + req._t('initRepo.GettingStarted') + '</h3>' +
1316 '<h4>' + req._t('initRepo.CreateNew') + '</h4><pre>' +
1317 'touch ' + req._t('initRepo.README') + '.md\n' +
1318 'git init\n' +
1319 'git add ' + req._t('initRepo.README') + '.md\n' +
1320 'git commit -m "' + req._t('initRepo.InitialCommit') + '"\n' +
1321 'git remote add origin ' + gitUrl + '\n' +
1322 'git push -u origin master</pre>\n' +
1323 '<h4>' + req._t('initRepo.PushExisting') + '</h4>\n' +
1324 '<pre>git remote add origin ' + gitUrl + '\n' +
1325 'git push -u origin master</pre>' +
1326 '</section>'))
1327 }
1328
1329 function serveRepoTree(req, repo, rev, path) {
1330 if (!rev) return serveEmptyRepo(req, repo)
1331 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1332 return renderRepoPage(req, repo, 'code', rev, cat([
1333 pull.once('<section><form action="" method="get">' +
1334 '<h3>' + req._t(type) + ': ' + rev + ' '),
1335 revMenu(req, repo, rev),
1336 pull.once('</h3></form>'),
1337 type == 'Branch' && renderRepoLatest(req, repo, rev),
1338 pull.once('</section><section>'),
1339 renderRepoTree(req, repo, rev, path),
1340 pull.once('</section>'),
1341 renderRepoReadme(req, repo, rev, path)
1342 ]))
1343 }
1344
1345 /* Search */
1346
1347 function serveSearch(req) {
1348 var q = String(req._u.query.q || '')
1349 if (!q) return serveIndex(req)
1350 var qId = q.replace(/^ssb:\/*/, '')
1351 if (ref.type(qId))
1352 return serveRedirect(req, encodeURI(qId))
1353
1354 var search = new RegExp(q, 'i')
1355 return serveTemplate(req, req._t('Search') + ' &middot; ' + q, 200)(
1356 renderFeed(req, null, function (opts) {
1357 opts.type == 'about'
1358 return function (read) {
1359 return pull(
1360 many([
1361 getRepoNames(opts),
1362 read
1363 ]),
1364 pull.filter(function (msg) {
1365 var c = msg.value.content
1366 return (
1367 search.test(msg.key) ||
1368 c.text && search.test(c.text) ||
1369 c.name && search.test(c.name) ||
1370 c.title && search.test(c.title))
1371 })
1372 )
1373 }
1374 })
1375 )
1376 }
1377
1378 function getRepoNames(opts) {
1379 return pull(
1380 ssb.messagesByType({
1381 type: 'about',
1382 reverse: opts.reverse,
1383 lt: opts.lt,
1384 gt: opts.gt,
1385 }),
1386 pull.filter(function (msg) {
1387 return '%' == String(msg.value.content.about)[0]
1388 && msg.value.content.name
1389 })
1390 )
1391 }
1392
1393 /* Repo activity */
1394
1395 function serveRepoActivity(req, repo, branch) {
1396 return renderRepoPage(req, repo, 'activity', branch, cat([
1397 pull.once('<h3>' + req._t('Activity') + '</h3>'),
1398 pull(
1399 ssb.links({
1400 dest: repo.id,
1401 source: repo.feed,
1402 rel: 'repo',
1403 values: true,
1404 reverse: true
1405 }),
1406 pull.map(renderRepoUpdate.bind(this, req, repo))
1407 ),
1408 readOnce(function (cb) {
1409 var done = multicb({ pluck: 1, spread: true })
1410 about.getName(repo.feed, done())
1411 getMsg(repo.id, done())
1412 done(function (err, authorName, msg) {
1413 if (err) return cb(err)
1414 renderFeedItem(req, {
1415 key: repo.id,
1416 value: msg,
1417 authorName: authorName
1418 }, cb)
1419 })
1420 })
1421 ]))
1422 }
1423
1424 function renderRepoUpdate(req, repo, msg, full) {
1425 var c = msg.value.content
1426
1427 if (c.type != 'git-update') {
1428 return ''
1429 // return renderFeedItem(msg, cb)
1430 // TODO: render post, issue, pull-request
1431 }
1432
1433 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1434 return {name: ref, value: c.refs[ref]}
1435 }) : []
1436 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1437
1438 var dateStr = new Date(msg.value.timestamp).toLocaleString(req._locale)
1439 return '<section class="collapse">' +
1440 link([msg.key], dateStr) + '<br>' +
1441 (numObjects ? req._t('PushedObjects', numObjects) + '<br/>': '') +
1442 refs.map(function (update) {
1443 var name = escapeHTML(update.name)
1444 if (!update.value) {
1445 return req._t('DeletedBranch', {branch: name})
1446 } else {
1447 var commitLink = link([repo.id, 'commit', update.value])
1448 return name + ' &rarr; ' + commitLink
1449 }
1450 }).join('<br>') +
1451 '</section>'
1452 }
1453
1454 /* Repo commits */
1455
1456 function serveRepoCommits(req, repo, branch) {
1457 var query = req._u.query
1458 return renderRepoPage(req, repo, 'commits', branch, cat([
1459 pull.once('<h3>' + req._t('Commits') + '</h3>'),
1460 pull(
1461 repo.readLog(query.start || branch),
1462 pull.take(20),
1463 paramap(repo.getCommitParsed.bind(repo), 8),
1464 paginate(
1465 !query.start ? '' : function (first, cb) {
1466 cb(null, '&hellip;')
1467 },
1468 pull.map(renderCommit.bind(this, req, repo)),
1469 function (commit, cb) {
1470 cb(null, commit.parents && commit.parents[0] ?
1471 '<a href="?start=' + commit.id + '">' +
1472 req._t('Older') + '</a>' : '')
1473 }
1474 )
1475 )
1476 ]))
1477 }
1478
1479 function renderCommit(req, repo, commit) {
1480 var commitPath = [repo.id, 'commit', commit.id]
1481 var treePath = [repo.id, 'tree', commit.id]
1482 return '<section class="collapse">' +
1483 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1484 '<tt>' + commit.id + '</tt> ' +
1485 link(treePath, req._t('Tree')) + '<br>' +
1486 escapeHTML(commit.author.name) + ' &middot; ' +
1487 commit.author.date.toLocaleString(req._locale) +
1488 (commit.separateAuthor ? '<br>' + req._t('CommittedOn', {
1489 name: escapeHTML(commit.committer.name),
1490 date: commit.committer.date.toLocaleString(req._locale)
1491 }) : '') +
1492 '</section>'
1493 }
1494
1495 /* Branch menu */
1496
1497 function formatRevOptions(currentName) {
1498 return function (name) {
1499 var htmlName = escapeHTML(name)
1500 return '<option value="' + htmlName + '"' +
1501 (name == currentName ? ' selected="selected"' : '') +
1502 '>' + htmlName + '</option>'
1503 }
1504 }
1505
1506 function formatRevType(req, type) {
1507 return (
1508 type == 'heads' ? req._t('Branches') :
1509 type == 'tags' ? req._t('Tags') :
1510 type)
1511 }
1512
1513 function revMenu(req, repo, currentName) {
1514 return readOnce(function (cb) {
1515 repo.getRefNames(function (err, refs) {
1516 if (err) return cb(err)
1517 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1518 Object.keys(refs).map(function (group) {
1519 return '<optgroup label="' + formatRevType(req, group) + '">' +
1520 refs[group].map(formatRevOptions(currentName)).join('') +
1521 '</optgroup>'
1522 }).join('') +
1523 '</select><noscript> ' +
1524 '<input type="submit" value="' + req._t('Go') + '"/></noscript>')
1525 })
1526 })
1527 }
1528
1529 function branchMenu(repo, name, currentName) {
1530 return cat([
1531 pull.once('<select name="' + name + '">'),
1532 pull(
1533 repo.refs(),
1534 pull.map(function (ref) {
1535 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
1536 return m[1] == 'heads' && m[2]
1537 }),
1538 pull.filter(Boolean),
1539 pullSort(),
1540 pull.map(formatRevOptions(currentName))
1541 ),
1542 pull.once('</select>')
1543 ])
1544 }
1545
1546 /* Repo tree */
1547
1548 function renderRepoLatest(req, repo, rev) {
1549 return readOnce(function (cb) {
1550 repo.getCommitParsed(rev, function (err, commit) {
1551 if (err) return cb(err)
1552 var commitPath = [repo.id, 'commit', commit.id]
1553 cb(null,
1554 req._t('Latest') + ': ' +
1555 '<strong>' + link(commitPath, commit.title) + '</strong><br/>' +
1556 '<tt>' + commit.id + '</tt><br/> ' +
1557 req._t('CommittedOn', {
1558 name: escapeHTML(commit.committer.name),
1559 date: commit.committer.date.toLocaleString(req._locale)
1560 }) +
1561 (commit.separateAuthor ? '<br/>' + req._t('AuthoredOn', {
1562 name: escapeHTML(commit.author.name),
1563 date: commit.author.date.toLocaleString(req._locale)
1564 }) : ''))
1565 })
1566 })
1567 }
1568
1569 // breadcrumbs
1570 function linkPath(basePath, path) {
1571 path = path.slice()
1572 var last = path.pop()
1573 return path.map(function (dir, i) {
1574 return link(basePath.concat(path.slice(0, i+1)), dir)
1575 }).concat(last).join(' / ')
1576 }
1577
1578 function renderRepoTree(req, repo, rev, path) {
1579 var pathLinks = path.length === 0 ? '' :
1580 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1581 return cat([
1582 pull.once('<h3>' + req._t('Files') + pathLinks + '</h3>'),
1583 pull(
1584 repo.readDir(rev, path),
1585 pull.map(function (file) {
1586 var type = (file.mode === 040000) ? 'tree' :
1587 (file.mode === 0160000) ? 'commit' : 'blob'
1588 if (type == 'commit')
1589 return [
1590 '<span title="' + req._t('gitCommitLink') + '">๐Ÿ–ˆ</span>',
1591 '<span title="' + escapeHTML(file.id) + '">' +
1592 escapeHTML(file.name) + '</span>']
1593 var filePath = [repo.id, type, rev].concat(path, file.name)
1594 return ['<i>' + (type == 'tree' ? '๐Ÿ“' : '๐Ÿ“„') + '</i>',
1595 link(filePath, file.name)]
1596 }),
1597 table('class="files"')
1598 )
1599 ])
1600 }
1601
1602 /* Repo readme */
1603
1604 function renderRepoReadme(req, repo, branch, path) {
1605 return readNext(function (cb) {
1606 pull(
1607 repo.readDir(branch, path),
1608 pull.filter(function (file) {
1609 return /readme(\.|$)/i.test(file.name)
1610 }),
1611 pull.take(1),
1612 pull.collect(function (err, files) {
1613 if (err) return cb(null, pull.empty())
1614 var file = files[0]
1615 if (!file)
1616 return cb(null, pull.once(path.length ? '' :
1617 '<p>' + req._t('NoReadme') + '</p>'))
1618 repo.getObjectFromAny(file.id, function (err, obj) {
1619 if (err) return cb(err)
1620 cb(null, cat([
1621 pull.once('<section><h4><a name="readme">' +
1622 escapeHTML(file.name) + '</a></h4><hr/>'),
1623 renderObjectData(obj, file.name, repo, branch, path),
1624 pull.once('</section>')
1625 ]))
1626 })
1627 })
1628 )
1629 })
1630 }
1631
1632 /* Repo commit */
1633
1634 function serveRepoCommit(req, repo, rev) {
1635 return renderRepoPage(req, repo, null, rev, cat([
1636 readNext(function (cb) {
1637 repo.getCommitParsed(rev, function (err, commit) {
1638 if (err) return cb(err)
1639 var commitPath = [repo.id, 'commit', commit.id]
1640 var treePath = [repo.id, 'tree', commit.id]
1641 cb(null, cat([pull.once(
1642 '<h3>' + link(commitPath,
1643 req._t('CommitRev', {rev: rev})) + '</h3>' +
1644 '<section class="collapse">' +
1645 '<div class="right-bar">' +
1646 link(treePath, req._t('BrowseFiles')) +
1647 '</div>' +
1648 '<h4>' + linkify(escapeHTML(commit.title)) + '</h4>' +
1649 (commit.body ? linkify(pre(commit.body)) : '') +
1650 (commit.separateAuthor ? req._t('AuthoredOn', {
1651 name: escapeHTML(commit.author.name),
1652 date: commit.author.date.toLocaleString(req._locale)
1653 }) + '<br/>' : '') +
1654 req._t('CommittedOn', {
1655 name: escapeHTML(commit.committer.name),
1656 date: commit.committer.date.toLocaleString(req._locale)
1657 }) + '<br/>' +
1658 commit.parents.map(function (id) {
1659 return req._t('Parent') + ': ' +
1660 link([repo.id, 'commit', id], id)
1661 }).join('<br>') +
1662 '</section>' +
1663 '<section><h3>' + req._t('FilesChanged') + '</h3>'),
1664 // TODO: show diff from all parents (merge commits)
1665 renderDiffStat(req, [repo, repo], [commit.parents[0], commit.id]),
1666 pull.once('</section>')
1667 ]))
1668 })
1669 })
1670 ]))
1671 }
1672
1673 /* Diff stat */
1674
1675 function renderDiffStat(req, repos, treeIds) {
1676 if (treeIds.length == 0) treeIds = [null]
1677 var id = treeIds[0]
1678 var lastI = treeIds.length - 1
1679 var oldTree = treeIds[0]
1680 var changedFiles = []
1681 return cat([
1682 pull(
1683 Repo.diffTrees(repos, treeIds, true),
1684 pull.map(function (item) {
1685 var filename = escapeHTML(item.filename = item.path.join('/'))
1686 var oldId = item.id && item.id[0]
1687 var newId = item.id && item.id[lastI]
1688 var oldMode = item.mode && item.mode[0]
1689 var newMode = item.mode && item.mode[lastI]
1690 var action =
1691 !oldId && newId ? req._t('action.added') :
1692 oldId && !newId ? req._t('action.deleted') :
1693 oldMode != newMode ? req._t('action.changedMode', {
1694 old: oldMode.toString(8),
1695 new: newMode.toString(8)
1696 }) : req._t('changed')
1697 if (item.id)
1698 changedFiles.push(item)
1699 var blobsPath = item.id[1]
1700 ? [repos[1].id, 'blob', treeIds[1]]
1701 : [repos[0].id, 'blob', treeIds[0]]
1702 var rawsPath = item.id[1]
1703 ? [repos[1].id, 'raw', treeIds[1]]
1704 : [repos[0].id, 'raw', treeIds[0]]
1705 item.blobPath = blobsPath.concat(item.path)
1706 item.rawPath = rawsPath.concat(item.path)
1707 var fileHref = item.id ?
1708 '#' + encodeURIComponent(item.path.join('/')) :
1709 encodeLink(item.blobPath)
1710 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1711 }),
1712 table()
1713 ),
1714 pull(
1715 pull.values(changedFiles),
1716 paramap(function (item, cb) {
1717 var extension = getExtension(item.filename)
1718 if (extension in imgMimes) {
1719 var filename = escapeHTML(item.filename)
1720 return cb(null,
1721 '<pre><table class="code">' +
1722 '<tr><th id="' + escapeHTML(item.filename) + '">' +
1723 filename + '</th></tr>' +
1724 '<tr><td><img src="' + encodeLink(item.rawPath) + '"' +
1725 ' alt="' + filename + '"/></td></tr>' +
1726 '</table></pre>')
1727 }
1728 var done = multicb({ pluck: 1, spread: true })
1729 getRepoObjectString(repos[0], item.id[0], done())
1730 getRepoObjectString(repos[1], item.id[lastI], done())
1731 done(function (err, strOld, strNew) {
1732 if (err) return cb(err)
1733 cb(null, htmlLineDiff(req, item.filename, item.filename,
1734 strOld, strNew,
1735 encodeLink(item.blobPath)))
1736 })
1737 }, 4)
1738 )
1739 ])
1740 }
1741
1742 function htmlLineDiff(req, filename, anchor, oldStr, newStr, blobHref) {
1743 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1744 var groups = diff.hunks.map(function (hunk) {
1745 var oldLine = hunk.oldStart
1746 var newLine = hunk.newStart
1747 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1748 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1749 '+' + newLine + ',' + hunk.newLines + ' @@' +
1750 '</td></tr>'
1751 return [header].concat(hunk.lines.map(function (line) {
1752 var s = line[0]
1753 if (s == '\\') return
1754 var html = highlight(line, getExtension(filename))
1755 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1756 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1757 var id = [filename].concat(lineNums).join('-')
1758 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1759 lineNums.map(function (num) {
1760 return '<td class="code-linenum">' +
1761 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1762 num + '</a>' : '') + '</td>'
1763 }).join('') +
1764 '<td class="code-text">' + html + '</td></tr>'
1765 }))
1766 })
1767 return '<pre><table class="code">' +
1768 '<tr><th colspan=3 id="' + escapeHTML(anchor) + '">' + filename +
1769 '<span class="right-bar">' +
1770 '<a href="' + blobHref + '">' + req._t('View') + '</a> ' +
1771 '</span></th></tr>' +
1772 [].concat.apply([], groups).join('') +
1773 '</table></pre>'
1774 }
1775
1776 /* An unknown message linking to a repo */
1777
1778 function serveRepoSomething(req, repo, id, msg, path) {
1779 return renderRepoPage(req, repo, null, null,
1780 pull.once('<section><h3>' + link([id]) + '</h3>' +
1781 json(msg) + '</section>'))
1782 }
1783
1784 /* Repo update */
1785
1786 function objsArr(objs) {
1787 return Array.isArray(objs) ? objs :
1788 Object.keys(objs).map(function (sha1) {
1789 var obj = Object.create(objs[sha1])
1790 obj.sha1 = sha1
1791 return obj
1792 })
1793 }
1794
1795 function serveRepoUpdate(req, repo, id, msg, path) {
1796 var raw = req._u.query.raw != null
1797
1798 if (raw)
1799 return renderRepoPage(req, repo, 'activity', null, pull.once(
1800 '<a href="?" class="raw-link header-align">' +
1801 req._t('Info') + '</a>' +
1802 '<h3>' + req._t('Update') + '</h3>' +
1803 '<section class="collapse">' +
1804 json({key: id, value: msg}) + '</section>'))
1805
1806 // convert packs to old single-object style
1807 if (msg.content.indexes) {
1808 for (var i = 0; i < msg.content.indexes.length; i++) {
1809 msg.content.packs[i] = {
1810 pack: {link: msg.content.packs[i].link},
1811 idx: msg.content.indexes[i]
1812 }
1813 }
1814 }
1815
1816 return renderRepoPage(req, repo, 'activity', null, cat([
1817 pull.values([
1818 '<a href="?raw" class="raw-link header-align">' +
1819 req._t('Data') + '</a>',
1820 '<h3>' + req._t('Update') + '</h3>',
1821 renderRepoUpdate(req, repo, {key: id, value: msg}, true)
1822 ].concat(msg.content.objects ?
1823 ['<h3>' + req._t('Objects') + '</h3>'].concat(
1824 objsArr(msg.content.objects).map(renderObject.bind(null, req)))
1825 : [],
1826 msg.content.packs ? [
1827 '<h3>' + req._t('Packs') + '</h3>'
1828 ].concat(msg.content.packs.map(renderPack.bind(null, req)))
1829 : [])),
1830 msg.content.packs && cat([
1831 pull.once('<h3>' + req._t('Commits') + '</h3>'),
1832 pull(
1833 pull.values(msg.content.packs),
1834 pull.asyncMap(function (pack, cb) {
1835 var done = multicb({ pluck: 1, spread: true })
1836 getBlob(req, pack.pack.link, done())
1837 getBlob(req, pack.idx.link, done())
1838 done(function (err, readPack, readIdx) {
1839 if (err) return cb(renderError(err))
1840 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1841 })
1842 }),
1843 pull.flatten(),
1844 pull.asyncMap(function (obj, cb) {
1845 if (obj.type == 'commit')
1846 Repo.getCommitParsed(obj, cb)
1847 else
1848 pull(obj.read, pull.drain(null, cb))
1849 }),
1850 pull.filter(),
1851 pull.map(function (commit) {
1852 return renderCommit(req, repo, commit)
1853 })
1854 )
1855 ])
1856 ]))
1857 }
1858
1859 function renderObject(req, obj) {
1860 return '<section class="collapse">' +
1861 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1862 req._t('NumBytes', obj.length) +
1863 '</section>'
1864 }
1865
1866 function renderPack(req, info) {
1867 return '<section class="collapse">' +
1868 (info.pack ? req._t('Pack') + ': ' +
1869 link([info.pack.link]) + '<br>' : '') +
1870 (info.idx ? req._t('Index') + ': ' +
1871 link([info.idx.link]) : '') + '</section>'
1872 }
1873
1874 /* Blob */
1875
1876 function serveRepoBlob(req, repo, rev, path) {
1877 return readNext(function (cb) {
1878 repo.getFile(rev, path, function (err, object) {
1879 if (err) return cb(null, serveBlobNotFound(req, repo.id, err))
1880 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1881 var pathLinks = path.length === 0 ? '' :
1882 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1883 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1884 var dirPath = path.slice(0, path.length-1)
1885 var filename = path[path.length-1]
1886 var extension = getExtension(filename)
1887 cb(null, renderRepoPage(req, repo, 'code', rev, cat([
1888 pull.once('<section><form action="" method="get">' +
1889 '<h3>' + req._t(type) + ': ' + rev + ' '),
1890 revMenu(req, repo, rev),
1891 pull.once('</h3></form>'),
1892 type == 'Branch' && renderRepoLatest(req, repo, rev),
1893 pull.once('</section><section class="collapse">' +
1894 '<h3>' + req._t('Files') + pathLinks + '</h3>' +
1895 '<div>' + object.length + ' bytes' +
1896 '<span class="raw-link">' +
1897 link(rawFilePath, req._t('Raw')) + '</span>' +
1898 '</div></section>' +
1899 '<section>'),
1900 extension in imgMimes
1901 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1902 '" alt="' + escapeHTML(filename) + '" />')
1903 : renderObjectData(object, filename, repo, rev, dirPath),
1904 pull.once('</section>')
1905 ])))
1906 })
1907 })
1908 }
1909
1910 function serveBlobNotFound(req, repoId, err) {
1911 return serveTemplate(req, req._t('error.BlobNotFound'), 404)(pull.once(
1912 '<h2>' + req._t('error.BlobNotFound') + '</h2>' +
1913 '<p>' + req._t('error.BlobNotFoundInRepo', {
1914 repo: link([repoId])
1915 }) + '</p>' +
1916 '<pre>' + escapeHTML(err.stack) + '</pre>'
1917 ))
1918 }
1919
1920 /* Raw blob */
1921
1922 function serveRepoRaw(req, repo, branch, path) {
1923 return readNext(function (cb) {
1924 repo.getFile(branch, path, function (err, object) {
1925 if (err) return cb(null,
1926 serveBuffer(404, req._t('error.BlobNotFound')))
1927 var extension = getExtension(path[path.length-1])
1928 var contentType = imgMimes[extension]
1929 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1930 })
1931 })
1932 }
1933
1934 function serveRaw(length, contentType) {
1935 var headers = {
1936 'Content-Type': contentType || 'text/plain; charset=utf-8',
1937 'Cache-Control': 'max-age=31536000'
1938 }
1939 if (length != null)
1940 headers['Content-Length'] = length
1941 return function (read) {
1942 return cat([pull.once([200, headers]), read])
1943 }
1944 }
1945
1946 function getBlob(req, key, cb) {
1947 ssb.blobs.want(key, function (err, got) {
1948 if (err) cb(err)
1949 else if (!got) cb(new Error(req._t('error.MissingBlob', {key: key})))
1950 else cb(null, ssb.blobs.get(key))
1951 })
1952 }
1953
1954 function serveBlob(req, key) {
1955 return readNext(function (cb) {
1956 getBlob(req, key, function (err, read) {
1957 if (err) cb(null, serveError(req, err))
1958 else if (!read) cb(null, serve404(req))
1959 else cb(null, serveRaw()(read))
1960 })
1961 })
1962 }
1963
1964 /* Digs */
1965
1966 function serveRepoDigs(req, repo) {
1967 return readNext(function (cb) {
1968 getVotes(repo.id, function (err, votes) {
1969 cb(null, renderRepoPage(req, repo, null, null, cat([
1970 pull.once('<section><h3>' + req._t('Digs') + '</h3>' +
1971 '<div>' + req._t('Total') + ': ' + votes.upvotes + '</div>'),
1972 pull(
1973 pull.values(Object.keys(votes.upvoters)),
1974 paramap(function (feedId, cb) {
1975 about.getName(feedId, function (err, name) {
1976 if (err) return cb(err)
1977 cb(null, link([feedId], name))
1978 })
1979 }, 8),
1980 ul()
1981 ),
1982 pull.once('</section>')
1983 ])))
1984 })
1985 })
1986 }
1987
1988 /* Forks */
1989
1990 function getForks(repo, includeSelf) {
1991 return pull(
1992 cat([
1993 includeSelf && readOnce(function (cb) {
1994 getMsg(repo.id, function (err, value) {
1995 cb(err, value && {key: repo.id, value: value})
1996 })
1997 }),
1998 ssb.links({
1999 dest: repo.id,
2000 values: true,
2001 rel: 'upstream'
2002 })
2003 ]),
2004 pull.filter(function (msg) {
2005 return msg.value.content && msg.value.content.type == 'git-repo'
2006 }),
2007 paramap(function (msg, cb) {
2008 getRepoFullName(about, msg.value.author, msg.key,
2009 function (err, repoName, authorName) {
2010 if (err) return cb(err)
2011 cb(null, {
2012 key: msg.key,
2013 value: msg.value,
2014 repoName: repoName,
2015 authorName: authorName
2016 })
2017 })
2018 }, 8)
2019 )
2020 }
2021
2022 function serveRepoForks(req, repo) {
2023 var hasForks
2024 return renderRepoPage(req, repo, null, null, cat([
2025 pull.once('<h3>' + req._t('Forks') + '</h3>'),
2026 pull(
2027 getForks(repo),
2028 pull.map(function (msg) {
2029 hasForks = true
2030 return '<section class="collapse">' +
2031 link([msg.value.author], msg.authorName) + ' / ' +
2032 link([msg.key], msg.repoName) +
2033 '<span class="right-bar">' +
2034 timestamp(msg.value.timestamp, req) +
2035 '</span></section>'
2036 })
2037 ),
2038 readOnce(function (cb) {
2039 cb(null, hasForks ? '' : req._t('NoForks'))
2040 })
2041 ]))
2042 }
2043
2044 function serveRepoForkPrompt(req, repo) {
2045 return renderRepoPage(req, repo, null, null, pull.once(
2046 '<form action="" method="post" onreset="history.back()">' +
2047 '<h3>' + req._t('ForkRepoPrompt') + '</h3>' +
2048 '<p>' + hiddenInputs({ id: repo.id }) +
2049 '<button class="btn open" type="submit" name="action" value="fork">' +
2050 req._t('Fork') +
2051 '</button>' +
2052 ' <button class="btn" type="reset">' +
2053 req._t('Cancel') + '</button>' +
2054 '</p></form>'
2055 ))
2056 }
2057
2058 /* Issues */
2059
2060 function serveRepoIssues(req, repo, isPRs) {
2061 var count = 0
2062 var state = req._u.query.state || 'open'
2063 var newPath = isPRs ? [repo.id, 'compare'] : [repo.id, 'issues', 'new']
2064 return renderRepoPage(req, repo, isPRs ? 'pulls' : 'issues', null, cat([
2065 pull.once(
2066 (isPublic ? '' :
2067 '<form class="right-bar" method="get"' +
2068 ' action="' + encodeLink(newPath) + '">' +
2069 '<button class="btn">&plus; ' +
2070 req._t(isPRs ? 'pullRequest.New' : 'issue.New') +
2071 '</button>' +
2072 '</form>') +
2073 '<h3>' + req._t(isPRs ? 'PullRequests' : 'Issues') + '</h3>' +
2074 nav([
2075 ['?', req._t('issues.Open'), 'open'],
2076 ['?state=closed', req._t('issues.Closed'), 'closed'],
2077 ['?state=all', req._t('issues.All'), 'all']
2078 ], state)),
2079 pull(
2080 (isPRs ? pullReqs : issues).list({
2081 repo: repo.id,
2082 project: repo.id,
2083 open: {open: true, closed: false}[state]
2084 }),
2085 pull.map(function (issue) {
2086 count++
2087 var state = (issue.open ? 'open' : 'closed')
2088 var stateStr = req._t(issue.open ?
2089 'issue.state.Open' : 'issue.state.Closed')
2090 return '<section class="collapse">' +
2091 '<i class="issue-state issue-state-' + state + '"' +
2092 ' title="' + stateStr + '">โ—ผ</i> ' +
2093 '<a href="' + encodeLink(issue.id) + '">' +
2094 escapeHTML(issue.title) +
2095 '<span class="right-bar">' +
2096 new Date(issue.created_at).toLocaleString(req._locale) +
2097 '</span>' +
2098 '</a>' +
2099 '</section>'
2100 })
2101 ),
2102 readOnce(function (cb) {
2103 cb(null, count > 0 ? '' :
2104 '<p>' + req._t(isPRs ? 'NoPullRequests' : 'NoIssues') + '</p>')
2105 })
2106 ]))
2107 }
2108
2109 /* New Issue */
2110
2111 function serveRepoNewIssue(req, repo, issueId, path) {
2112 return renderRepoPage(req, repo, 'issues', null, pull.once(
2113 '<h3>' + req._t('issue.New') + '</h3>' +
2114 '<section><form action="" method="post">' +
2115 '<input type="hidden" name="action" value="new-issue">' +
2116 '<p><input class="wide-input" name="title" placeholder="' +
2117 req._t('issue.Title') + '" size="77" /></p>' +
2118 renderPostForm(req, repo, req._t('Description'), 8) +
2119 '<button type="submit" class="btn">' + req._t('Create') + '</button>' +
2120 '</form></section>'))
2121 }
2122
2123 /* Issue */
2124
2125 function serveRepoIssue(req, repo, issue, path, postId) {
2126 var isAuthor = (myId == issue.author) || (myId == repo.feed)
2127 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
2128 return renderRepoPage(req, repo, 'issues', null, cat([
2129 pull.once(
2130 renderNameForm(req, !isPublic, issue.id, issue.title, 'issue-title',
2131 null, req._t('issue.Rename'),
2132 '<h3>' + link([issue.id], issue.title) + '</h3>') +
2133 '<code>' + issue.id + '</code>' +
2134 '<section class="collapse">' +
2135 (issue.open
2136 ? '<strong class="issue-status open">' +
2137 req._t('issue.state.Open') + '</strong>'
2138 : '<strong class="issue-status closed">' +
2139 req._t('issue.state.Closed') + '</strong>')),
2140 readOnce(function (cb) {
2141 about.getName(issue.author, function (err, authorName) {
2142 if (err) return cb(err)
2143 var authorLink = link([issue.author], authorName)
2144 cb(null, req._t('issue.Opened',
2145 {name: authorLink, datetime: timestamp(issue.created_at, req)}))
2146 })
2147 }),
2148 pull.once('<hr/>' + markdown(issue.text, repo) + '</section>'),
2149 // render posts and edits
2150 pull(
2151 ssb.links({
2152 dest: issue.id,
2153 values: true
2154 }),
2155 pull.unique('key'),
2156 addAuthorName(about),
2157 sortMsgs(),
2158 pull.through(function (msg) {
2159 if (msg.value.timestamp > newestMsg.value.timestamp)
2160 newestMsg = msg
2161 }),
2162 pull.map(renderIssueActivityMsg.bind(null, req, repo, issue,
2163 req._t('issue.'), postId))
2164 ),
2165 isPublic ? pull.empty() : readOnce(function (cb) {
2166 cb(null, renderIssueCommentForm(req, issue, repo, newestMsg.key,
2167 isAuthor, req._t('issue.')))
2168 })
2169 ]))
2170 }
2171
2172 function renderIssueActivityMsg(req, repo, issue, type, postId, msg) {
2173 var authorLink = link([msg.value.author], msg.authorName)
2174 var msgHref = encodeLink(msg.key) + '#' + encodeURIComponent(msg.key)
2175 var msgTimeLink = '<a href="' + msgHref + '"' +
2176 ' name="' + escapeHTML(msg.key) + '">' +
2177 new Date(msg.value.timestamp).toLocaleString(req._locale) + '</a>'
2178 var c = msg.value.content
2179 switch (c.type) {
2180 case 'post':
2181 if (c.root == issue.id) {
2182 var changed = issues.isStatusChanged(msg, issue)
2183 return '<section class="collapse">' +
2184 (msg.key == postId ? '<div class="highlight">' : '') +
2185 '<tt class="right-bar item-id">' + msg.key + '</tt> ' +
2186 (changed == null ? authorLink : req._t(
2187 changed ? 'issue.Reopened' : 'issue.Closed',
2188 {name: authorLink, type: type})) +
2189 ' &middot; ' + msgTimeLink +
2190 (msg.key == postId ? '</div>' : '') +
2191 markdown(c.text, repo) +
2192 '</section>'
2193 } else {
2194 var text = c.text || (c.type + ' ' + msg.key)
2195 return '<section class="collapse mention-preview">' +
2196 req._t('issue.MentionedIn', {
2197 name: authorLink,
2198 type: type,
2199 post: '<a href="/' + msg.key + '#' + msg.key + '">' +
2200 String(text).substr(0, 140) + '</a>'
2201 }) + '</section>'
2202 }
2203 case 'issue':
2204 case 'pull-request':
2205 return '<section class="collapse mention-preview">' +
2206 req._t('issue.MentionedIn', {
2207 name: authorLink,
2208 type: type,
2209 post: link([msg.key], String(c.title || msg.key).substr(0, 140))
2210 }) + '</section>'
2211 case 'issue-edit':
2212 return '<section class="collapse">' +
2213 (msg.key == postId ? '<div class="highlight">' : '') +
2214 (c.title == null ? '' : req._t('issue.Renamed', {
2215 name: authorLink,
2216 type: type,
2217 name: '<q>' + escapeHTML(c.title) + '</q>'
2218 })) + ' &middot; ' + msgTimeLink +
2219 (msg.key == postId ? '</div>' : '') +
2220 '</section>'
2221 case 'git-update':
2222 var mention = issues.getMention(msg, issue)
2223 if (mention) {
2224 var commitLink = link([repo.id, 'commit', mention.object],
2225 mention.label || mention.object)
2226 return '<section class="collapse">' +
2227 req._t(mention.open ? 'issue.Reopened' : 'issue.Closed', {
2228 name: authorLink,
2229 type: type
2230 }) + ' &middot; ' + msgTimeLink + '<br/>' +
2231 commitLink +
2232 '</section>'
2233 } else if ((mention = getMention(msg, issue.id))) {
2234 var commitLink = link(mention.object ?
2235 [repo.id, 'commit', mention.object] : [msg.key],
2236 mention.label || mention.object || msg.key)
2237 return '<section class="collapse">' +
2238 req._t('issue.Mentioned', {
2239 name: authorLink,
2240 type: type
2241 }) + ' &middot; ' + msgTimeLink + '<br/>' +
2242 commitLink +
2243 '</section>'
2244 } else {
2245 // fallthrough
2246 }
2247
2248 default:
2249 return '<section class="collapse">' +
2250 authorLink +
2251 ' &middot; ' + msgTimeLink +
2252 json(c) +
2253 '</section>'
2254 }
2255 }
2256
2257 function renderIssueCommentForm(req, issue, repo, branch, isAuthor, type) {
2258 return '<section><form action="" method="post">' +
2259 '<input type="hidden" name="action" value="comment">' +
2260 '<input type="hidden" name="id" value="' + issue.id + '">' +
2261 '<input type="hidden" name="issue" value="' + issue.id + '">' +
2262 '<input type="hidden" name="repo" value="' + repo.id + '">' +
2263 '<input type="hidden" name="branch" value="' + branch + '">' +
2264 renderPostForm(req, repo) +
2265 '<input type="submit" class="btn open" value="' +
2266 req._t('issue.Comment') + '" />' +
2267 (isAuthor ?
2268 '<input type="submit" class="btn"' +
2269 ' name="' + (issue.open ? 'close' : 'open') + '"' +
2270 ' value="' + req._t(
2271 issue.open ? 'issue.Close' : 'issue.Reopen', {type: type}
2272 ) + '"/>' : '') +
2273 '</form></section>'
2274 }
2275
2276 /* Pull Request */
2277
2278 function serveRepoPullReq(req, repo, pr, path, postId) {
2279 var headRepo, authorLink
2280 var page = path[0] || 'activity'
2281 return renderRepoPage(req, repo, 'pulls', null, cat([
2282 pull.once('<div class="pull-request">' +
2283 renderNameForm(req, !isPublic, pr.id, pr.title, 'issue-title', null,
2284 req._t('pullRequest.Rename'),
2285 '<h3>' + link([pr.id], pr.title) + '</h3>') +
2286 '<code>' + pr.id + '</code>'),
2287 readOnce(function (cb) {
2288 var done = multicb({ pluck: 1, spread: true })
2289 var gotHeadRepo = done()
2290 about.getName(pr.author, done())
2291 var sameRepo = (pr.headRepo == pr.baseRepo)
2292 getRepo(pr.headRepo, function (err, headRepo) {
2293 if (err) return cb(err)
2294 getRepoName(about, headRepo.feed, headRepo.id, done())
2295 about.getName(headRepo.feed, done())
2296 gotHeadRepo(null, Repo(headRepo))
2297 })
2298
2299 done(function (err, _headRepo, issueAuthorName,
2300 headRepoName, headRepoAuthorName) {
2301 if (err) return cb(err)
2302 headRepo = _headRepo
2303 authorLink = link([pr.author], issueAuthorName)
2304 var repoLink = link([pr.headRepo], headRepoName)
2305 var headRepoAuthorLink = link([headRepo.feed], headRepoAuthorName)
2306 var headRepoLink = link([headRepo.id], headRepoName)
2307 var headBranchLink = link([headRepo.id, 'tree', pr.headBranch])
2308 var baseBranchLink = link([repo.id, 'tree', pr.baseBranch])
2309 cb(null, '<section class="collapse">' +
2310 '<strong class="issue-status ' +
2311 (pr.open ? 'open' : 'closed') + '">' +
2312 req._t(pr.open ? 'issue.state.Open' : 'issue.state.Closed') +
2313 '</strong> ' +
2314 req._t('pullRequest.WantToMerge', {
2315 name: authorLink,
2316 base: '<code>' + baseBranchLink + '</code>',
2317 head: (sameRepo ?
2318 '<code>' + headBranchLink + '</code>' :
2319 '<code class="bgslash">' +
2320 headRepoAuthorLink + ' / ' +
2321 headRepoLink + ' / ' +
2322 headBranchLink + '</code>')
2323 }) + '</section>')
2324 })
2325 }),
2326 pull.once(
2327 nav([
2328 [[pr.id], req._t('Discussion'), 'activity'],
2329 [[pr.id, 'commits'], req._t('Commits'), 'commits'],
2330 [[pr.id, 'files'], req._t('Files'), 'files']
2331 ], page)),
2332 readNext(function (cb) {
2333 if (page == 'commits') cb(null,
2334 renderPullReqCommits(req, pr, repo, headRepo))
2335 else if (page == 'files') cb(null,
2336 renderPullReqFiles(req, pr, repo, headRepo))
2337 else cb(null,
2338 renderPullReqActivity(req, pr, repo, headRepo, authorLink, postId))
2339 })
2340 ]))
2341 }
2342
2343 function renderPullReqCommits(req, pr, baseRepo, headRepo) {
2344 return cat([
2345 pull.once('<section>'),
2346 renderCommitLog(req, baseRepo, pr.baseBranch, headRepo, pr.headBranch),
2347 pull.once('</section>')
2348 ])
2349 }
2350
2351 function renderPullReqFiles(req, pr, baseRepo, headRepo) {
2352 return cat([
2353 pull.once('<section>'),
2354 renderDiffStat(req,
2355 [baseRepo, headRepo], [pr.baseBranch, pr.headBranch]),
2356 pull.once('</section>')
2357 ])
2358 }
2359
2360 function renderPullReqActivity(req, pr, repo, headRepo, authorLink, postId) {
2361 var msgTimeLink = link([pr.id],
2362 new Date(pr.created_at).toLocaleString(req._locale))
2363 var newestMsg = {key: pr.id, value: {timestamp: pr.created_at}}
2364 var isAuthor = (myId == pr.author) || (myId == repo.feed)
2365 return cat([
2366 readOnce(function (cb) {
2367 cb(null,
2368 '<section class="collapse">' +
2369 authorLink + ' &middot; ' + msgTimeLink +
2370 markdown(pr.text, repo) + '</section>')
2371 }),
2372 // render posts, edits, and updates
2373 pull(
2374 many([
2375 ssb.links({
2376 dest: pr.id,
2377 values: true
2378 }),
2379 readNext(function (cb) {
2380 cb(null, pull(
2381 ssb.links({
2382 dest: headRepo.id,
2383 source: headRepo.feed,
2384 rel: 'repo',
2385 values: true,
2386 reverse: true
2387 }),
2388 pull.take(function (link) {
2389 return link.value.timestamp > pr.created_at
2390 }),
2391 pull.filter(function (link) {
2392 return link.value.content.type == 'git-update'
2393 && ('refs/heads/' + pr.headBranch) in link.value.content.refs
2394 })
2395 ))
2396 })
2397 ]),
2398 addAuthorName(about),
2399 pull.unique('key'),
2400 pull.through(function (msg) {
2401 if (msg.value.timestamp > newestMsg.value.timestamp)
2402 newestMsg = msg
2403 }),
2404 sortMsgs(),
2405 pull.map(function (item) {
2406 if (item.value.content.type == 'git-update')
2407 return renderBranchUpdate(req, pr, item)
2408 return renderIssueActivityMsg(req, repo, pr,
2409 req._t('pull request'), postId, item)
2410 })
2411 ),
2412 !isPublic && isAuthor && pr.open && pull.once(
2413 '<section class="merge-instructions">' +
2414 '<input type="checkbox" class="toggle" id="merge-instructions"/>' +
2415 '<h4><label for="merge-instructions" class="toggle-link"><a>' +
2416 req._t('mergeInstructions.MergeViaCmdLine') +
2417 '</a></label></h4>' +
2418 '<div class="contents">' +
2419 '<p>' + req._t('mergeInstructions.CheckOut') + '</p>' +
2420 '<pre>' +
2421 'git fetch ssb://' + escapeHTML(pr.headRepo) + ' ' +
2422 escapeHTML(pr.headBranch) + '\n' +
2423 'git checkout -b ' + escapeHTML(pr.headBranch) + ' FETCH_HEAD' +
2424 '</pre>' +
2425 '<p>' + req._t('mergeInstructions.MergeAndPush') + '</p>' +
2426 '<pre>' +
2427 'git checkout ' + escapeHTML(pr.baseBranch) + '\n' +
2428 'git merge ' + escapeHTML(pr.headBranch) + '\n' +
2429 'git push ssb ' + escapeHTML(pr.baseBranch) +
2430 '</pre>' +
2431 '</div></section>'),
2432 !isPublic && readOnce(function (cb) {
2433 cb(null, renderIssueCommentForm(req, pr, repo, newestMsg.key, isAuthor,
2434 req._t('pull request')))
2435 })
2436 ])
2437 }
2438
2439 function renderBranchUpdate(req, pr, msg) {
2440 var authorLink = link([msg.value.author], msg.authorName)
2441 var msgLink = link([msg.key],
2442 new Date(msg.value.timestamp).toLocaleString(req._locale))
2443 var rev = msg.value.content.refs['refs/heads/' + pr.headBranch]
2444 if (!rev)
2445 return '<section class="collapse">' +
2446 req._t('NameDeletedBranch', {
2447 name: authorLink,
2448 branch: '<code>' + pr.headBranch + '</code>'
2449 }) + ' &middot; ' + msgLink +
2450 '</section>'
2451
2452 var revLink = link([pr.headRepo, 'commit', rev], rev.substr(0, 8))
2453 return '<section class="collapse">' +
2454 req._t('NameUpdatedBranch', {
2455 name: authorLink,
2456 rev: '<code>' + revLink + '</code>'
2457 }) + ' &middot; ' + msgLink +
2458 '</section>'
2459 }
2460
2461 /* Compare changes */
2462
2463 function serveRepoCompare(req, repo) {
2464 var query = req._u.query
2465 var base
2466 var count = 0
2467
2468 return renderRepoPage(req, repo, 'pulls', null, cat([
2469 pull.once('<h3>' + req._t('CompareChanges') + '</h3>' +
2470 '<form action="' + encodeLink(repo.id) + '/comparing" method="get">' +
2471 '<section>'),
2472 pull.once(req._t('BaseBranch') + ': '),
2473 readNext(function (cb) {
2474 if (query.base) gotBase(null, query.base)
2475 else repo.getSymRef('HEAD', true, gotBase)
2476 function gotBase(err, ref) {
2477 if (err) return cb(err)
2478 cb(null, branchMenu(repo, 'base', base = ref || 'HEAD'))
2479 }
2480 }),
2481 pull.once('<br/>' + req._t('ComparisonRepoBranch') + ':'),
2482 pull(
2483 getForks(repo, true),
2484 pull.asyncMap(function (msg, cb) {
2485 getRepo(msg.key, function (err, repo) {
2486 if (err) return cb(err)
2487 cb(null, {
2488 msg: msg,
2489 repo: repo
2490 })
2491 })
2492 }),
2493 pull.map(renderFork),
2494 pull.flatten()
2495 ),
2496 pull.once('</section>'),
2497 readOnce(function (cb) {
2498 cb(null, count == 0 ? req._t('NoBranches') :
2499 '<button type="submit" class="btn">' +
2500 req._t('Compare') + '</button>')
2501 }),
2502 pull.once('</form>')
2503 ]))
2504
2505 function renderFork(fork) {
2506 return pull(
2507 fork.repo.refs(),
2508 pull.map(function (ref) {
2509 var m = /^refs\/([^\/]*)\/(.*)$/.exec(ref.name) || [,ref.name]
2510 return {
2511 type: m[1],
2512 name: m[2],
2513 value: ref.value
2514 }
2515 }),
2516 pull.filter(function (ref) {
2517 return ref.type == 'heads'
2518 && !(ref.name == base && fork.msg.key == repo.id)
2519 }),
2520 pull.map(function (ref) {
2521 var branchLink = link([fork.msg.key, 'tree', ref.name], ref.name)
2522 var authorLink = link([fork.msg.value.author], fork.msg.authorName)
2523 var repoLink = link([fork.msg.key], fork.msg.repoName)
2524 var value = fork.msg.key + ':' + ref.name
2525 count++
2526 return '<div class="bgslash">' +
2527 '<input type="radio" name="head"' +
2528 ' value="' + escapeHTML(value) + '"' +
2529 (query.head == value ? ' checked="checked"' : '') + '> ' +
2530 authorLink + ' / ' + repoLink + ' / ' + branchLink + '</div>'
2531 })
2532 )
2533 }
2534 }
2535
2536 function serveRepoComparing(req, repo) {
2537 var query = req._u.query
2538 var baseBranch = query.base
2539 var s = (query.head || '').split(':')
2540
2541 if (!s || !baseBranch)
2542 return serveRedirect(req, encodeLink([repo.id, 'compare']))
2543
2544 var headRepoId = s[0]
2545 var headBranch = s[1]
2546 var baseLink = link([repo.id, 'tree', baseBranch])
2547 var headBranchLink = link([headRepoId, 'tree', headBranch])
2548 var backHref = encodeLink([repo.id, 'compare']) + req._u.search
2549
2550 return renderRepoPage(req, repo, 'pulls', null, cat([
2551 pull.once('<h3>' +
2552 req._t(query.expand ? 'OpenPullRequest': 'ComparingChanges') +
2553 '</h3>'),
2554 readNext(function (cb) {
2555 getRepo(headRepoId, function (err, headRepo) {
2556 if (err) return cb(err)
2557 getRepoFullName(about, headRepo.feed, headRepo.id,
2558 function (err, repoName, authorName) {
2559 if (err) return cb(err)
2560 cb(null, renderRepoInfo(Repo(headRepo), repoName, authorName))
2561 }
2562 )
2563 })
2564 })
2565 ]))
2566
2567 function renderRepoInfo(headRepo, headRepoName, headRepoAuthorName) {
2568 var authorLink = link([headRepo.feed], headRepoAuthorName)
2569 var repoLink = link([headRepoId], headRepoName)
2570 return cat([
2571 pull.once('<section>' +
2572 req._t('Base') + ': ' + baseLink + '<br/>' +
2573 req._t('Head') + ': ' +
2574 '<span class="bgslash">' + authorLink + ' / ' + repoLink +
2575 ' / ' + headBranchLink + '</span>' +
2576 '</section>' +
2577 (query.expand ? '<section><form method="post" action="">' +
2578 hiddenInputs({
2579 action: 'new-pull',
2580 branch: baseBranch,
2581 head_repo: headRepoId,
2582 head_branch: headBranch
2583 }) +
2584 '<input class="wide-input" name="title"' +
2585 ' placeholder="' + req._t('Title') + '" size="77"/>' +
2586 renderPostForm(req, repo, req._t('Description'), 8) +
2587 '<button type="submit" class="btn open">' +
2588 req._t('Create') + '</button>' +
2589 '</form></section>'
2590 : '<section><form method="get" action="">' +
2591 hiddenInputs({
2592 base: baseBranch,
2593 head: query.head
2594 }) +
2595 '<button class="btn open" type="submit" name="expand" value="1">' +
2596 '<i>โŽ‡</i> ' + req._t('CreatePullRequest') + '</button> ' +
2597 '<a href="' + backHref + '">' + req._t('Back') + '</a>' +
2598 '</form></section>') +
2599 '<div id="commits"></div>' +
2600 '<div class="tab-links">' +
2601 '<a href="#" id="files-link">' + req._t('FilesChanged') + '</a> ' +
2602 '<a href="#commits" id="commits-link">' +
2603 req._t('Commits') + '</a>' +
2604 '</div>' +
2605 '<section id="files-tab">'),
2606 renderDiffStat(req, [repo, headRepo], [baseBranch, headBranch]),
2607 pull.once('</section>' +
2608 '<section id="commits-tab">'),
2609 renderCommitLog(req, repo, baseBranch, headRepo, headBranch),
2610 pull.once('</section>')
2611 ])
2612 }
2613 }
2614
2615 function renderCommitLog(req, baseRepo, baseBranch, headRepo, headBranch) {
2616 return cat([
2617 pull.once('<table class="compare-commits">'),
2618 readNext(function (cb) {
2619 baseRepo.resolveRef(baseBranch, function (err, baseBranchRev) {
2620 if (err) return cb(err)
2621 var currentDay
2622 return cb(null, pull(
2623 headRepo.readLog(headBranch),
2624 pull.take(function (rev) { return rev != baseBranchRev }),
2625 pullReverse(),
2626 paramap(headRepo.getCommitParsed.bind(headRepo), 8),
2627 pull.map(function (commit) {
2628 var commitPath = [headRepo.id, 'commit', commit.id]
2629 var commitIdShort = '<tt>' + commit.id.substr(0, 8) + '</tt>'
2630 var day = Math.floor(commit.author.date / 86400000)
2631 var dateRow = day == currentDay ? '' :
2632 '<tr><th colspan=3 class="date-info">' +
2633 commit.author.date.toLocaleDateString(req._locale) +
2634 '</th><tr>'
2635 currentDay = day
2636 return dateRow + '<tr>' +
2637 '<td>' + escapeHTML(commit.author.name) + '</td>' +
2638 '<td>' + link(commitPath, commit.title) + '</td>' +
2639 '<td>' + link(commitPath, commitIdShort, true) + '</td>' +
2640 '</tr>'
2641 })
2642 ))
2643 })
2644 }),
2645 pull.once('</table>')
2646 ])
2647 }
2648}
2649

Built with git-ssb-web