git ssb

30+

cel / git-ssb-web



Tree: 3f2951a9e595cf0bb2f165ed7c36bdc35bfb82a2

Files: 3f2951a9e595cf0bb2f165ed7c36bdc35bfb82a2 / index.js

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

Built with git-ssb-web