git ssb

30+

cel / git-ssb-web



Tree: 87a92b12f3bb2fcd5f7cf0890137360f32e0375d

Files: 87a92b12f3bb2fcd5f7cf0890137360f32e0375d / index.js

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

Built with git-ssb-web