git ssb

30+

cel / git-ssb-web



Tree: 0c0f1f2e7a8f7d76e46d4a2781b7576e30e37d7f

Files: 0c0f1f2e7a8f7d76e46d4a2781b7576e30e37d7f / index.js

51674 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 paramap(renderFeedItem, 8)
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 readNext(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, cat([pull.once('<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 renderDiffStat(repo, commit.tree, commit.parents)
1173 ]))
1174 })
1175 })
1176 ]))
1177 }
1178
1179 /* Diff stat */
1180
1181 function renderDiffStat(repo, id, parentIds) {
1182 if (parentIds.length == 0) parentIds = [null]
1183 var lastI = parentIds.length
1184 var oldTree = parentIds[0]
1185 return cat([
1186 pull.once('<section><h3>Files changed</h3>'),
1187 pull(
1188 repo.diffTrees(parentIds.concat(id), true),
1189 pull.map(function (item) {
1190 var filename = escapeHTML(item.path.join('/'))
1191 var oldId = item.id && item.id[0]
1192 var newId = item.id && item.id[lastI]
1193 var oldMode = item.mode && item.mode[0].toString(8)
1194 var newMode = item.mode && item.mode[lastI].toString(8)
1195 var action =
1196 !oldId && newId ? 'new' :
1197 oldId && !newId ? 'deleted' :
1198 oldMode != newMode ?
1199 'changed mode from ' + oldMode + ' to ' + newMode :
1200 ''
1201 var newLink = newId ?
1202 link([repo.id, 'blob', id].concat(item.path), 'new') : ''
1203 var oldLink = oldId ?
1204 link([repo.id, 'blob', oldTree].concat(item.path), 'old') : ''
1205 var links = [oldLink, newLink]
1206 var fileLink = newLink || oldLink ? filename :
1207 link([repo.id, 'blob', id].concat(item.path), filename)
1208 return [fileLink, action, links.filter(Boolean).join(', ')]
1209 }),
1210 table()
1211 ),
1212 pull.once('</section>'),
1213 ])
1214 }
1215
1216 /* An unknown message linking to a repo */
1217
1218 function serveRepoSomething(req, repo, id, msg, path) {
1219 return renderRepoPage(repo, null, null,
1220 pull.once('<section><h3>' + link([id]) + '</h3>' +
1221 json(msg) + '</section>'))
1222 }
1223
1224 /* Repo update */
1225
1226 function objsArr(objs) {
1227 return Array.isArray(objs) ? objs :
1228 Object.keys(objs).map(function (sha1) {
1229 var obj = Object.create(objs[sha1])
1230 obj.sha1 = sha1
1231 return obj
1232 })
1233 }
1234
1235 function serveRepoUpdate(req, repo, id, msg, path) {
1236 var raw = req._u.query.raw != null
1237
1238 if (raw)
1239 return renderRepoPage(repo, 'activity', null, pull.once(
1240 '<a href="?" class="raw-link header-align">Info</a>' +
1241 '<h3>Update</h3>' +
1242 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1243
1244 // convert packs to old single-object style
1245 if (msg.content.indexes) {
1246 for (var i = 0; i < msg.content.indexes.length; i++) {
1247 msg.content.packs[i] = {
1248 pack: {link: msg.content.packs[i].link},
1249 idx: msg.content.indexes[i]
1250 }
1251 }
1252 }
1253
1254 return renderRepoPage(repo, 'activity', null, cat([
1255 pull.once(
1256 '<a href="?raw" class="raw-link header-align">Data</a>' +
1257 '<h3>Update</h3>' +
1258 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1259 (msg.content.objects ? '<h3>Objects</h3>' +
1260 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1261 (msg.content.packs ? '<h3>Packs</h3>' +
1262 msg.content.packs.map(renderPack).join('\n') : '')),
1263 cat(!msg.content.packs ? [] : [
1264 pull.once('<h3>Commits</h3>'),
1265 pull(
1266 pull.values(msg.content.packs),
1267 paramap(function (pack, cb) {
1268 var key = pack.pack.link
1269 ssb.blobs.want(key, function (err, got) {
1270 if (err) cb(err)
1271 else if (!got) cb(null, pull.once('Missing blob ' + key))
1272 else cb(null, ssb.blobs.get(key))
1273 })
1274 }, 8),
1275 pull.map(function (readPack, cb) {
1276 return gitPack.decode({}, repo, cb, readPack)
1277 }),
1278 pull.flatten(),
1279 paramap(function (obj, cb) {
1280 if (obj.type == 'commit')
1281 Repo.getCommitParsed(obj, cb)
1282 else
1283 pull(obj.read, pull.drain(null, cb))
1284 }, 8),
1285 pull.filter(),
1286 pull.map(function (commit) {
1287 return renderCommit(repo, commit)
1288 })
1289 )
1290 ])
1291 ]))
1292 }
1293
1294 function renderObject(obj) {
1295 return '<section class="collapse">' +
1296 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1297 obj.length + ' bytes' +
1298 '</section>'
1299 }
1300
1301 function renderPack(info) {
1302 return '<section class="collapse">' +
1303 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1304 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1305 }
1306
1307 /* Blob */
1308
1309 function serveRepoBlob(repo, rev, path) {
1310 return readNext(function (cb) {
1311 repo.getFile(rev, path, function (err, object) {
1312 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1313 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1314 var pathLinks = path.length === 0 ? '' :
1315 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1316 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1317 var filename = path[path.length-1]
1318 var extension = filename.split('.').pop()
1319 cb(null, renderRepoPage(repo, 'code', rev, cat([
1320 pull.once('<section><form action="" method="get">' +
1321 '<h3>' + type + ': ' + rev + ' '),
1322 revMenu(repo, rev),
1323 pull.once('</h3></form>'),
1324 type == 'Branch' && renderRepoLatest(repo, rev),
1325 pull.once('</section><section class="collapse">' +
1326 '<h3>Files' + pathLinks + '</h3>' +
1327 '<div>' + object.length + ' bytes' +
1328 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1329 '</div></section>' +
1330 '<section>'),
1331 extension in imgMimes
1332 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1333 '" alt="' + escapeHTML(filename) + '" />')
1334 : renderObjectData(object, filename, repo),
1335 pull.once('</section>')
1336 ])))
1337 })
1338 })
1339 }
1340
1341 function serveBlobNotFound(repoId, err) {
1342 return serveTemplate('Blob not found', 404, pull.values([
1343 '<h2>Blob not found</h2>',
1344 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1345 '<pre>' + escapeHTML(err.stack) + '</pre>'
1346 ]))
1347 }
1348
1349 /* Raw blob */
1350
1351 function serveRepoRaw(repo, branch, path) {
1352 return readNext(function (cb) {
1353 repo.getFile(branch, path, function (err, object) {
1354 if (err) return cb(null, servePlainError(404, 'Blob not found'))
1355 var extension = path[path.length-1].split('.').pop()
1356 var contentType = imgMimes[extension]
1357 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1358 })
1359 })
1360 }
1361
1362 function serveRaw(length, contentType) {
1363 var inBody
1364 var headers = {
1365 'Content-Type': contentType || 'text/plain; charset=utf-8',
1366 'Cache-Control': 'max-age=31536000'
1367 }
1368 if (length != null)
1369 headers['Content-Length'] = length
1370 return function (read) {
1371 return function (end, cb) {
1372 if (inBody) return read(end, cb)
1373 if (end) return cb(true)
1374 cb(null, [200, headers])
1375 inBody = true
1376 }
1377 }
1378 }
1379
1380 function serveBlob(req, key) {
1381 return readNext(function (cb) {
1382 ssb.blobs.want(key, function (err, got) {
1383 if (err) cb(null, serveError(err))
1384 else if (!got) cb(null, serve404(req))
1385 else cb(null, serveRaw()(ssb.blobs.get(key)))
1386 })
1387 })
1388 }
1389
1390 /* Digs */
1391
1392 function serveRepoDigs(repo) {
1393 return readNext(function (cb) {
1394 getVotes(repo.id, function (err, votes) {
1395 cb(null, renderRepoPage(repo, null, null, cat([
1396 pull.once('<section><h3>Digs</h3>' +
1397 '<div>Total: ' + votes.upvotes + '</div>'),
1398 pull(
1399 pull.values(Object.keys(votes.upvoters)),
1400 paramap(function (feedId, cb) {
1401 about.getName(feedId, function (err, name) {
1402 if (err) return cb(err)
1403 cb(null, link([feedId], name))
1404 })
1405 }, 8),
1406 ul()
1407 ),
1408 pull.once('</section>')
1409 ])))
1410 })
1411 })
1412 }
1413
1414 /* Issues */
1415
1416 function serveRepoIssues(req, repo, issueId, path) {
1417 var numIssues = 0
1418 var state = req._u.query.state || 'open'
1419 return renderRepoPage(repo, 'issues', null, cat([
1420 pull.once(
1421 (isPublic ? '' :
1422 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1423 '<button class="btn">&plus; New Issue</button>', true) +
1424 '</div>') +
1425 '<h3>Issues</h3>' +
1426 nav([
1427 ['?state=open', 'Open', 'open'],
1428 ['?state=closed', 'Closed', 'closed'],
1429 ['?state=all', 'All', 'all']
1430 ], state)),
1431 pull(
1432 issues.createFeedStream({ project: repo.id }),
1433 pull.filter(function (issue) {
1434 return state == 'all' ? true : (state == 'closed') == !issue.open
1435 }),
1436 pull.map(function (issue) {
1437 numIssues++
1438 var state = (issue.open ? 'open' : 'closed')
1439 return '<section class="collapse">' +
1440 '<i class="issue-state issue-state-' + state + '"' +
1441 ' title="' + ucfirst(state) + '">◾</i> ' +
1442 '<a href="' + encodeLink(issue.id) + '">' +
1443 escapeHTML(issue.title) +
1444 '<span class="right-bar">' +
1445 new Date(issue.created_at).toLocaleString() +
1446 '</span>' +
1447 '</a>' +
1448 '</section>'
1449 })
1450 ),
1451 readOnce(function (cb) {
1452 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1453 })
1454 ]))
1455 }
1456
1457 /* New Issue */
1458
1459 function serveRepoNewIssue(repo, issueId, path) {
1460 return renderRepoPage(repo, 'issues', null, pull.once(
1461 '<h3>New Issue</h3>' +
1462 '<section><form action="" method="post">' +
1463 '<input type="hidden" name="action" value="new-issue">' +
1464 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1465 renderPostForm(repo, 'Description', 8) +
1466 '<button type="submit" class="btn">Create</button>' +
1467 '</form></section>'))
1468 }
1469
1470 /* Issue */
1471
1472 function serveRepoIssue(req, repo, issue, path, postId) {
1473 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1474 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1475 return renderRepoPage(repo, 'issues', null, cat([
1476 pull.once(
1477 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1478 'Rename the issue',
1479 '<h3>' + link([issue.id], issue.title) + '</h3>') +
1480 '<code>' + issue.id + '</code>' +
1481 '<section class="collapse">' +
1482 (issue.open
1483 ? '<strong class="issue-status open">Open</strong>'
1484 : '<strong class="issue-status closed">Closed</strong>')),
1485 readOnce(function (cb) {
1486 about.getName(issue.author, function (err, authorName) {
1487 if (err) return cb(err)
1488 var authorLink = link([issue.author], authorName)
1489 cb(null,
1490 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1491 '<hr/>' +
1492 markdown(issue.text, repo) +
1493 '</section>')
1494 })
1495 }),
1496 // render posts and edits
1497 pull(
1498 ssb.links({
1499 dest: issue.id,
1500 values: true
1501 }),
1502 pull.unique('key'),
1503 addAuthorName(about),
1504 sortMsgs(),
1505 pull.map(function (msg) {
1506 var authorLink = link([msg.value.author], msg.authorName)
1507 var msgTimeLink = link([msg.key],
1508 new Date(msg.value.timestamp).toLocaleString(), false,
1509 'name="' + escapeHTML(msg.key) + '"')
1510 var c = msg.value.content
1511 if (msg.value.timestamp > newestMsg.value.timestamp)
1512 newestMsg = msg
1513 switch (c.type) {
1514 case 'post':
1515 if (c.root == issue.id) {
1516 var changed = issues.isStatusChanged(msg, issue)
1517 return '<section class="collapse">' +
1518 (msg.key == postId ? '<div class="highlight">' : '') +
1519 authorLink +
1520 (changed == null ? '' : ' ' + (
1521 changed ? 'reopened this issue' : 'closed this issue')) +
1522 ' &middot; ' + msgTimeLink +
1523 (msg.key == postId ? '</div>' : '') +
1524 markdown(c.text, repo) +
1525 '</section>'
1526 } else {
1527 var text = c.text || (c.type + ' ' + msg.key)
1528 return '<section class="collapse mention-preview">' +
1529 authorLink + ' mentioned this issue in ' +
1530 '<a href="/' + msg.key + '#' + msg.key + '">' +
1531 String(text).substr(0, 140) + '</a>' +
1532 '</section>'
1533 }
1534 case 'issue':
1535 return '<section class="collapse mention-preview">' +
1536 authorLink + ' mentioned this issue in ' +
1537 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
1538 '</section>'
1539 case 'issue-edit':
1540 return '<section class="collapse">' +
1541 (c.title == null ? '' :
1542 authorLink + ' renamed this issue to <q>' +
1543 escapeHTML(c.title) + '</q>') +
1544 ' &middot; ' + msgTimeLink +
1545 '</section>'
1546 case 'git-update':
1547 var mention = issues.getMention(msg, issue)
1548 if (mention) {
1549 var commitLink = link([repo.id, 'commit', mention.object],
1550 mention.label || mention.object)
1551 return '<section class="collapse">' +
1552 authorLink + ' ' +
1553 (mention.open ? 'reopened this issue' :
1554 'closed this issue') +
1555 ' &middot; ' + msgTimeLink + '<br/>' +
1556 commitLink +
1557 '</section>'
1558 } else if ((mention = getMention(msg, issue.id))) {
1559 var commitLink = link(mention.object ?
1560 [repo.id, 'commit', mention.object] : [msg.key],
1561 mention.label || mention.object || msg.key)
1562 return '<section class="collapse">' +
1563 authorLink + ' mentioned this issue' +
1564 ' &middot; ' + msgTimeLink + '<br/>' +
1565 commitLink +
1566 '</section>'
1567 } else {
1568 // fallthrough
1569 }
1570
1571 default:
1572 return '<section class="collapse">' +
1573 authorLink +
1574 ' &middot; ' + msgTimeLink +
1575 json(c) +
1576 '</section>'
1577 }
1578 })
1579 ),
1580 isPublic ? pull.empty() : readOnce(renderCommentForm)
1581 ]))
1582
1583 function renderCommentForm(cb) {
1584 cb(null, '<section><form action="" method="post">' +
1585 '<input type="hidden" name="action" value="comment">' +
1586 '<input type="hidden" name="id" value="' + issue.id + '">' +
1587 '<input type="hidden" name="issue" value="' + issue.id + '">' +
1588 '<input type="hidden" name="repo" value="' + repo.id + '">' +
1589 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1590 renderPostForm(repo) +
1591 '<input type="submit" class="btn open" value="Comment" />' +
1592 (isAuthor ?
1593 '<input type="submit" class="btn"' +
1594 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1595 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1596 '/>' : '') +
1597 '</form></section>')
1598 }
1599 }
1600
1601}
1602

Built with git-ssb-web