git ssb

30+

cel / git-ssb-web



Tree: ba02aa5007c20a431511f04d41c9928886e6a166

Files: ba02aa5007c20a431511f04d41c9928886e6a166 / index.js

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

Built with git-ssb-web