git ssb

30+

cel / git-ssb-web



Tree: 4cd0e7ebc99a154fda9b7d201aaf74a5dd9ba62f

Files: 4cd0e7ebc99a154fda9b7d201aaf74a5dd9ba62f / index.js

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

Built with git-ssb-web