git ssb

30+

cel / git-ssb-web



Tree: ac331ec120c1bf480ccc64ae36c3c27fbe380fc6

Files: ac331ec120c1bf480ccc64ae36c3c27fbe380fc6 / index.js

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

Built with git-ssb-web