git ssb

30+

cel / git-ssb-web



Tree: 6839527655b266fe0e7ea9e840a892514428e492

Files: 6839527655b266fe0e7ea9e840a892514428e492 / index.js

59095 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 'repo':
514 if (data.fork != null) {
515 var repoId = data.id
516 if (!repoId) return cb(null,
517 serveError(new Error('Missing repo id'), 400))
518 ssbGit.createRepo(ssb, {upstream: repoId},
519 function (err, repo) {
520 if (err) return cb(null, serveError(err))
521 cb(null, serveRedirect(encodeLink(repo.id)))
522 })
523 } else if (data.vote != null) {
524 // fallthrough
525 } else {
526 return cb(null, serveError(new Error('Unknown action'), 400))
527 }
528
529 case 'vote':
530 var voteValue = +data.value || 0
531 if (!data.id)
532 return cb(null, serveError(new Error('Missing vote id'), 400))
533 var msg = schemas.vote(data.id, voteValue)
534 return ssb.publish(msg, function (err) {
535 if (err) return cb(null, serveError(err))
536 cb(null, serveRedirect(req.url))
537 })
538
539 case 'repo-name':
540 if (!data.name)
541 return cb(null, serveError(new Error('Missing name'), 400))
542 if (!data.id)
543 return cb(null, serveError(new Error('Missing id'), 400))
544 var msg = schemas.name(data.id, data.name)
545 return ssb.publish(msg, function (err) {
546 if (err) return cb(null, serveError(err))
547 cb(null, serveRedirect(req.url))
548 })
549
550 case 'issue-title':
551 if (!data.name)
552 return cb(null, serveError(new Error('Missing name'), 400))
553 if (!data.id)
554 return cb(null, serveError(new Error('Missing id'), 400))
555 var msg = Issues.schemas.edit(data.id, {title: data.name})
556 return ssb.publish(msg, function (err) {
557 if (err) return cb(null, serveError(err))
558 cb(null, serveRedirect(req.url))
559 })
560
561 case 'comment':
562 if (!data.id)
563 return cb(null, serveError(new Error('Missing id'), 400))
564
565 var msg = schemas.post(data.text, data.id, data.branch || data.id)
566 msg.issue = data.issue
567 msg.repo = data.repo
568 if (data.open != null)
569 Issues.schemas.opens(msg, data.id)
570 if (data.close != null)
571 Issues.schemas.closes(msg, data.id)
572 var mentions = Mentions(data.text)
573 if (mentions.length)
574 msg.mentions = mentions
575 return ssb.publish(msg, function (err) {
576 if (err) return cb(null, serveError(err))
577 cb(null, serveRedirect(req.url))
578 })
579
580 case 'new-issue':
581 var msg = Issues.schemas.new(dir, data.title, data.text)
582 var mentions = Mentions(data.text)
583 if (mentions.length)
584 msg.mentions = mentions
585 return ssb.publish(msg, function (err, msg) {
586 if (err) return cb(null, serveError(err))
587 cb(null, serveRedirect(encodeLink(msg.key)))
588 })
589
590 case 'markdown':
591 return cb(null, serveMarkdown(data.text, {id: data.repo}))
592
593 default:
594 cb(null, serveBuffer(400, 'What are you trying to do?'))
595 }
596 })
597 })
598 }
599
600 if (dir == '')
601 return serveIndex(req)
602 else if (ref.isBlobId(dir))
603 return serveBlob(req, dir)
604 else if (ref.isMsgId(dir))
605 return serveMessage(req, dir, dirs.slice(1))
606 else if (ref.isFeedId(dir))
607 return serveUserPage(req, dir, dirs.slice(1))
608 else if (dir == 'static')
609 return serveFile(req, dirs)
610 else if (dir == 'highlight')
611 return serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
612 else
613 return serve404(req)
614 }
615
616 function serveFile(req, dirs, outside) {
617 var filename = path.resolve.apply(path, [__dirname].concat(dirs))
618 // prevent escaping base dir
619 if (!outside && filename.indexOf('../') === 0)
620 return serveBuffer(403, '403 Forbidden')
621
622 return readNext(function (cb) {
623 fs.stat(filename, function (err, stats) {
624 cb(null, err ?
625 err.code == 'ENOENT' ? serve404(req)
626 : serveBuffer(500, err.message)
627 : 'if-modified-since' in req.headers &&
628 new Date(req.headers['if-modified-since']) >= stats.mtime ?
629 pull.once([304])
630 : stats.isDirectory() ?
631 serveBuffer(403, 'Directory not listable')
632 : cat([
633 pull.once([200, {
634 'Content-Type': getContentType(filename),
635 'Content-Length': stats.size,
636 'Last-Modified': stats.mtime.toGMTString()
637 }]),
638 toPull(fs.createReadStream(filename))
639 ]))
640 })
641 })
642 }
643
644 function servePlainError(code, msg) {
645 return pull.values([
646 [code, {
647 'Content-Length': Buffer.byteLength(msg),
648 'Content-Type': 'text/plain; charset=utf-8'
649 }],
650 msg
651 ])
652 }
653
654 function serveBuffer(code, buf, contentType, headers) {
655 headers = headers || {}
656 headers['Content-Type'] = contentType || 'text/plain; charset=utf-8'
657 headers['Content-Length'] = Buffer.byteLength(buf)
658 return pull.values([
659 [code, headers],
660 buf
661 ])
662 }
663
664 function serve404(req) {
665 return serveBuffer(404, '404 Not Found')
666 }
667
668 function serveRedirect(path) {
669 return serveBuffer(302,
670 '<!doctype><html><head>' +
671 '<title>Redirect</title></head><body>' +
672 '<p><a href="' + escapeHTML(path) + '">Continue</a></p>' +
673 '</body></html>', 'text/html; charset=utf-8', {Location: path})
674 }
675
676 function serveMarkdown(text, repo) {
677 return serveBuffer(200, markdown(text, repo), 'text/html; charset=utf-8')
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 if (repo.upstream) {
1039 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1040 about.getName(repo.upstream.feed, done())
1041 }
1042
1043 return readNext(function (cb) {
1044 done(function (err, repoName, authorName, votes,
1045 upstreamName, upstreamAuthorName) {
1046 if (err) return cb(null, serveError(err))
1047 var upvoted = votes.upvoters[myId] > 0
1048 var upstreamLink = !repo.upstream ? '' :
1049 link([repo.upstream])
1050 cb(null, serveTemplate(repo.id)(cat([
1051 pull.once(
1052 '<div class="repo-title">' +
1053 '<form class="right-bar" action="" method="post">' +
1054 '<button class="btn" ' +
1055 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1056 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
1057 '</button>' +
1058 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1059 (upvoted ? '0' : '1') + '">' +
1060 '<input type="hidden" name="action" value="repo">' +
1061 '<input type="hidden" name="id" value="' +
1062 escapeHTML(repo.id) + '">') + ' ' +
1063 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1064 (isPublic ? '' :
1065 '<button class="btn" type="submit" name="fork">' +
1066 '<i>⑂</i> Fork' +
1067 '</button>') +
1068 '</form>' +
1069 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
1070 'Rename the repo',
1071 '<h2>' + link([repo.feed], authorName) + ' / ' +
1072 link([repo.id], repoName) + '</h2>') +
1073 '</div>' +
1074 (repo.upstream ?
1075 '<small>forked from ' +
1076 link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
1077 link([repo.upstream.id], upstreamName) +
1078 '</small>' : '') +
1079 nav([
1080 [[repo.id], 'Code', 'code'],
1081 [[repo.id, 'activity'], 'Activity', 'activity'],
1082 [[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
1083 [[repo.id, 'issues'], 'Issues', 'issues']
1084 ], page, gitLink)),
1085 body
1086 ])))
1087 })
1088 })
1089 }
1090
1091 function serveEmptyRepo(repo) {
1092 if (repo.feed != myId)
1093 return renderRepoPage(repo, 'code', null, pull.once(
1094 '<section>' +
1095 '<h3>Empty repository</h3>' +
1096 '</section>'))
1097
1098 var gitUrl = 'ssb://' + repo.id
1099 return renderRepoPage(repo, 'code', null, pull.once(
1100 '<section>' +
1101 '<h3>Getting started</h3>' +
1102 '<h4>Create a new repository</h4><pre>' +
1103 'touch README.md\n' +
1104 'git init\n' +
1105 'git add README.md\n' +
1106 'git commit -m "Initial commit"\n' +
1107 'git remote add origin ' + gitUrl + '\n' +
1108 'git push -u origin master</pre>\n' +
1109 '<h4>Push an existing repository</h4>\n' +
1110 '<pre>git remote add origin ' + gitUrl + '\n' +
1111 'git push -u origin master</pre>' +
1112 '</section>'))
1113 }
1114
1115 function serveRepoTree(repo, rev, path) {
1116 if (!rev) return serveEmptyRepo(repo)
1117 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1118 return renderRepoPage(repo, 'code', rev, cat([
1119 pull.once('<section><form action="" method="get">' +
1120 '<h3>' + type + ': ' + rev + ' '),
1121 revMenu(repo, rev),
1122 pull.once('</h3></form>'),
1123 type == 'Branch' && renderRepoLatest(repo, rev),
1124 pull.once('</section><section>'),
1125 renderRepoTree(repo, rev, path),
1126 pull.once('</section>'),
1127 renderRepoReadme(repo, rev, path)
1128 ]))
1129 }
1130
1131 /* Repo activity */
1132
1133 function serveRepoActivity(repo, branch) {
1134 return renderRepoPage(repo, 'activity', branch, cat([
1135 pull.once('<h3>Activity</h3>'),
1136 pull(
1137 ssb.links({
1138 type: 'git-update',
1139 dest: repo.id,
1140 source: repo.feed,
1141 rel: 'repo',
1142 values: true,
1143 reverse: true
1144 }),
1145 pull.map(renderRepoUpdate.bind(this, repo))
1146 )
1147 ]))
1148 }
1149
1150 function renderRepoUpdate(repo, msg, full) {
1151 var c = msg.value.content
1152
1153 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1154 return {name: ref, value: c.refs[ref]}
1155 }) : []
1156 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1157
1158 return '<section class="collapse">' +
1159 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1160 '<br>' +
1161 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1162 refs.map(function (update) {
1163 var name = escapeHTML(update.name)
1164 if (!update.value) {
1165 return 'Deleted ' + name
1166 } else {
1167 var commitLink = link([repo.id, 'commit', update.value])
1168 return name + ' &rarr; ' + commitLink
1169 }
1170 }).join('<br>') +
1171 '</section>'
1172 }
1173
1174 /* Repo commits */
1175
1176 function serveRepoCommits(req, repo, branch) {
1177 var query = req._u.query
1178 return renderRepoPage(repo, 'commits', branch, cat([
1179 pull.once('<h3>Commits</h3>'),
1180 pull(
1181 repo.readLog(query.start || branch),
1182 pull.take(20),
1183 paginate(
1184 !query.start ? '' : function (first, cb) {
1185 cb(null, '&hellip;')
1186 },
1187 pull(
1188 paramap(repo.getCommitParsed.bind(repo), 8),
1189 pull.map(renderCommit.bind(this, repo))
1190 ),
1191 function (last, cb) {
1192 cb(null, '<a href="?start=' + last + '">Older</a>')
1193 }
1194 )
1195 )
1196 ]))
1197 }
1198
1199 function renderCommit(repo, commit) {
1200 var commitPath = [repo.id, 'commit', commit.id]
1201 var treePath = [repo.id, 'tree', commit.id]
1202 return '<section class="collapse">' +
1203 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1204 '<tt>' + commit.id + '</tt> ' +
1205 link(treePath, 'Tree') + '<br>' +
1206 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1207 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1208 '</section>'
1209}
1210
1211 /* Repo tree */
1212
1213 function revMenu(repo, currentName) {
1214 return readOnce(function (cb) {
1215 repo.getRefNames(true, function (err, refs) {
1216 if (err) return cb(err)
1217 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1218 Object.keys(refs).map(function (group) {
1219 return '<optgroup label="' + group + '">' +
1220 refs[group].map(function (name) {
1221 var htmlName = escapeHTML(name)
1222 return '<option value="' + htmlName + '"' +
1223 (name == currentName ? ' selected="selected"' : '') +
1224 '>' + htmlName + '</option>'
1225 }).join('') + '</optgroup>'
1226 }).join('') +
1227 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1228 })
1229 })
1230 }
1231
1232 function renderRepoLatest(repo, rev) {
1233 return readOnce(function (cb) {
1234 repo.getCommitParsed(rev, function (err, commit) {
1235 if (err) return cb(err)
1236 var commitPath = [repo.id, 'commit', commit.id]
1237 cb(null,
1238 'Latest: <strong>' + link(commitPath, commit.title) +
1239 '</strong><br>' +
1240 '<tt>' + commit.id + '</tt><br> ' +
1241 escapeHTML(commit.committer.name) + ' committed on ' +
1242 commit.committer.date.toLocaleString() +
1243 (commit.separateAuthor ? '<br>' +
1244 escapeHTML(commit.author.name) + ' authored on ' +
1245 commit.author.date.toLocaleString() : ''))
1246 })
1247 })
1248 }
1249
1250 // breadcrumbs
1251 function linkPath(basePath, path) {
1252 path = path.slice()
1253 var last = path.pop()
1254 return path.map(function (dir, i) {
1255 return link(basePath.concat(path.slice(0, i+1)), dir)
1256 }).concat(last).join(' / ')
1257 }
1258
1259 function renderRepoTree(repo, rev, path) {
1260 var pathLinks = path.length === 0 ? '' :
1261 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1262 return cat([
1263 pull.once('<h3>Files' + pathLinks + '</h3>'),
1264 pull(
1265 repo.readDir(rev, path),
1266 pull.map(function (file) {
1267 var type = (file.mode === 040000) ? 'tree' :
1268 (file.mode === 0160000) ? 'commit' : 'blob'
1269 if (type == 'commit')
1270 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1271 var filePath = [repo.id, type, rev].concat(path, file.name)
1272 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1273 link(filePath, file.name)]
1274 }),
1275 table('class="files"')
1276 )
1277 ])
1278 }
1279
1280 /* Repo readme */
1281
1282 function renderRepoReadme(repo, branch, path) {
1283 return readNext(function (cb) {
1284 pull(
1285 repo.readDir(branch, path),
1286 pull.filter(function (file) {
1287 return /readme(\.|$)/i.test(file.name)
1288 }),
1289 pull.take(1),
1290 pull.collect(function (err, files) {
1291 if (err) return cb(null, pull.empty())
1292 var file = files[0]
1293 if (!file)
1294 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1295 repo.getObjectFromAny(file.id, function (err, obj) {
1296 if (err) return cb(err)
1297 cb(null, cat([
1298 pull.once('<section><h4><a name="readme">' +
1299 escapeHTML(file.name) + '</a></h4><hr/>'),
1300 renderObjectData(obj, file.name, repo),
1301 pull.once('</section>')
1302 ]))
1303 })
1304 })
1305 )
1306 })
1307 }
1308
1309 /* Repo commit */
1310
1311 function serveRepoCommit(repo, rev) {
1312 return renderRepoPage(repo, null, rev, cat([
1313 readNext(function (cb) {
1314 repo.getCommitParsed(rev, function (err, commit) {
1315 if (err) return cb(err)
1316 var commitPath = [repo.id, 'commit', commit.id]
1317 var treePath = [repo.id, 'tree', commit.id]
1318 cb(null, cat([pull.once(
1319 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1320 '<section class="collapse">' +
1321 '<div class="right-bar">' +
1322 link(treePath, 'Browse Files') +
1323 '</div>' +
1324 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1325 (commit.body ? pre(commit.body) : '') +
1326 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1327 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1328 : '') +
1329 escapeHTML(commit.committer.name) + ' committed on ' +
1330 commit.committer.date.toLocaleString() + '<br/>' +
1331 commit.parents.map(function (id) {
1332 return 'Parent: ' + link([repo.id, 'commit', id], id)
1333 }).join('<br>') +
1334 '</section>'),
1335 renderDiffStat(repo, commit.id, commit.parents)
1336 ]))
1337 })
1338 })
1339 ]))
1340 }
1341
1342 /* Diff stat */
1343
1344 function renderDiffStat(repo, id, parentIds) {
1345 if (parentIds.length == 0) parentIds = [null]
1346 var lastI = parentIds.length
1347 var oldTree = parentIds[0]
1348 var changedFiles = []
1349 return cat([
1350 pull.once('<section><h3>Files changed</h3>'),
1351 pull(
1352 repo.diffTrees(parentIds.concat(id), true),
1353 pull.map(function (item) {
1354 var filename = item.filename = escapeHTML(item.path.join('/'))
1355 var oldId = item.id && item.id[0]
1356 var newId = item.id && item.id[lastI]
1357 var oldMode = item.mode && item.mode[0]
1358 var newMode = item.mode && item.mode[lastI]
1359 var action =
1360 !oldId && newId ? 'added' :
1361 oldId && !newId ? 'deleted' :
1362 oldMode != newMode ?
1363 'changed mode from ' + oldMode.toString(8) +
1364 ' to ' + newMode.toString(8) :
1365 'changed'
1366 if (item.id)
1367 changedFiles.push(item)
1368 var fileHref = item.id ?
1369 '#' + encodeURIComponent(item.path.join('/')) :
1370 encodeLink([repo.id, 'blob', id].concat(item.path))
1371 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1372 }),
1373 table()
1374 ),
1375 pull(
1376 pull.values(changedFiles),
1377 paramap(function (item, cb) {
1378 var done = multicb({ pluck: 1, spread: true })
1379 getRepoObjectString(repo, item.id[0], done())
1380 getRepoObjectString(repo, item.id[lastI], done())
1381 done(function (err, strOld, strNew) {
1382 if (err) return cb(err)
1383 var commitId = item.id[lastI] ? id : parentIds.filter(Boolean)[0]
1384 cb(null, htmlLineDiff(item.filename, item.filename,
1385 strOld, strNew,
1386 encodeLink([repo.id, 'blob', commitId].concat(item.path))))
1387 })
1388 }, 4)
1389 ),
1390 pull.once('</section>' + hashHighlightScript),
1391 ])
1392 }
1393
1394 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref, rawHref) {
1395 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1396 var groups = diff.hunks.map(function (hunk) {
1397 var oldLine = hunk.oldStart
1398 var newLine = hunk.newStart
1399 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1400 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1401 '+' + newLine + ',' + hunk.newLines + ' @@' +
1402 '</td></tr>'
1403 return [header].concat(hunk.lines.map(function (line) {
1404 var s = line[0]
1405 if (s == '\\') return
1406 var html = highlight(line, getExtension(filename))
1407 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1408 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1409 var id = [filename].concat(lineNums).join('-')
1410 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1411 lineNums.map(function (num) {
1412 return '<td class="code-linenum">' +
1413 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1414 num + '</a>' : '') + '</td>'
1415 }).join('') +
1416 '<td class="code-text">' + html + '</td></tr>'
1417 }))
1418 })
1419 return '<pre><table class="code">' +
1420 '<tr><th colspan=3 id="' + anchor + '">' + filename +
1421 '<span class="right-bar">' +
1422 '<a href="' + blobHref + '">View</a> ' +
1423 '</span></th></tr>' +
1424 [].concat.apply([], groups).join('') +
1425 '</table></pre>'
1426 }
1427
1428 /* An unknown message linking to a repo */
1429
1430 function serveRepoSomething(req, repo, id, msg, path) {
1431 return renderRepoPage(repo, null, null,
1432 pull.once('<section><h3>' + link([id]) + '</h3>' +
1433 json(msg) + '</section>'))
1434 }
1435
1436 /* Repo update */
1437
1438 function objsArr(objs) {
1439 return Array.isArray(objs) ? objs :
1440 Object.keys(objs).map(function (sha1) {
1441 var obj = Object.create(objs[sha1])
1442 obj.sha1 = sha1
1443 return obj
1444 })
1445 }
1446
1447 function serveRepoUpdate(req, repo, id, msg, path) {
1448 var raw = req._u.query.raw != null
1449
1450 if (raw)
1451 return renderRepoPage(repo, 'activity', null, pull.once(
1452 '<a href="?" class="raw-link header-align">Info</a>' +
1453 '<h3>Update</h3>' +
1454 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1455
1456 // convert packs to old single-object style
1457 if (msg.content.indexes) {
1458 for (var i = 0; i < msg.content.indexes.length; i++) {
1459 msg.content.packs[i] = {
1460 pack: {link: msg.content.packs[i].link},
1461 idx: msg.content.indexes[i]
1462 }
1463 }
1464 }
1465
1466 return renderRepoPage(repo, 'activity', null, cat([
1467 pull.once(
1468 '<a href="?raw" class="raw-link header-align">Data</a>' +
1469 '<h3>Update</h3>' +
1470 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1471 (msg.content.objects ? '<h3>Objects</h3>' +
1472 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1473 (msg.content.packs ? '<h3>Packs</h3>' +
1474 msg.content.packs.map(renderPack).join('\n') : '')),
1475 cat(!msg.content.packs ? [] : [
1476 pull.once('<h3>Commits</h3>'),
1477 pull(
1478 pull.values(msg.content.packs),
1479 paramap(function (pack, cb) {
1480 var key = pack.pack.link
1481 ssb.blobs.want(key, function (err, got) {
1482 if (err) cb(err)
1483 else if (!got) cb(null, pull.once('Missing blob ' + key))
1484 else cb(null, ssb.blobs.get(key))
1485 })
1486 }, 8),
1487 pull.map(function (readPack, cb) {
1488 return gitPack.decode({}, repo, cb, readPack)
1489 }),
1490 pull.flatten(),
1491 paramap(function (obj, cb) {
1492 if (obj.type == 'commit')
1493 Repo.getCommitParsed(obj, cb)
1494 else
1495 pull(obj.read, pull.drain(null, cb))
1496 }, 8),
1497 pull.filter(),
1498 pull.map(function (commit) {
1499 return renderCommit(repo, commit)
1500 })
1501 )
1502 ])
1503 ]))
1504 }
1505
1506 function renderObject(obj) {
1507 return '<section class="collapse">' +
1508 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1509 obj.length + ' bytes' +
1510 '</section>'
1511 }
1512
1513 function renderPack(info) {
1514 return '<section class="collapse">' +
1515 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1516 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1517 }
1518
1519 /* Blob */
1520
1521 function serveRepoBlob(repo, rev, path) {
1522 return readNext(function (cb) {
1523 repo.getFile(rev, path, function (err, object) {
1524 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1525 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1526 var pathLinks = path.length === 0 ? '' :
1527 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1528 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1529 var filename = path[path.length-1]
1530 var extension = getExtension(filename)
1531 cb(null, renderRepoPage(repo, 'code', rev, cat([
1532 pull.once('<section><form action="" method="get">' +
1533 '<h3>' + type + ': ' + rev + ' '),
1534 revMenu(repo, rev),
1535 pull.once('</h3></form>'),
1536 type == 'Branch' && renderRepoLatest(repo, rev),
1537 pull.once('</section><section class="collapse">' +
1538 '<h3>Files' + pathLinks + '</h3>' +
1539 '<div>' + object.length + ' bytes' +
1540 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1541 '</div></section>' +
1542 '<section>'),
1543 extension in imgMimes
1544 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1545 '" alt="' + escapeHTML(filename) + '" />')
1546 : renderObjectData(object, filename, repo),
1547 pull.once('</section>')
1548 ])))
1549 })
1550 })
1551 }
1552
1553 function serveBlobNotFound(repoId, err) {
1554 return serveTemplate('Blob not found', 404, pull.values([
1555 '<h2>Blob not found</h2>',
1556 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1557 '<pre>' + escapeHTML(err.stack) + '</pre>'
1558 ]))
1559 }
1560
1561 /* Raw blob */
1562
1563 function serveRepoRaw(repo, branch, path) {
1564 return readNext(function (cb) {
1565 repo.getFile(branch, path, function (err, object) {
1566 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1567 var extension = getExtension(path[path.length-1])
1568 var contentType = imgMimes[extension]
1569 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1570 })
1571 })
1572 }
1573
1574 function serveRaw(length, contentType) {
1575 var inBody
1576 var headers = {
1577 'Content-Type': contentType || 'text/plain; charset=utf-8',
1578 'Cache-Control': 'max-age=31536000'
1579 }
1580 if (length != null)
1581 headers['Content-Length'] = length
1582 return function (read) {
1583 return function (end, cb) {
1584 if (inBody) return read(end, cb)
1585 if (end) return cb(true)
1586 cb(null, [200, headers])
1587 inBody = true
1588 }
1589 }
1590 }
1591
1592 function serveBlob(req, key) {
1593 return readNext(function (cb) {
1594 ssb.blobs.want(key, function (err, got) {
1595 if (err) cb(null, serveError(err))
1596 else if (!got) cb(null, serve404(req))
1597 else cb(null, serveRaw()(ssb.blobs.get(key)))
1598 })
1599 })
1600 }
1601
1602 /* Digs */
1603
1604 function serveRepoDigs(repo) {
1605 return readNext(function (cb) {
1606 getVotes(repo.id, function (err, votes) {
1607 cb(null, renderRepoPage(repo, null, null, cat([
1608 pull.once('<section><h3>Digs</h3>' +
1609 '<div>Total: ' + votes.upvotes + '</div>'),
1610 pull(
1611 pull.values(Object.keys(votes.upvoters)),
1612 paramap(function (feedId, cb) {
1613 about.getName(feedId, function (err, name) {
1614 if (err) return cb(err)
1615 cb(null, link([feedId], name))
1616 })
1617 }, 8),
1618 ul()
1619 ),
1620 pull.once('</section>')
1621 ])))
1622 })
1623 })
1624 }
1625
1626 /* Issues */
1627
1628 function serveRepoIssues(req, repo, issueId, path) {
1629 var numIssues = 0
1630 var state = req._u.query.state || 'open'
1631 return renderRepoPage(repo, 'issues', null, cat([
1632 pull.once(
1633 (isPublic ? '' :
1634 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1635 '<button class="btn">&plus; New Issue</button>', true) +
1636 '</div>') +
1637 '<h3>Issues</h3>' +
1638 nav([
1639 ['?state=open', 'Open', 'open'],
1640 ['?state=closed', 'Closed', 'closed'],
1641 ['?state=all', 'All', 'all']
1642 ], state)),
1643 pull(
1644 issues.createFeedStream({ project: repo.id }),
1645 pull.filter(function (issue) {
1646 return state == 'all' ? true : (state == 'closed') == !issue.open
1647 }),
1648 pull.map(function (issue) {
1649 numIssues++
1650 var state = (issue.open ? 'open' : 'closed')
1651 return '<section class="collapse">' +
1652 '<i class="issue-state issue-state-' + state + '"' +
1653 ' title="' + ucfirst(state) + '">◼</i> ' +
1654 '<a href="' + encodeLink(issue.id) + '">' +
1655 escapeHTML(issue.title) +
1656 '<span class="right-bar">' +
1657 new Date(issue.created_at).toLocaleString() +
1658 '</span>' +
1659 '</a>' +
1660 '</section>'
1661 })
1662 ),
1663 readOnce(function (cb) {
1664 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1665 })
1666 ]))
1667 }
1668
1669 /* New Issue */
1670
1671 function serveRepoNewIssue(repo, issueId, path) {
1672 return renderRepoPage(repo, 'issues', null, pull.once(
1673 '<h3>New Issue</h3>' +
1674 '<section><form action="" method="post">' +
1675 '<input type="hidden" name="action" value="new-issue">' +
1676 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1677 renderPostForm(repo, 'Description', 8) +
1678 '<button type="submit" class="btn">Create</button>' +
1679 '</form></section>'))
1680 }
1681
1682 /* Issue */
1683
1684 function serveRepoIssue(req, repo, issue, path, postId) {
1685 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1686 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1687 return renderRepoPage(repo, 'issues', null, cat([
1688 pull.once(
1689 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1690 'Rename the issue',
1691 '<h3>' + link([issue.id], issue.title) + '</h3>') +
1692 '<code>' + issue.id + '</code>' +
1693 '<section class="collapse">' +
1694 (issue.open
1695 ? '<strong class="issue-status open">Open</strong>'
1696 : '<strong class="issue-status closed">Closed</strong>')),
1697 readOnce(function (cb) {
1698 about.getName(issue.author, function (err, authorName) {
1699 if (err) return cb(err)
1700 var authorLink = link([issue.author], authorName)
1701 cb(null,
1702 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1703 '<hr/>' +
1704 markdown(issue.text, repo) +
1705 '</section>')
1706 })
1707 }),
1708 // render posts and edits
1709 pull(
1710 ssb.links({
1711 dest: issue.id,
1712 values: true
1713 }),
1714 pull.unique('key'),
1715 addAuthorName(about),
1716 sortMsgs(),
1717 pull.map(function (msg) {
1718 var authorLink = link([msg.value.author], msg.authorName)
1719 var msgTimeLink = link([msg.key],
1720 new Date(msg.value.timestamp).toLocaleString(), false,
1721 'name="' + escapeHTML(msg.key) + '"')
1722 var c = msg.value.content
1723 if (msg.value.timestamp > newestMsg.value.timestamp)
1724 newestMsg = msg
1725 switch (c.type) {
1726 case 'post':
1727 if (c.root == issue.id) {
1728 var changed = issues.isStatusChanged(msg, issue)
1729 return '<section class="collapse">' +
1730 (msg.key == postId ? '<div class="highlight">' : '') +
1731 authorLink +
1732 (changed == null ? '' : ' ' + (
1733 changed ? 'reopened this issue' : 'closed this issue')) +
1734 ' &middot; ' + msgTimeLink +
1735 (msg.key == postId ? '</div>' : '') +
1736 markdown(c.text, repo) +
1737 '</section>'
1738 } else {
1739 var text = c.text || (c.type + ' ' + msg.key)
1740 return '<section class="collapse mention-preview">' +
1741 authorLink + ' mentioned this issue in ' +
1742 '<a href="/' + msg.key + '#' + msg.key + '">' +
1743 String(text).substr(0, 140) + '</a>' +
1744 '</section>'
1745 }
1746 case 'issue':
1747 return '<section class="collapse mention-preview">' +
1748 authorLink + ' mentioned this issue in ' +
1749 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
1750 '</section>'
1751 case 'issue-edit':
1752 return '<section class="collapse">' +
1753 (c.title == null ? '' :
1754 authorLink + ' renamed this issue to <q>' +
1755 escapeHTML(c.title) + '</q>') +
1756 ' &middot; ' + msgTimeLink +
1757 '</section>'
1758 case 'git-update':
1759 var mention = issues.getMention(msg, issue)
1760 if (mention) {
1761 var commitLink = link([repo.id, 'commit', mention.object],
1762 mention.label || mention.object)
1763 return '<section class="collapse">' +
1764 authorLink + ' ' +
1765 (mention.open ? 'reopened this issue' :
1766 'closed this issue') +
1767 ' &middot; ' + msgTimeLink + '<br/>' +
1768 commitLink +
1769 '</section>'
1770 } else if ((mention = getMention(msg, issue.id))) {
1771 var commitLink = link(mention.object ?
1772 [repo.id, 'commit', mention.object] : [msg.key],
1773 mention.label || mention.object || msg.key)
1774 return '<section class="collapse">' +
1775 authorLink + ' mentioned this issue' +
1776 ' &middot; ' + msgTimeLink + '<br/>' +
1777 commitLink +
1778 '</section>'
1779 } else {
1780 // fallthrough
1781 }
1782
1783 default:
1784 return '<section class="collapse">' +
1785 authorLink +
1786 ' &middot; ' + msgTimeLink +
1787 json(c) +
1788 '</section>'
1789 }
1790 })
1791 ),
1792 isPublic ? pull.empty() : readOnce(renderCommentForm)
1793 ]))
1794
1795 function renderCommentForm(cb) {
1796 cb(null, '<section><form action="" method="post">' +
1797 '<input type="hidden" name="action" value="comment">' +
1798 '<input type="hidden" name="id" value="' + issue.id + '">' +
1799 '<input type="hidden" name="issue" value="' + issue.id + '">' +
1800 '<input type="hidden" name="repo" value="' + repo.id + '">' +
1801 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1802 renderPostForm(repo) +
1803 '<input type="submit" class="btn open" value="Comment" />' +
1804 (isAuthor ?
1805 '<input type="submit" class="btn"' +
1806 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1807 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1808 '/>' : '') +
1809 '</form></section>')
1810 }
1811 }
1812
1813}
1814

Built with git-ssb-web