git ssb

30+

cel / git-ssb-web



Tree: 81785ac357039921e1de71f1df9e2891178e375c

Files: 81785ac357039921e1de71f1df9e2891178e375c / index.js

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

Built with git-ssb-web