git ssb

30+

cel / git-ssb-web



Tree: 1e73dbc22e7cbacbfcbbc499676d04157d8499de

Files: 1e73dbc22e7cbacbfcbbc499676d04157d8499de / index.js

50056 bytesRaw
1var fs = require('fs')
2var http = require('http')
3var path = require('path')
4var url = require('url')
5var qs = require('querystring')
6var ref = require('ssb-ref')
7var pull = require('pull-stream')
8var ssbGit = require('ssb-git-repo')
9var toPull = require('stream-to-pull-stream')
10var cat = require('pull-cat')
11var Repo = require('pull-git-repo')
12var ssbAbout = require('./about')
13var ssbVotes = require('./votes')
14var marked = require('ssb-marked')
15var asyncMemo = require('asyncmemo')
16var multicb = require('multicb')
17var schemas = require('ssb-msg-schemas')
18var Issues = require('ssb-issues')
19var paramap = require('pull-paramap')
20var gitPack = require('pull-git-pack')
21var Mentions = require('ssb-mentions')
22var Highlight = require('highlight.js')
23
24// render links to git objects and ssb objects
25var blockRenderer = new marked.Renderer()
26blockRenderer.urltransform = function (url) {
27 if (ref.isLink(url))
28 return encodeLink(url)
29 if (/^[0-9a-f]{40}$/.test(url) && this.options.repo)
30 return encodeLink([this.options.repo.id, 'commit', url])
31 return url
32}
33
34function highlight(code, lang) {
35 return lang
36 ? Highlight.highlight(lang, code).value
37 : Highlight.highlightAuto(code).value
38}
39
40marked.setOptions({
41 gfm: true,
42 mentions: true,
43 tables: true,
44 breaks: true,
45 pedantic: false,
46 sanitize: true,
47 smartLists: true,
48 smartypants: false,
49 highlight: highlight,
50 renderer: blockRenderer
51})
52
53// hack to make git link mentions work
54var mdRules = new marked.InlineLexer(1, marked.defaults).rules
55mdRules.mention =
56 /^(\s)?([@%&][A-Za-z0-9\._\-+=\/]*[A-Za-z0-9_\-+=\/]|[0-9a-f]{40})/
57mdRules.text = /^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n| [@%&]|[0-9a-f]{40}|$)/
58
59function markdown(text, repo, cb) {
60 if (!text) return ''
61 if (typeof text != 'string') text = String(text)
62 return marked(text, {repo: repo}, cb)
63}
64
65function parseAddr(str, def) {
66 if (!str) return def
67 var i = str.lastIndexOf(':')
68 if (~i) return {host: str.substr(0, i), port: str.substr(i+1)}
69 if (isNaN(str)) return {host: str, port: def.port}
70 return {host: def.host, port: str}
71}
72
73function isArray(arr) {
74 return Object.prototype.toString.call(arr) == '[object Array]'
75}
76
77function encodeLink(url) {
78 if (!isArray(url)) url = [url]
79 return '/' + url.map(encodeURIComponent).join('/')
80}
81
82function link(parts, text, raw, props) {
83 if (text == null) text = parts[parts.length-1]
84 if (!raw) text = escapeHTML(text)
85 return '<a href="' + encodeLink(parts) + '"' +
86 (props ? ' ' + props : '') +
87 '>' + text + '</a>'
88}
89
90function linkify(text) {
91 // regex is from ssb-ref
92 return text.replace(/(@|%|&)[A-Za-z0-9\/+]{43}=\.[\w\d]+/g, function (str) {
93 return '<a href="/' + encodeURIComponent(str) + '">' + str + '</a>'
94 })
95}
96
97function timestamp(time) {
98 time = Number(time)
99 var d = new Date(time)
100 return '<span title="' + time + '">' + d.toLocaleString() + '</span>'
101}
102
103function pre(text) {
104 return '<pre>' + escapeHTML(text) + '</pre>'
105}
106
107function json(obj) {
108 return linkify(pre(JSON.stringify(obj, null, 2)))
109}
110
111function escapeHTML(str) {
112 return String(str)
113 .replace(/&/g, '&amp;')
114 .replace(/</g, '&lt;')
115 .replace(/>/g, '&gt;')
116 .replace(/"/g, '&quot;')
117}
118
119function ucfirst(str) {
120 return str[0].toLocaleUpperCase() + str.slice(1)
121}
122
123function table(props) {
124 return function (read) {
125 return cat([
126 pull.once('<table' + (props ? ' ' + props : '') + '>'),
127 pull(
128 read,
129 pull.map(function (row) {
130 return row ? '<tr>' + row.map(function (cell) {
131 return '<td>' + cell + '</td>'
132 }).join('') + '</tr>' : ''
133 })
134 ),
135 pull.once('</table>')
136 ])
137 }
138}
139
140function ul(props) {
141 return function (read) {
142 return cat([
143 pull.once('<ul' + (props ? ' ' + props : '') + '>'),
144 pull(
145 read,
146 pull.map(function (li) {
147 return '<li>' + li + '</li>'
148 })
149 ),
150 pull.once('</ul>')
151 ])
152 }
153}
154
155function renderNameForm(enabled, id, name, action, inputId, title, header) {
156 if (!inputId) inputId = action
157 return '<form class="petname" action="" method="post">' +
158 (enabled ?
159 '<input type="checkbox" class="name-checkbox" id="' + inputId + '" ' +
160 'onfocus="this.form.name.focus()" />' +
161 '<input name="name" class="name" value="' + escapeHTML(name) + '" ' +
162 'onkeyup="if (event.keyCode == 27) this.form.reset()" />' +
163 '<input type="hidden" name="action" value="' + action + '">' +
164 '<input type="hidden" name="id" value="' +
165 escapeHTML(id) + '">' +
166 '<label class="name-toggle" for="' + inputId + '" ' +
167 'title="' + title + '"><i>✍</i></label> ' +
168 '<input class="btn name-btn" type="submit" value="Rename">' +
169 header :
170 header + '<br clear="all"/>'
171 ) +
172 '</form>'
173}
174
175function renderPostForm(repo, placeholder, rows) {
176 return '<input type="radio" class="tab-radio" id="tab1" name="tab" checked="checked"/>' +
177 '<input type="radio" class="tab-radio" id="tab2" name="tab"/>' +
178 '<div id="tab-links" class="tab-links" style="display:none">' +
179 '<label for="tab1" id="write-tab-link" class="tab1-link">Write</label>' +
180 '<label for="tab2" id="preview-tab-link" class="tab2-link">Preview</label>' +
181 '</div>' +
182 '<input type="hidden" id="repo-id" value="' + repo.id + '"/>' +
183 '<div id="write-tab" class="tab1">' +
184 '<textarea id="post-text" name="text" class="wide-input"' +
185 ' rows="' + (rows||4) + '" cols="77"' +
186 (placeholder ? ' placeholder="' + placeholder + '"' : '') +
187 '></textarea>' +
188 '</div>' +
189 '<div class="preview-text tab2" id="preview-tab"></div>' +
190 '<script>' + issueCommentScript + '</script>'
191}
192
193function wrap(tag) {
194 return function (read) {
195 return cat([
196 pull.once('<' + tag + '>'),
197 read,
198 pull.once('</' + tag + '>')
199 ])
200 }
201}
202
203function readNext(fn) {
204 var next
205 return function (end, cb) {
206 if (next) return next(end, cb)
207 fn(function (err, _next) {
208 if (err) return cb(err)
209 next = _next
210 next(null, cb)
211 })
212 }
213}
214
215function readOnce(fn) {
216 var ended
217 return function (end, cb) {
218 fn(function (err, data) {
219 if (err || ended) return cb(err || ended)
220 ended = true
221 cb(null, data)
222 })
223 }
224}
225
226function compareMsgs(a, b) {
227 return (a.value.timestamp - b.value.timestamp) || (a.key - b.key)
228}
229
230function pullSort(comparator) {
231 return function (read) {
232 return readNext(function (cb) {
233 pull(read, pull.collect(function (err, items) {
234 if (err) return cb(err)
235 items.sort(comparator)
236 cb(null, pull.values(items))
237 }))
238 })
239 }
240}
241
242function sortMsgs() {
243 return pullSort(compareMsgs)
244}
245
246function tryDecodeURIComponent(str) {
247 if (!str || (str[0] == '%' && ref.isBlobId(str)))
248 return str
249 try {
250 str = decodeURIComponent(str)
251 } finally {
252 return str
253 }
254}
255
256function getRepoName(about, ownerId, repoId, cb) {
257 about.getName({
258 owner: ownerId,
259 target: repoId,
260 toString: function () {
261 // hack to fit two parameters into asyncmemo
262 return ownerId + '/' + repoId
263 }
264 }, cb)
265}
266
267function addAuthorName(about) {
268 return paramap(function (msg, cb) {
269 about.getName(msg.value.author, function (err, authorName) {
270 msg.authorName = authorName
271 cb(err, msg)
272 })
273 }, 8)
274}
275
276function getMention(msg, id) {
277 if (msg.key == id) return msg
278 var mentions = msg.value.content.mentions
279 if (mentions) for (var i = 0; i < mentions.length; i++) {
280 var mention = mentions[i]
281 if (mention.link == id)
282 return mention
283 }
284 return null
285}
286
287var hasOwnProp = Object.prototype.hasOwnProperty
288
289function getContentType(filename) {
290 var ext = filename.split('.').pop()
291 return hasOwnProp.call(contentTypes, ext)
292 ? contentTypes[ext]
293 : 'text/plain; charset=utf-8'
294}
295
296var contentTypes = {
297 css: 'text/css'
298}
299
300function readReqJSON(req, cb) {
301 pull(
302 toPull(req),
303 pull.collect(function (err, bufs) {
304 if (err) return cb(err)
305 var data
306 try {
307 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
308 } catch(e) {
309 return cb(e)
310 }
311 cb(null, data)
312 })
313 )
314}
315
316var issueCommentScript = '(' + function () {
317 var $ = document.getElementById.bind(document)
318 $('tab-links').style.display = 'block'
319 $('preview-tab-link').onclick = function (e) {
320 with (new XMLHttpRequest()) {
321 open('POST', '', true)
322 onload = function() {
323 $('preview-tab').innerHTML = responseText
324 }
325 send('action=markdown' +
326 '&repo=' + encodeURIComponent($('repo-id').value) +
327 '&text=' + encodeURIComponent($('post-text').value))
328 }
329 }
330}.toString() + ')()'
331
332var msgTypes = {
333 'git-repo': true,
334 'git-update': true,
335 'issue': true
336}
337
338var imgMimes = {
339 png: 'image/png',
340 jpeg: 'image/jpeg',
341 jpg: 'image/jpeg',
342 gif: 'image/gif',
343 tif: 'image/tiff',
344 svg: 'image/svg+xml',
345 bmp: 'image/bmp'
346}
347
348module.exports = function (opts, cb) {
349 var ssb, reconnect, myId, getRepo, getVotes, getMsg, issues
350 var about = function (id, cb) { cb(null, {name: id}) }
351 var reqQueue = []
352 var isPublic = opts.public
353 var ssbAppname = opts.appname || 'ssb'
354
355 var addr = parseAddr(opts.listenAddr, {host: 'localhost', port: 7718})
356 http.createServer(onRequest).listen(addr.port, addr.host, onListening)
357
358 var server = {
359 setSSB: function (_ssb, _reconnect) {
360 _ssb.whoami(function (err, feed) {
361 if (err) throw err
362 ssb = _ssb
363 reconnect = _reconnect
364 myId = feed.id
365 about = ssbAbout(ssb, myId)
366 while (reqQueue.length)
367 onRequest.apply(this, reqQueue.shift())
368 getRepo = asyncMemo(function (id, cb) {
369 getMsg(id, function (err, msg) {
370 if (err) return cb(err)
371 ssbGit.getRepo(ssb, {key: id, value: msg}, {live: true}, cb)
372 })
373 })
374 getVotes = ssbVotes(ssb)
375 getMsg = asyncMemo(ssb.get)
376 issues = Issues.init(ssb)
377 })
378 }
379 }
380
381 function onListening() {
382 var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host
383 console.log('Listening on http://' + host + ':' + addr.port + '/')
384 cb(null, server)
385 }
386
387 /* Serving a request */
388
389 function onRequest(req, res) {
390 console.log(req.method, req.url)
391 if (!ssb) return reqQueue.push(arguments)
392 pull(
393 handleRequest(req),
394 pull.filter(function (data) {
395 if (Array.isArray(data)) {
396 res.writeHead.apply(res, data)
397 return false
398 }
399 return true
400 }),
401 toPull(res)
402 )
403 }
404
405 function handleRequest(req) {
406 var u = req._u = url.parse(req.url, true)
407 var path = u.pathname.slice(1)
408 var dirs = ref.isLink(path) ? [path] :
409 path.split(/\/+/).map(tryDecodeURIComponent)
410 var dir = dirs[0]
411
412 if (req.method == 'POST') {
413 if (isPublic)
414 return servePlainError(405, 'POST not allowed on public site')
415 return readNext(function (cb) {
416 readReqJSON(req, function (err, data) {
417 if (err) return cb(null, serveError(err, 400))
418 if (!data) return cb(null, serveError(new Error('No data'), 400))
419
420 switch (data.action) {
421 case 'vote':
422 var voteValue = +data.vote || 0
423 if (!data.id)
424 return cb(null, serveError(new Error('Missing vote id'), 400))
425 var msg = schemas.vote(data.id, voteValue)
426 return ssb.publish(msg, function (err) {
427 if (err) return cb(null, serveError(err))
428 cb(null, serveRedirect(req.url))
429 })
430 return
431
432 case 'repo-name':
433 if (!data.name)
434 return cb(null, serveError(new Error('Missing name'), 400))
435 if (!data.id)
436 return cb(null, serveError(new Error('Missing id'), 400))
437 var msg = schemas.name(data.id, data.name)
438 return ssb.publish(msg, function (err) {
439 if (err) return cb(null, serveError(err))
440 cb(null, serveRedirect(req.url))
441 })
442
443 case 'issue-title':
444 if (!data.name)
445 return cb(null, serveError(new Error('Missing name'), 400))
446 if (!data.id)
447 return cb(null, serveError(new Error('Missing id'), 400))
448 var msg = Issues.schemas.edit(data.id, {title: data.name})
449 return ssb.publish(msg, function (err) {
450 if (err) return cb(null, serveError(err))
451 cb(null, serveRedirect(req.url))
452 })
453
454 case 'comment':
455 if (!data.id)
456 return cb(null, serveError(new Error('Missing id'), 400))
457
458 var msg = schemas.post(data.text, data.id, data.branch || data.id)
459 msg.issue = data.issue
460 msg.repo = data.repo
461 if (data.open != null)
462 Issues.schemas.opens(msg, data.id)
463 if (data.close != null)
464 Issues.schemas.closes(msg, data.id)
465 var mentions = Mentions(data.text)
466 if (mentions.length)
467 msg.mentions = mentions
468 return ssb.publish(msg, function (err) {
469 if (err) return cb(null, serveError(err))
470 cb(null, serveRedirect(req.url))
471 })
472
473 case 'new-issue':
474 var msg = Issues.schemas.new(dir, data.title, data.text)
475 var mentions = Mentions(data.text)
476 if (mentions.length)
477 msg.mentions = mentions
478 return ssb.publish(msg, function (err, msg) {
479 if (err) return cb(null, serveError(err))
480 cb(null, serveRedirect(encodeLink(msg.key)))
481 })
482
483 case 'markdown':
484 return cb(null, serveMarkdown(data.text, {id: data.repo}))
485
486 default:
487 cb(null, servePlainError(400, 'What are you trying to do?'))
488 }
489 })
490 })
491 }
492
493 if (dir == '')
494 return serveIndex(req)
495 else if (ref.isBlobId(dir))
496 return serveBlob(req, dir)
497 else if (ref.isMsgId(dir))
498 return serveMessage(req, dir, dirs.slice(1))
499 else if (ref.isFeedId(dir))
500 return serveUserPage(dir, dirs.slice(1))
501 else
502 return serveFile(req, dirs)
503 }
504
505 function serveFile(req, dirs) {
506 var filename = path.join.apply(path, [__dirname].concat(dirs))
507 // prevent escaping base dir
508 if (filename.indexOf('../') === 0)
509 return servePlainError(403, '403 Forbidden')
510
511 return readNext(function (cb) {
512 fs.stat(filename, function (err, stats) {
513 cb(null, err ?
514 err.code == 'ENOENT' ? serve404(req)
515 : servePlainError(500, err.message)
516 : 'if-modified-since' in req.headers &&
517 new Date(req.headers['if-modified-since']) >= stats.mtime ?
518 pull.once([304])
519 : stats.isDirectory() ?
520 servePlainError(403, 'Directory not listable')
521 : cat([
522 pull.once([200, {
523 'Content-Type': getContentType(filename),
524 'Content-Length': stats.size,
525 'Last-Modified': stats.mtime.toGMTString()
526 }]),
527 toPull(fs.createReadStream(filename))
528 ]))
529 })
530 })
531 }
532
533 function servePlainError(code, msg) {
534 return pull.values([
535 [code, {
536 'Content-Length': Buffer.byteLength(msg),
537 'Content-Type': 'text/plain; charset=utf-8'
538 }],
539 msg
540 ])
541 }
542
543 function serve404(req) {
544 return servePlainError(404, '404 Not Found')
545 }
546
547 function serveRedirect(path) {
548 var msg = '<!doctype><html><head><meta charset=utf-8>' +
549 '<title>Redirect</title></head>' +
550 '<body><p><a href="' + path + '">Continue</a></p></body></html>'
551 return pull.values([
552 [302, {
553 'Content-Length': Buffer.byteLength(msg),
554 'Content-Type': 'text/html',
555 Location: path
556 }],
557 msg
558 ])
559 }
560
561 function serveMarkdown(text, repo) {
562 var html = markdown(text, repo)
563 return pull.values([
564 [200, {
565 'Content-Length': Buffer.byteLength(html),
566 'Content-Type': 'text/html; charset=utf-8'
567 }],
568 html
569 ])
570 }
571
572 function renderTry(read) {
573 var ended
574 return function (end, cb) {
575 if (ended) return cb(ended)
576 read(end, function (err, data) {
577 if (err === true)
578 cb(true)
579 else if (err) {
580 ended = true
581 cb(null,
582 '<h3>' + err.name + '</h3>' +
583 '<pre>' + escapeHTML(err.stack) + '</pre>')
584 } else
585 cb(null, data)
586 })
587 }
588 }
589
590 function serveTemplate(title, code, read) {
591 if (read === undefined) return serveTemplate.bind(this, title, code)
592 return cat([
593 pull.values([
594 [code || 200, {
595 'Content-Type': 'text/html'
596 }],
597 '<!doctype html><html><head><meta charset=utf-8>',
598 '<title>' + escapeHTML(title || 'git ssb') + '</title>',
599 '<link rel=stylesheet href="/static/styles.css"/>',
600 '<link rel=stylesheet href="/node_modules/highlight.js/styles/github.css"/>',
601 '</head>\n',
602 '<body>',
603 '<header>',
604 '<h1><a href="/">git ssb' +
605 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
606 '</a></h1>',
607 '</header>',
608 '<article>']),
609 renderTry(read),
610 pull.once('<hr/></article></body></html>')
611 ])
612 }
613
614 function serveError(err, status) {
615 if (err.message == 'stream is closed')
616 reconnect()
617 return pull(
618 pull.once(
619 '<h2>' + err.name + '</h3>' +
620 '<pre>' + escapeHTML(err.stack) + '</pre>'),
621 serveTemplate(err.name, status || 500)
622 )
623 }
624
625 function renderObjectData(obj, filename, repo) {
626 var ext = (/\.([^.]+)$/.exec(filename) || [,filename])[1]
627 return readOnce(function (cb) {
628 pull(obj.read, pull.collect(function (err, bufs) {
629 if (err) return cb(err)
630 var buf = Buffer.concat(bufs, obj.length).toString('utf8')
631 cb(null, (ext == 'md' || ext == 'markdown')
632 ? markdown(buf, repo)
633 : '<pre>' + highlight(buf, ext) + '</pre>')
634 }))
635 })
636 }
637
638 /* Feed */
639
640 function renderFeed(feedId) {
641 var opts = {
642 reverse: true,
643 id: feedId
644 }
645 return pull(
646 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
647 pull.filter(function (msg) {
648 return msg.value.content.type in msgTypes &&
649 msg.value.timestamp < Date.now()
650 }),
651 pull.take(20),
652 addAuthorName(about),
653 pull.asyncMap(renderFeedItem)
654 )
655 }
656
657 function renderFeedItem(msg, cb) {
658 var c = msg.value.content
659 var msgLink = link([msg.key],
660 new Date(msg.value.timestamp).toLocaleString())
661 var author = msg.value.author
662 var authorLink = link([msg.value.author], msg.authorName)
663 switch (c.type) {
664 case 'git-repo':
665 return getRepoName(about, author, msg.key, function (err, repoName) {
666 if (err) return cb(err)
667 var repoLink = link([msg.key], repoName)
668 cb(null, '<section class="collapse">' + msgLink + '<br>' +
669 authorLink + ' created repo ' + repoLink + '</section>')
670 })
671 case 'git-update':
672 return getRepoName(about, author, c.repo, function (err, repoName) {
673 if (err) return cb(err)
674 var repoLink = link([c.repo], repoName)
675 cb(null, '<section class="collapse">' + msgLink + '<br>' +
676 authorLink + ' pushed to ' + repoLink + '</section>')
677 })
678 case 'issue':
679 var issueLink = link([msg.key], c.title)
680 return getRepoName(about, author, c.project, function (err, repoName) {
681 if (err) return cb(err)
682 var repoLink = link([c.project], repoName)
683 cb(null, '<section class="collapse">' + msgLink + '<br>' +
684 authorLink + ' opened issue ' + issueLink +
685 ' on ' + repoLink + '</section>')
686 })
687 }
688 }
689
690 /* Index */
691
692 function serveIndex() {
693 return serveTemplate('git ssb')(renderFeed())
694 }
695
696 function serveUserPage(feedId, dirs) {
697 switch (dirs[0]) {
698 case undefined:
699 case '':
700 case 'activity':
701 return serveUserActivity(feedId)
702 case 'repos':
703 return serveUserRepos(feedId)
704 }
705 }
706
707 function renderUserPage(feedId, page, body) {
708 return serveTemplate(feedId)(cat([
709 readOnce(function (cb) {
710 about.getName(feedId, function (err, name) {
711 cb(null, '<h2>' + link([feedId], name) +
712 '<code class="user-id">' + feedId + '</code></h2>' +
713 '<nav>' +
714 link([feedId], 'Activity', true,
715 page == 'activity' ? ' class="active"' : '') +
716 link([feedId, 'repos'], 'Repos', true,
717 page == 'repos' ? ' class="active"' : '') +
718 '</nav>')
719 })
720 }),
721 body,
722 ]))
723 }
724
725 function serveUserActivity(feedId) {
726 return renderUserPage(feedId, 'activity', renderFeed(feedId))
727 }
728
729 function serveUserRepos(feedId) {
730 return renderUserPage(feedId, 'repos', pull(
731 ssb.messagesByType({
732 type: 'git-repo',
733 reverse: true
734 }),
735 pull.filter(function (msg) {
736 return msg.value.author == feedId
737 }),
738 pull.take(20),
739 paramap(function (msg, cb) {
740 getRepoName(about, feedId, msg.key, function (err, repoName) {
741 if (err) return cb(err)
742 cb(null, '<section class="collapse">' +
743 link([msg.key], repoName) +
744 '</section>')
745 })
746 }, 8)
747 ))
748 }
749
750 /* Message */
751
752 function serveMessage(req, id, path) {
753 return readNext(function (cb) {
754 ssb.get(id, function (err, msg) {
755 if (err) return cb(null, serveError(err))
756 var c = msg.content || {}
757 switch (c.type) {
758 case 'git-repo':
759 return getRepo(id, function (err, repo) {
760 if (err) return cb(null, serveError(err))
761 cb(null, serveRepoPage(req, Repo(repo), path))
762 })
763 case 'git-update':
764 return getRepo(c.repo, function (err, repo) {
765 if (err) return cb(null, serveRepoNotFound(c.repo, err))
766 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
767 })
768 case 'issue':
769 return getRepo(c.project, function (err, repo) {
770 if (err) return cb(null, serveRepoNotFound(c.project, err))
771 issues.get(id, function (err, issue) {
772 if (err) return cb(null, serveError(err))
773 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
774 })
775 })
776 case 'post':
777 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
778 var done = multicb({ pluck: 1, spread: true })
779 getRepo(c.repo, done())
780 issues.get(c.issue, done())
781 return done(function (err, repo, issue) {
782 if (err) {
783 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
784 return cb(null, serveError(err))
785 }
786 cb(null, serveRepoIssue(req, Repo(repo), issue, path, id))
787 })
788 }
789 // fallthrough
790 default:
791 if (ref.isMsgId(c.repo))
792 return getRepo(c.repo, function (err, repo) {
793 if (err) return cb(null, serveRepoNotFound(c.repo, err))
794 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
795 })
796 else
797 return cb(null, serveGenericMessage(req, id, msg, path))
798 }
799 })
800 })
801 }
802
803 function serveGenericMessage(req, id, msg, path) {
804 return serveTemplate(id)(pull.once(
805 '<section><h2>' + link([id]) + '</h2>' +
806 json(msg) +
807 '</section>'))
808 }
809
810 /* Repo */
811
812 function serveRepoPage(req, repo, path) {
813 var defaultBranch = 'master'
814 var query = req._u.query
815
816 if (query.rev != null) {
817 // Allow navigating revs using GET query param.
818 // Replace the branch in the path with the rev query value
819 path[0] = path[0] || 'tree'
820 path[1] = query.rev
821 req._u.pathname = encodeLink([repo.id].concat(path))
822 delete req._u.query.rev
823 delete req._u.search
824 return serveRedirect(url.format(req._u))
825 }
826
827 // get branch
828 return path[1] ?
829 serveRepoPage2(req, repo, path) :
830 readNext(function (cb) {
831 repo.getSymRef('HEAD', true, function (err, value) {
832 if (err) return cb(err)
833 path[1] = value || null
834 cb(null, serveRepoPage2(req, repo, path))
835 })
836 })
837 }
838
839 function serveRepoPage2(req, repo, path) {
840 var branch = path[1]
841 var filePath = path.slice(2)
842 switch (path[0]) {
843 case undefined:
844 case '':
845 return serveRepoTree(repo, branch, [])
846 case 'activity':
847 return serveRepoActivity(repo, branch)
848 case 'commits':
849 return serveRepoCommits(repo, branch)
850 case 'commit':
851 return serveRepoCommit(repo, path[1])
852 case 'tree':
853 return serveRepoTree(repo, branch, filePath)
854 case 'blob':
855 return serveRepoBlob(repo, branch, filePath)
856 case 'raw':
857 return serveRepoRaw(repo, branch, filePath)
858 case 'digs':
859 return serveRepoDigs(repo)
860 case 'issues':
861 switch (path[1]) {
862 case 'new':
863 if (filePath.length == 0)
864 return serveRepoNewIssue(repo)
865 break
866 default:
867 return serveRepoIssues(req, repo, branch, filePath)
868 }
869 default:
870 return serve404(req)
871 }
872 }
873
874 function serveRepoNotFound(id, err) {
875 return serveTemplate('Repo not found', 404, pull.values([
876 '<h2>Repo not found</h2>',
877 '<p>Repo ' + id + ' was not found</p>',
878 '<pre>' + escapeHTML(err.stack) + '</pre>',
879 ]))
880 }
881
882 function renderRepoPage(repo, page, branch, body) {
883 var gitUrl = 'ssb://' + repo.id
884 var gitLink = '<input class="clone-url" readonly="readonly" ' +
885 'value="' + gitUrl + '" size="61" ' +
886 'onclick="this.select()"/>'
887 var digsPath = [repo.id, 'digs']
888
889 var done = multicb({ pluck: 1, spread: true })
890 getRepoName(about, repo.feed, repo.id, done())
891 about.getName(repo.feed, done())
892 getVotes(repo.id, done())
893
894 return readNext(function (cb) {
895 done(function (err, repoName, authorName, votes) {
896 if (err) return cb(null, serveError(err))
897 var upvoted = votes.upvoters[myId] > 0
898 cb(null, serveTemplate(repo.id)(cat([
899 pull.once(
900 '<div class="repo-title">' +
901 '<form class="right-bar" action="" method="post">' +
902 '<button class="btn" ' +
903 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
904 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
905 '</button>' +
906 (isPublic ? '' : '<input type="hidden" name="vote" value="' +
907 (upvoted ? '0' : '1') + '">' +
908 '<input type="hidden" name="action" value="vote">' +
909 '<input type="hidden" name="id" value="' +
910 escapeHTML(repo.id) + '">') + ' ' +
911 '<strong>' + link(digsPath, votes.upvotes) + '</strong>' +
912 '</form>' +
913 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
914 'Rename the repo',
915 '<h2>' + link([repo.feed], authorName) + ' / ' +
916 link([repo.id], repoName) + '</h2>') +
917 '</div><nav>' + link([repo.id], 'Code') +
918 link([repo.id, 'activity'], 'Activity') +
919 link([repo.id, 'commits', branch || ''], 'Commits') +
920 link([repo.id, 'issues'], 'Issues') +
921 gitLink +
922 '</nav>'),
923 body
924 ])))
925 })
926 })
927 }
928
929 function serveEmptyRepo(repo) {
930 if (repo.feed != myId)
931 return renderRepoPage(repo, null, pull.once(
932 '<section>' +
933 '<h3>Empty repository</h3>' +
934 '</section>'))
935
936 var gitUrl = 'ssb://' + repo.id
937 return renderRepoPage(repo, null, pull.once(
938 '<section>' +
939 '<h3>Getting started</h3>' +
940 '<h4>Create a new repository</h4><pre>' +
941 'touch README.md\n' +
942 'git init\n' +
943 'git add README.md\n' +
944 'git commit -m "Initial commit"\n' +
945 'git remote add origin ' + gitUrl + '\n' +
946 'git push -u origin master</pre>\n' +
947 '<h4>Push an existing repository</h4>\n' +
948 '<pre>git remote add origin ' + gitUrl + '\n' +
949 'git push -u origin master</pre>' +
950 '</section>'))
951 }
952
953 function serveRepoTree(repo, rev, path) {
954 if (!rev) return serveEmptyRepo(repo)
955 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
956 return renderRepoPage(repo, rev, cat([
957 pull.once('<section><form action="" method="get">' +
958 '<h3>' + type + ': ' + rev + ' '),
959 revMenu(repo, rev),
960 pull.once('</h3></form>'),
961 type == 'Branch' && renderRepoLatest(repo, rev),
962 pull.once('</section><section>'),
963 renderRepoTree(repo, rev, path),
964 pull.once('</section>'),
965 renderRepoReadme(repo, rev, path)
966 ]))
967 }
968
969 /* Repo activity */
970
971 function serveRepoActivity(repo, branch) {
972 return renderRepoPage(repo, branch, cat([
973 pull.once('<h3>Activity</h3>'),
974 pull(
975 ssb.links({
976 type: 'git-update',
977 dest: repo.id,
978 source: repo.feed,
979 rel: 'repo',
980 values: true,
981 reverse: true,
982 limit: 8
983 }),
984 pull.map(renderRepoUpdate.bind(this, repo))
985 )
986 ]))
987 }
988
989 function renderRepoUpdate(repo, msg, full) {
990 var c = msg.value.content
991
992 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
993 return {name: ref, value: c.refs[ref]}
994 }) : []
995 var numObjects = c.objects ? Object.keys(c.objects).length : 0
996
997 return '<section class="collapse">' +
998 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
999 '<br>' +
1000 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1001 refs.map(function (update) {
1002 var name = escapeHTML(update.name)
1003 if (!update.value) {
1004 return 'Deleted ' + name
1005 } else {
1006 var commitLink = link([repo.id, 'commit', update.value])
1007 return name + ' &rarr; ' + commitLink
1008 }
1009 }).join('<br>') +
1010 '</section>'
1011 }
1012
1013 /* Repo commits */
1014
1015 function serveRepoCommits(repo, branch) {
1016 return renderRepoPage(repo, branch, cat([
1017 pull.once('<h3>Commits</h3>'),
1018 pull(
1019 repo.readLog(branch),
1020 paramap(function (hash, cb) {
1021 repo.getCommitParsed(hash, function (err, commit) {
1022 if (err) return cb(err)
1023 cb(null, renderCommit(repo, commit))
1024 })
1025 }, 8)
1026 )
1027 ]))
1028 }
1029
1030 function renderCommit(repo, commit) {
1031 var commitPath = [repo.id, 'commit', commit.id]
1032 var treePath = [repo.id, 'tree', commit.id]
1033 return '<section class="collapse">' +
1034 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1035 '<tt>' + commit.id + '</tt> ' +
1036 link(treePath, 'Tree') + '<br>' +
1037 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1038 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1039 '</section>'
1040}
1041
1042 /* Repo tree */
1043
1044 function revMenu(repo, currentName) {
1045 return readOnce(function (cb) {
1046 repo.getRefNames(true, function (err, refs) {
1047 if (err) return cb(err)
1048 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1049 Object.keys(refs).map(function (group) {
1050 return '<optgroup label="' + group + '">' +
1051 refs[group].map(function (name) {
1052 var htmlName = escapeHTML(name)
1053 return '<option value="' + htmlName + '"' +
1054 (name == currentName ? ' selected="selected"' : '') +
1055 '>' + htmlName + '</option>'
1056 }).join('') + '</optgroup>'
1057 }).join('') +
1058 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1059 })
1060 })
1061 }
1062
1063 function renderRepoLatest(repo, rev) {
1064 return readOnce(function (cb) {
1065 repo.getCommitParsed(rev, function (err, commit) {
1066 if (err) return cb(err)
1067 var commitPath = [repo.id, 'commit', commit.id]
1068 cb(null,
1069 'Latest: <strong>' + link(commitPath, commit.title) +
1070 '</strong><br>' +
1071 '<tt>' + commit.id + '</tt><br> ' +
1072 escapeHTML(commit.committer.name) + ' committed on ' +
1073 commit.committer.date.toLocaleString() +
1074 (commit.separateAuthor ? '<br>' +
1075 escapeHTML(commit.author.name) + ' authored on ' +
1076 commit.author.date.toLocaleString() : ''))
1077 })
1078 })
1079 }
1080
1081 // breadcrumbs
1082 function linkPath(basePath, path) {
1083 path = path.slice()
1084 var last = path.pop()
1085 return path.map(function (dir, i) {
1086 return link(basePath.concat(path.slice(0, i+1)), dir)
1087 }).concat(last).join(' / ')
1088 }
1089
1090 function renderRepoTree(repo, rev, path) {
1091 var pathLinks = path.length === 0 ? '' :
1092 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1093 return cat([
1094 pull.once('<h3>Files' + pathLinks + '</h3>'),
1095 pull(
1096 repo.readDir(rev, path),
1097 pull.map(function (file) {
1098 var type = (file.mode === 040000) ? 'tree' :
1099 (file.mode === 0160000) ? 'commit' : 'blob'
1100 if (type == 'commit')
1101 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1102 var filePath = [repo.id, type, rev].concat(path, file.name)
1103 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1104 link(filePath, file.name)]
1105 }),
1106 table('class="files"')
1107 )
1108 ])
1109 }
1110
1111 /* Repo readme */
1112
1113 function renderRepoReadme(repo, branch, path) {
1114 return readNext(function (cb) {
1115 pull(
1116 repo.readDir(branch, path),
1117 pull.filter(function (file) {
1118 return /readme(\.|$)/i.test(file.name)
1119 }),
1120 pull.take(1),
1121 pull.collect(function (err, files) {
1122 if (err) return cb(null, pull.empty())
1123 var file = files[0]
1124 if (!file)
1125 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1126 repo.getObjectFromAny(file.id, function (err, obj) {
1127 if (err) return cb(err)
1128 cb(null, cat([
1129 pull.once('<section><h4><a name="readme">' +
1130 escapeHTML(file.name) + '</a></h4><hr/>'),
1131 renderObjectData(obj, file.name, repo),
1132 pull.once('</section>')
1133 ]))
1134 })
1135 })
1136 )
1137 })
1138 }
1139
1140 /* Repo commit */
1141
1142 function serveRepoCommit(repo, rev) {
1143 return renderRepoPage(repo, rev, cat([
1144 pull.once('<h3>Commit ' + rev + '</h3>'),
1145 readOnce(function (cb) {
1146 repo.getCommitParsed(rev, function (err, commit) {
1147 if (err) return cb(err)
1148 var commitPath = [repo.id, 'commit', commit.id]
1149 var treePath = [repo.id, 'tree', commit.tree]
1150 cb(null, '<section class="collapse">' +
1151 '<strong>' + link(commitPath, commit.title) + '</strong>' +
1152 (commit.body ? pre(commit.body) : '') +
1153 '<p>' +
1154 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1155 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1156 : '') +
1157 escapeHTML(commit.committer.name) + ' committed on ' +
1158 commit.committer.date.toLocaleString() + '</p>' +
1159 '<p>' + commit.parents.map(function (id) {
1160 return 'Parent: ' + link([repo.id, 'commit', id], id)
1161 }).join('<br>') + '</p>' +
1162 (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') +
1163 '</section>')
1164 })
1165 })
1166 ]))
1167 }
1168
1169 /* An unknown message linking to a repo */
1170
1171 function serveRepoSomething(req, repo, id, msg, path) {
1172 return renderRepoPage(repo, null,
1173 pull.once('<section><h3>' + link([id]) + '</h3>' +
1174 json(msg) + '</section>'))
1175 }
1176
1177 /* Repo update */
1178
1179 function objsArr(objs) {
1180 return Array.isArray(objs) ? objs :
1181 Object.keys(objs).map(function (sha1) {
1182 var obj = Object.create(objs[sha1])
1183 obj.sha1 = sha1
1184 return obj
1185 })
1186 }
1187
1188 function serveRepoUpdate(req, repo, id, msg, path) {
1189 var raw = req._u.query.raw != null
1190
1191 if (raw)
1192 return renderRepoPage(repo, null, pull.once(
1193 '<a href="?" class="raw-link header-align">Info</a>' +
1194 '<h3>Update</h3>' +
1195 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1196
1197 // convert packs to old single-object style
1198 if (msg.content.indexes) {
1199 for (var i = 0; i < msg.content.indexes.length; i++) {
1200 msg.content.packs[i] = {
1201 pack: {link: msg.content.packs[i].link},
1202 idx: msg.content.indexes[i]
1203 }
1204 }
1205 }
1206
1207 return renderRepoPage(repo, null, cat([
1208 pull.once(
1209 '<a href="?raw" class="raw-link header-align">Data</a>' +
1210 '<h3>Update</h3>' +
1211 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1212 (msg.content.objects ? '<h3>Objects</h3>' +
1213 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1214 (msg.content.packs ? '<h3>Packs</h3>' +
1215 msg.content.packs.map(renderPack).join('\n') : '')),
1216 cat(!msg.content.packs ? [] : [
1217 pull.once('<h3>Commits</h3>'),
1218 pull(
1219 pull.values(msg.content.packs),
1220 paramap(function (pack, cb) {
1221 var key = pack.pack.link
1222 ssb.blobs.want(key, function (err, got) {
1223 if (err) cb(err)
1224 else if (!got) cb(null, pull.once('Missing blob ' + key))
1225 else cb(null, ssb.blobs.get(key))
1226 })
1227 }, 8),
1228 pull.map(function (readPack, cb) {
1229 return gitPack.decode({}, repo, cb, readPack)
1230 }),
1231 pull.flatten(),
1232 paramap(function (obj, cb) {
1233 if (obj.type == 'commit')
1234 Repo.getCommitParsed(obj, cb)
1235 else
1236 pull(obj.read, pull.drain(null, cb))
1237 }, 8),
1238 pull.filter(),
1239 pull.map(function (commit) {
1240 return renderCommit(repo, commit)
1241 })
1242 )
1243 ])
1244 ]))
1245 }
1246
1247 function renderObject(obj) {
1248 return '<section class="collapse">' +
1249 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1250 obj.length + ' bytes' +
1251 '</section>'
1252 }
1253
1254 function renderPack(info) {
1255 return '<section class="collapse">' +
1256 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1257 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1258 }
1259
1260 /* Blob */
1261
1262 function serveRepoBlob(repo, rev, path) {
1263 return readNext(function (cb) {
1264 repo.getFile(rev, path, function (err, object) {
1265 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1266 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1267 var pathLinks = path.length === 0 ? '' :
1268 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1269 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1270 var filename = path[path.length-1]
1271 var extension = filename.split('.').pop()
1272 cb(null, renderRepoPage(repo, rev, cat([
1273 pull.once('<section><form action="" method="get">' +
1274 '<h3>' + type + ': ' + rev + ' '),
1275 revMenu(repo, rev),
1276 pull.once('</h3></form>'),
1277 type == 'Branch' && renderRepoLatest(repo, rev),
1278 pull.once('</section><section class="collapse">' +
1279 '<h3>Files' + pathLinks + '</h3>' +
1280 '<div>' + object.length + ' bytes' +
1281 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1282 '</div></section>' +
1283 '<section>'),
1284 extension in imgMimes
1285 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1286 '" alt="' + escapeHTML(filename) + '" />')
1287 : renderObjectData(object, filename, repo),
1288 pull.once('</section>')
1289 ])))
1290 })
1291 })
1292 }
1293
1294 function serveBlobNotFound(repoId, err) {
1295 return serveTemplate('Blob not found', 404, pull.values([
1296 '<h2>Blob not found</h2>',
1297 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1298 '<pre>' + escapeHTML(err.stack) + '</pre>'
1299 ]))
1300 }
1301
1302 /* Raw blob */
1303
1304 function serveRepoRaw(repo, branch, path) {
1305 return readNext(function (cb) {
1306 repo.getFile(branch, path, function (err, object) {
1307 if (err) return cb(null, servePlainError(404, 'Blob not found'))
1308 var extension = path[path.length-1].split('.').pop()
1309 var contentType = imgMimes[extension]
1310 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1311 })
1312 })
1313 }
1314
1315 function serveRaw(length, contentType) {
1316 var inBody
1317 var headers = {
1318 'Content-Type': contentType || 'text/plain; charset=utf-8',
1319 'Cache-Control': 'max-age=31536000'
1320 }
1321 if (length != null)
1322 headers['Content-Length'] = length
1323 return function (read) {
1324 return function (end, cb) {
1325 if (inBody) return read(end, cb)
1326 if (end) return cb(true)
1327 cb(null, [200, headers])
1328 inBody = true
1329 }
1330 }
1331 }
1332
1333 function serveBlob(req, key) {
1334 return readNext(function (cb) {
1335 ssb.blobs.want(key, function (err, got) {
1336 if (err) cb(null, serveError(err))
1337 else if (!got) cb(null, serve404(req))
1338 else cb(null, serveRaw()(ssb.blobs.get(key)))
1339 })
1340 })
1341 }
1342
1343 /* Digs */
1344
1345 function serveRepoDigs(repo) {
1346 return readNext(function (cb) {
1347 getVotes(repo.id, function (err, votes) {
1348 cb(null, renderRepoPage(repo, '', cat([
1349 pull.once('<section><h3>Digs</h3>' +
1350 '<div>Total: ' + votes.upvotes + '</div>'),
1351 pull(
1352 pull.values(Object.keys(votes.upvoters)),
1353 pull.asyncMap(function (feedId, cb) {
1354 about.getName(feedId, function (err, name) {
1355 if (err) return cb(err)
1356 cb(null, link([feedId], name))
1357 })
1358 }),
1359 ul()
1360 ),
1361 pull.once('</section>')
1362 ])))
1363 })
1364 })
1365 }
1366
1367 /* Issues */
1368
1369 function serveRepoIssues(req, repo, issueId, path) {
1370 var numIssues = 0
1371 var state = req._u.query.state || 'open'
1372 return renderRepoPage(repo, '', cat([
1373 pull.once(
1374 (isPublic ? '' :
1375 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1376 '<button class="btn">&plus; New Issue</button>', true) +
1377 '</div>') +
1378 '<h3>Issues</h3>' +
1379 '<nav>' +
1380 '<a href="?state=open"' +
1381 (state == 'open' ? ' class="active"' : '') + '>Open</a>' +
1382 '<a href="?state=closed"' +
1383 (state == 'closed' ? ' class="active"' : '') + '>Closed</a>' +
1384 '<a href="?state=all"' +
1385 (state == 'all' ? ' class="active"' : '') + '>All</a>' +
1386 '</nav>'),
1387 pull(
1388 issues.createFeedStream({ project: repo.id }),
1389 pull.filter(function (issue) {
1390 return state == 'all' ? true : (state == 'closed') == !issue.open
1391 }),
1392 pull.map(function (issue) {
1393 numIssues++
1394 var state = (issue.open ? 'open' : 'closed')
1395 return '<section class="collapse">' +
1396 '<i class="issue-state issue-state-' + state + '"' +
1397 ' title="' + ucfirst(state) + '">◾</i> ' +
1398 '<a href="' + encodeLink(issue.id) + '">' +
1399 escapeHTML(issue.title) +
1400 '<span class="right-bar">' +
1401 new Date(issue.created_at).toLocaleString() +
1402 '</span>' +
1403 '</a>' +
1404 '</section>'
1405 })
1406 ),
1407 readOnce(function (cb) {
1408 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1409 })
1410 ]))
1411 }
1412
1413 /* New Issue */
1414
1415 function serveRepoNewIssue(repo, issueId, path) {
1416 return renderRepoPage(repo, '', pull.once(
1417 '<h3>New Issue</h3>' +
1418 '<section><form action="" method="post">' +
1419 '<input type="hidden" name="action" value="new-issue">' +
1420 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1421 renderPostForm(repo, 'Description', 8) +
1422 '<button type="submit" class="btn">Create</button>' +
1423 '</form></section>'))
1424 }
1425
1426 /* Issue */
1427
1428 function serveRepoIssue(req, repo, issue, path, postId) {
1429 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1430 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1431 return renderRepoPage(repo, null, cat([
1432 pull.once(
1433 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1434 'Rename the issue',
1435 '<h3>' + link([issue.id], issue.title) + '</h3>') +
1436 '<code>' + issue.id + '</code>' +
1437 '<section class="collapse">' +
1438 (issue.open
1439 ? '<strong class="issue-status open">Open</strong>'
1440 : '<strong class="issue-status closed">Closed</strong>')),
1441 readOnce(function (cb) {
1442 about.getName(issue.author, function (err, authorName) {
1443 if (err) return cb(err)
1444 var authorLink = link([issue.author], authorName)
1445 cb(null,
1446 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1447 '<hr/>' +
1448 markdown(issue.text, repo) +
1449 '</section>')
1450 })
1451 }),
1452 // render posts and edits
1453 pull(
1454 ssb.links({
1455 dest: issue.id,
1456 values: true
1457 }),
1458 pull.unique('key'),
1459 addAuthorName(about),
1460 sortMsgs(),
1461 pull.map(function (msg) {
1462 var authorLink = link([msg.value.author], msg.authorName)
1463 var msgTimeLink = link([msg.key],
1464 new Date(msg.value.timestamp).toLocaleString(), false,
1465 'name="' + escapeHTML(msg.key) + '"')
1466 var c = msg.value.content
1467 if (msg.value.timestamp > newestMsg.value.timestamp)
1468 newestMsg = msg
1469 switch (c.type) {
1470 case 'post':
1471 if (c.root == issue.id) {
1472 var changed = issues.isStatusChanged(msg, issue)
1473 return '<section class="collapse">' +
1474 (msg.key == postId ? '<div class="highlight">' : '') +
1475 authorLink +
1476 (changed == null ? '' : ' ' + (
1477 changed ? 'reopened this issue' : 'closed this issue')) +
1478 ' &middot; ' + msgTimeLink +
1479 (msg.key == postId ? '</div>' : '') +
1480 markdown(c.text, repo) +
1481 '</section>'
1482 } else {
1483 var text = c.text || (c.type + ' ' + msg.key)
1484 return '<section class="collapse mention-preview">' +
1485 authorLink + ' mentioned this issue in ' +
1486 '<a href="/' + msg.key + '#' + msg.key + '">' +
1487 String(text).substr(0, 140) + '</a>' +
1488 '</section>'
1489 }
1490 case 'issue':
1491 return '<section class="collapse mention-preview">' +
1492 authorLink + ' mentioned this issue in ' +
1493 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
1494 '</section>'
1495 case 'issue-edit':
1496 return '<section class="collapse">' +
1497 (c.title == null ? '' :
1498 authorLink + ' renamed this issue to <q>' +
1499 escapeHTML(c.title) + '</q>') +
1500 ' &middot; ' + msgTimeLink +
1501 '</section>'
1502 case 'git-update':
1503 var mention = issues.getMention(msg, issue)
1504 if (mention) {
1505 var commitLink = link([repo.id, 'commit', mention.object],
1506 mention.label || mention.object)
1507 return '<section class="collapse">' +
1508 authorLink + ' ' +
1509 (mention.open ? 'reopened this issue' :
1510 'closed this issue') +
1511 ' &middot; ' + msgTimeLink + '<br/>' +
1512 commitLink +
1513 '</section>'
1514 } else if ((mention = getMention(msg, issue.id))) {
1515 var commitLink = link(mention.object ?
1516 [repo.id, 'commit', mention.object] : [msg.key],
1517 mention.label || mention.object || msg.key)
1518 return '<section class="collapse">' +
1519 authorLink + ' mentioned this issue' +
1520 ' &middot; ' + msgTimeLink + '<br/>' +
1521 commitLink +
1522 '</section>'
1523 } else {
1524 // fallthrough
1525 }
1526
1527 default:
1528 return '<section class="collapse">' +
1529 authorLink +
1530 ' &middot; ' + msgTimeLink +
1531 json(c) +
1532 '</section>'
1533 }
1534 })
1535 ),
1536 isPublic ? pull.empty() : readOnce(renderCommentForm)
1537 ]))
1538
1539 function renderCommentForm(cb) {
1540 cb(null, '<section><form action="" method="post">' +
1541 '<input type="hidden" name="action" value="comment">' +
1542 '<input type="hidden" name="id" value="' + issue.id + '">' +
1543 '<input type="hidden" name="issue" value="' + issue.id + '">' +
1544 '<input type="hidden" name="repo" value="' + repo.id + '">' +
1545 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1546 renderPostForm(repo) +
1547 '<input type="submit" class="btn open" value="Comment" />' +
1548 (isAuthor ?
1549 '<input type="submit" class="btn"' +
1550 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1551 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1552 '/>' : '') +
1553 '</form></section>')
1554 }
1555 }
1556
1557}
1558

Built with git-ssb-web