git ssb

30+

cel / git-ssb-web



Tree: 85dd6e2c80b5aa7daef0a42e25cc6d5f77c1ff9d

Files: 85dd6e2c80b5aa7daef0a42e25cc6d5f77c1ff9d / index.js

61448 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 renderError(err, tag) {
681 tag = tag || 'h3'
682 return '<' + tag + '>' + err.name + '</' + tag + '>' +
683 '<pre>' + escapeHTML(err.stack) + '</pre>'
684 }
685
686 function renderTry(read) {
687 var ended
688 return function (end, cb) {
689 if (ended) return cb(ended)
690 read(end, function (err, data) {
691 if (err === true)
692 cb(true)
693 else if (err) {
694 ended = true
695 cb(null, renderError(err, 'h3'))
696 } else
697 cb(null, data)
698 })
699 }
700 }
701
702 function serveTemplate(title, code, read) {
703 if (read === undefined) return serveTemplate.bind(this, title, code)
704 return cat([
705 pull.values([
706 [code || 200, {
707 'Content-Type': 'text/html'
708 }],
709 '<!doctype html><html><head><meta charset=utf-8>',
710 '<title>' + escapeHTML(title || 'git ssb') + '</title>',
711 '<link rel=stylesheet href="/static/styles.css"/>',
712 '<link rel=stylesheet href="/highlight/github.css"/>',
713 '</head>\n',
714 '<body>',
715 '<header>',
716 '<h1><a href="/">git ssb' +
717 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
718 '</a></h1>',
719 '</header>',
720 '<article>']),
721 renderTry(read),
722 pull.once('<hr/></article></body></html>')
723 ])
724 }
725
726 function serveError(err, status) {
727 if (err.message == 'stream is closed')
728 reconnect()
729 return pull(
730 pull.once(renderError(err, 'h2')),
731 serveTemplate(err.name, status || 500)
732 )
733 }
734
735 function renderObjectData(obj, filename, repo) {
736 var ext = getExtension(filename)
737 return readOnce(function (cb) {
738 readObjectString(obj, function (err, buf) {
739 buf = buf.toString('utf8')
740 if (err) return cb(err)
741 cb(null, (ext == 'md' || ext == 'markdown')
742 ? markdown(buf, repo)
743 : renderCodeTable(buf, ext) + hashHighlightScript)
744 })
745 })
746 }
747
748 function renderCodeTable(buf, ext) {
749 return '<pre><table class="code">' +
750 highlight(buf, ext).split('\n').map(function (line, i) {
751 i++
752 return '<tr id="L' + i + '">' +
753 '<td class="code-linenum">' + '<a href="#L' + i + '">' + i + '</td>' +
754 '<td class="code-text">' + line + '</td></tr>'
755 }).join('') +
756 '</table></pre>'
757 }
758
759 /* Feed */
760
761 function renderFeed(req, feedId) {
762 var query = req._u.query
763 var opts = {
764 reverse: !query.forwards,
765 lt: query.lt && +query.lt || Date.now(),
766 gt: query.gt && +query.gt,
767 id: feedId
768 }
769 return pull(
770 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
771 pull.filter(function (msg) {
772 return msg.value.content.type in msgTypes
773 }),
774 pull.take(20),
775 addAuthorName(about),
776 query.forwards && pullReverse(),
777 paginate(
778 function (first, cb) {
779 if (!query.lt && !query.gt) return cb(null, '')
780 var gt = feedId ? first.value.sequence : first.value.timestamp + 1
781 var q = qs.stringify({
782 gt: gt,
783 forwards: 1
784 })
785 cb(null, '<a href="?' + q + '">Newer</a>')
786 },
787 paramap(renderFeedItem, 8),
788 function (last, cb) {
789 cb(null, '<a href="?' + qs.stringify({
790 lt: feedId ? last.value.sequence : last.value.timestamp - 1
791 }) + '">Older</a>')
792 },
793 function (cb) {
794 cb(null, query.forwards ?
795 '<a href="?lt=' + (opts.gt + 1) + '">Older</a>' :
796 '<a href="?gt=' + (opts.lt - 1) + '&amp;forwards=1">Newer</a>')
797 }
798 )
799 )
800 }
801
802 function renderFeedItem(msg, cb) {
803 var c = msg.value.content
804 var msgLink = link([msg.key],
805 new Date(msg.value.timestamp).toLocaleString())
806 var author = msg.value.author
807 var authorLink = link([msg.value.author], msg.authorName)
808 switch (c.type) {
809 case 'git-repo':
810 var done = multicb({ pluck: 1, spread: true })
811 getRepoName(about, author, msg.key, done())
812 if (c.upstream) {
813 getRepoName(about, author, c.upstream, done())
814 return done(function (err, repoName, upstreamName) {
815 cb(null, '<section class="collapse">' + msgLink + '<br>' +
816 authorLink + ' forked ' + link([c.upstream], upstreamName) +
817 ' to ' + link([msg.key], repoName) + '</section>')
818 })
819 } else {
820 return done(function (err, repoName) {
821 if (err) return cb(err)
822 var repoLink = link([msg.key], repoName)
823 cb(null, '<section class="collapse">' + msgLink + '<br>' +
824 authorLink + ' created repo ' + repoLink + '</section>')
825 })
826 }
827 case 'git-update':
828 return getRepoName(about, author, c.repo, function (err, repoName) {
829 if (err) return cb(err)
830 var repoLink = link([c.repo], repoName)
831 cb(null, '<section class="collapse">' + msgLink + '<br>' +
832 authorLink + ' pushed to ' + repoLink + '</section>')
833 })
834 case 'issue':
835 var issueLink = link([msg.key], c.title)
836 return getRepoName(about, author, c.project, function (err, repoName) {
837 if (err) return cb(err)
838 var repoLink = link([c.project], repoName)
839 cb(null, '<section class="collapse">' + msgLink + '<br>' +
840 authorLink + ' opened issue ' + issueLink +
841 ' on ' + repoLink + '</section>')
842 })
843 }
844 }
845
846 /* Index */
847
848 function serveIndex(req) {
849 return serveTemplate('git ssb')(renderFeed(req))
850 }
851
852 function serveUserPage(req, feedId, dirs) {
853 switch (dirs[0]) {
854 case undefined:
855 case '':
856 case 'activity':
857 return serveUserActivity(req, feedId)
858 case 'repos':
859 return serveUserRepos(feedId)
860 }
861 }
862
863 function renderUserPage(feedId, page, body) {
864 return serveTemplate(feedId)(cat([
865 readOnce(function (cb) {
866 about.getName(feedId, function (err, name) {
867 cb(null, '<h2>' + link([feedId], name) +
868 '<code class="user-id">' + feedId + '</code></h2>' +
869 nav([
870 [[feedId], 'Activity', 'activity'],
871 [[feedId, 'repos'], 'Repos', 'repos']
872 ], page))
873 })
874 }),
875 body,
876 ]))
877 }
878
879 function serveUserActivity(req, feedId) {
880 return renderUserPage(feedId, 'activity', renderFeed(req, feedId))
881 }
882
883 function serveUserRepos(feedId) {
884 return renderUserPage(feedId, 'repos', pull(
885 ssb.messagesByType({
886 type: 'git-repo',
887 reverse: true
888 }),
889 pull.filter(function (msg) {
890 return msg.value.author == feedId
891 }),
892 pull.take(20),
893 paramap(function (msg, cb) {
894 getRepoName(about, feedId, msg.key, function (err, repoName) {
895 if (err) return cb(err)
896 cb(null, '<section class="collapse">' +
897 link([msg.key], repoName) +
898 '</section>')
899 })
900 }, 8)
901 ))
902 }
903
904 /* Message */
905
906 function serveMessage(req, id, path) {
907 return readNext(function (cb) {
908 ssb.get(id, function (err, msg) {
909 if (err) return cb(null, serveError(err))
910 var c = msg.content || {}
911 switch (c.type) {
912 case 'git-repo':
913 return getRepo(id, function (err, repo) {
914 if (err) return cb(null, serveError(err))
915 cb(null, serveRepoPage(req, Repo(repo), path))
916 })
917 case 'git-update':
918 return getRepo(c.repo, function (err, repo) {
919 if (err) return cb(null, serveRepoNotFound(c.repo, err))
920 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
921 })
922 case 'issue':
923 return getRepo(c.project, function (err, repo) {
924 if (err) return cb(null, serveRepoNotFound(c.project, err))
925 issues.get(id, function (err, issue) {
926 if (err) return cb(null, serveError(err))
927 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
928 })
929 })
930 case 'post':
931 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
932 var done = multicb({ pluck: 1, spread: true })
933 getRepo(c.repo, done())
934 issues.get(c.issue, done())
935 return done(function (err, repo, issue) {
936 if (err) {
937 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
938 return cb(null, serveError(err))
939 }
940 cb(null, serveRepoIssue(req, Repo(repo), issue, path, id))
941 })
942 }
943 // fallthrough
944 default:
945 if (ref.isMsgId(c.repo))
946 return getRepo(c.repo, function (err, repo) {
947 if (err) return cb(null, serveRepoNotFound(c.repo, err))
948 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
949 })
950 else
951 return cb(null, serveGenericMessage(req, id, msg, path))
952 }
953 })
954 })
955 }
956
957 function serveGenericMessage(req, id, msg, path) {
958 return serveTemplate(id)(pull.once(
959 '<section><h2>' + link([id]) + '</h2>' +
960 json(msg) +
961 '</section>'))
962 }
963
964 /* Repo */
965
966 function serveRepoPage(req, repo, path) {
967 var defaultBranch = 'master'
968 var query = req._u.query
969
970 if (query.rev != null) {
971 // Allow navigating revs using GET query param.
972 // Replace the branch in the path with the rev query value
973 path[0] = path[0] || 'tree'
974 path[1] = query.rev
975 req._u.pathname = encodeLink([repo.id].concat(path))
976 delete req._u.query.rev
977 delete req._u.search
978 return serveRedirect(url.format(req._u))
979 }
980
981 // get branch
982 return path[1] ?
983 serveRepoPage2(req, repo, path) :
984 readNext(function (cb) {
985 // TODO: handle this in pull-git-repo or ssb-git-repo
986 repo.getSymRef('HEAD', true, function (err, ref) {
987 if (err) return cb(err)
988 repo.resolveRef(ref, function (err, rev) {
989 path[1] = rev ? ref : null
990 cb(null, serveRepoPage2(req, repo, path))
991 })
992 })
993 })
994 }
995
996 function serveRepoPage2(req, repo, path) {
997 var branch = path[1]
998 var filePath = path.slice(2)
999 switch (path[0]) {
1000 case undefined:
1001 case '':
1002 return serveRepoTree(repo, branch, [])
1003 case 'activity':
1004 return serveRepoActivity(repo, branch)
1005 case 'commits':
1006 return serveRepoCommits(req, repo, branch)
1007 case 'commit':
1008 return serveRepoCommit(repo, path[1])
1009 case 'tree':
1010 return serveRepoTree(repo, branch, filePath)
1011 case 'blob':
1012 return serveRepoBlob(repo, branch, filePath)
1013 case 'raw':
1014 return serveRepoRaw(repo, branch, filePath)
1015 case 'digs':
1016 return serveRepoDigs(repo)
1017 case 'forks':
1018 return serveRepoForks(repo)
1019 case 'issues':
1020 switch (path[1]) {
1021 case 'new':
1022 if (filePath.length == 0)
1023 return serveRepoNewIssue(repo)
1024 break
1025 default:
1026 return serveRepoIssues(req, repo, branch, filePath)
1027 }
1028 default:
1029 return serve404(req)
1030 }
1031 }
1032
1033 function serveRepoNotFound(id, err) {
1034 return serveTemplate('Repo not found', 404, pull.values([
1035 '<h2>Repo not found</h2>',
1036 '<p>Repo ' + id + ' was not found</p>',
1037 '<pre>' + escapeHTML(err.stack) + '</pre>',
1038 ]))
1039 }
1040
1041 function renderRepoPage(repo, page, branch, body) {
1042 var gitUrl = 'ssb://' + repo.id
1043 var gitLink = '<input class="clone-url" readonly="readonly" ' +
1044 'value="' + gitUrl + '" size="61" ' +
1045 'onclick="this.select()"/>'
1046 var digsPath = [repo.id, 'digs']
1047
1048 var done = multicb({ pluck: 1, spread: true })
1049 getRepoName(about, repo.feed, repo.id, done())
1050 about.getName(repo.feed, done())
1051 getVotes(repo.id, done())
1052
1053 if (repo.upstream) {
1054 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1055 about.getName(repo.upstream.feed, done())
1056 }
1057
1058 return readNext(function (cb) {
1059 done(function (err, repoName, authorName, votes,
1060 upstreamName, upstreamAuthorName) {
1061 if (err) return cb(null, serveError(err))
1062 var upvoted = votes.upvoters[myId] > 0
1063 var upstreamLink = !repo.upstream ? '' :
1064 link([repo.upstream])
1065 cb(null, serveTemplate(repo.id)(cat([
1066 pull.once(
1067 '<div class="repo-title">' +
1068 '<form class="right-bar" action="" method="post">' +
1069 '<button class="btn" ' +
1070 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1071 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
1072 '</button>' +
1073 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1074 (upvoted ? '0' : '1') + '">' +
1075 '<input type="hidden" name="action" value="repo">' +
1076 '<input type="hidden" name="id" value="' +
1077 escapeHTML(repo.id) + '">') + ' ' +
1078 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1079 (isPublic ? '' :
1080 '<button class="btn" type="submit" name="fork">' +
1081 '<i>⑂</i> Fork' +
1082 '</button>') + ' ' +
1083 link([repo.id, 'forks'], '+', false, ' title="Forks"') +
1084 '</form>' +
1085 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
1086 'Rename the repo',
1087 '<h2>' + link([repo.feed], authorName) + ' / ' +
1088 link([repo.id], repoName) + '</h2>') +
1089 '</div>' +
1090 (repo.upstream ?
1091 '<small>forked from ' +
1092 link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
1093 link([repo.upstream.id], upstreamName) +
1094 '</small>' : '') +
1095 nav([
1096 [[repo.id], 'Code', 'code'],
1097 [[repo.id, 'activity'], 'Activity', 'activity'],
1098 [[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
1099 [[repo.id, 'issues'], 'Issues', 'issues']
1100 ], page, gitLink)),
1101 body
1102 ])))
1103 })
1104 })
1105 }
1106
1107 function serveEmptyRepo(repo) {
1108 if (repo.feed != myId)
1109 return renderRepoPage(repo, 'code', null, pull.once(
1110 '<section>' +
1111 '<h3>Empty repository</h3>' +
1112 '</section>'))
1113
1114 var gitUrl = 'ssb://' + repo.id
1115 return renderRepoPage(repo, 'code', null, pull.once(
1116 '<section>' +
1117 '<h3>Getting started</h3>' +
1118 '<h4>Create a new repository</h4><pre>' +
1119 'touch README.md\n' +
1120 'git init\n' +
1121 'git add README.md\n' +
1122 'git commit -m "Initial commit"\n' +
1123 'git remote add origin ' + gitUrl + '\n' +
1124 'git push -u origin master</pre>\n' +
1125 '<h4>Push an existing repository</h4>\n' +
1126 '<pre>git remote add origin ' + gitUrl + '\n' +
1127 'git push -u origin master</pre>' +
1128 '</section>'))
1129 }
1130
1131 function serveRepoTree(repo, rev, path) {
1132 if (!rev) return serveEmptyRepo(repo)
1133 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1134 return renderRepoPage(repo, 'code', rev, cat([
1135 pull.once('<section><form action="" method="get">' +
1136 '<h3>' + type + ': ' + rev + ' '),
1137 revMenu(repo, rev),
1138 pull.once('</h3></form>'),
1139 type == 'Branch' && renderRepoLatest(repo, rev),
1140 pull.once('</section><section>'),
1141 renderRepoTree(repo, rev, path),
1142 pull.once('</section>'),
1143 renderRepoReadme(repo, rev, path)
1144 ]))
1145 }
1146
1147 /* Repo activity */
1148
1149 function serveRepoActivity(repo, branch) {
1150 return renderRepoPage(repo, 'activity', branch, cat([
1151 pull.once('<h3>Activity</h3>'),
1152 pull(
1153 ssb.links({
1154 type: 'git-update',
1155 dest: repo.id,
1156 source: repo.feed,
1157 rel: 'repo',
1158 values: true,
1159 reverse: true
1160 }),
1161 pull.map(renderRepoUpdate.bind(this, repo))
1162 ),
1163 readOnce(function (cb) {
1164 var done = multicb({ pluck: 1, spread: true })
1165 about.getName(repo.feed, done())
1166 getMsg(repo.id, done())
1167 done(function (err, authorName, msg) {
1168 if (err) return cb(err)
1169 renderFeedItem({
1170 key: repo.id,
1171 value: msg,
1172 authorName: authorName
1173 }, cb)
1174 })
1175 })
1176 ]))
1177 }
1178
1179 function renderRepoUpdate(repo, msg, full) {
1180 var c = msg.value.content
1181
1182 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1183 return {name: ref, value: c.refs[ref]}
1184 }) : []
1185 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1186
1187 return '<section class="collapse">' +
1188 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1189 '<br>' +
1190 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1191 refs.map(function (update) {
1192 var name = escapeHTML(update.name)
1193 if (!update.value) {
1194 return 'Deleted ' + name
1195 } else {
1196 var commitLink = link([repo.id, 'commit', update.value])
1197 return name + ' &rarr; ' + commitLink
1198 }
1199 }).join('<br>') +
1200 '</section>'
1201 }
1202
1203 /* Repo commits */
1204
1205 function serveRepoCommits(req, repo, branch) {
1206 var query = req._u.query
1207 return renderRepoPage(repo, 'commits', branch, cat([
1208 pull.once('<h3>Commits</h3>'),
1209 pull(
1210 repo.readLog(query.start || branch),
1211 pull.take(20),
1212 paginate(
1213 !query.start ? '' : function (first, cb) {
1214 cb(null, '&hellip;')
1215 },
1216 pull(
1217 paramap(repo.getCommitParsed.bind(repo), 8),
1218 pull.map(renderCommit.bind(this, repo))
1219 ),
1220 function (last, cb) {
1221 cb(null, '<a href="?start=' + last + '">Older</a>')
1222 }
1223 )
1224 )
1225 ]))
1226 }
1227
1228 function renderCommit(repo, commit) {
1229 var commitPath = [repo.id, 'commit', commit.id]
1230 var treePath = [repo.id, 'tree', commit.id]
1231 return '<section class="collapse">' +
1232 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1233 '<tt>' + commit.id + '</tt> ' +
1234 link(treePath, 'Tree') + '<br>' +
1235 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1236 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1237 '</section>'
1238}
1239
1240 /* Repo tree */
1241
1242 function revMenu(repo, currentName) {
1243 return readOnce(function (cb) {
1244 repo.getRefNames(true, function (err, refs) {
1245 if (err) return cb(err)
1246 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1247 Object.keys(refs).map(function (group) {
1248 return '<optgroup label="' + group + '">' +
1249 refs[group].map(function (name) {
1250 var htmlName = escapeHTML(name)
1251 return '<option value="' + htmlName + '"' +
1252 (name == currentName ? ' selected="selected"' : '') +
1253 '>' + htmlName + '</option>'
1254 }).join('') + '</optgroup>'
1255 }).join('') +
1256 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1257 })
1258 })
1259 }
1260
1261 function renderRepoLatest(repo, rev) {
1262 return readOnce(function (cb) {
1263 repo.getCommitParsed(rev, function (err, commit) {
1264 if (err) return cb(err)
1265 var commitPath = [repo.id, 'commit', commit.id]
1266 cb(null,
1267 'Latest: <strong>' + link(commitPath, commit.title) +
1268 '</strong><br>' +
1269 '<tt>' + commit.id + '</tt><br> ' +
1270 escapeHTML(commit.committer.name) + ' committed on ' +
1271 commit.committer.date.toLocaleString() +
1272 (commit.separateAuthor ? '<br>' +
1273 escapeHTML(commit.author.name) + ' authored on ' +
1274 commit.author.date.toLocaleString() : ''))
1275 })
1276 })
1277 }
1278
1279 // breadcrumbs
1280 function linkPath(basePath, path) {
1281 path = path.slice()
1282 var last = path.pop()
1283 return path.map(function (dir, i) {
1284 return link(basePath.concat(path.slice(0, i+1)), dir)
1285 }).concat(last).join(' / ')
1286 }
1287
1288 function renderRepoTree(repo, rev, path) {
1289 var pathLinks = path.length === 0 ? '' :
1290 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1291 return cat([
1292 pull.once('<h3>Files' + pathLinks + '</h3>'),
1293 pull(
1294 repo.readDir(rev, path),
1295 pull.map(function (file) {
1296 var type = (file.mode === 040000) ? 'tree' :
1297 (file.mode === 0160000) ? 'commit' : 'blob'
1298 if (type == 'commit')
1299 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1300 var filePath = [repo.id, type, rev].concat(path, file.name)
1301 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1302 link(filePath, file.name)]
1303 }),
1304 table('class="files"')
1305 )
1306 ])
1307 }
1308
1309 /* Repo readme */
1310
1311 function renderRepoReadme(repo, branch, path) {
1312 return readNext(function (cb) {
1313 pull(
1314 repo.readDir(branch, path),
1315 pull.filter(function (file) {
1316 return /readme(\.|$)/i.test(file.name)
1317 }),
1318 pull.take(1),
1319 pull.collect(function (err, files) {
1320 if (err) return cb(null, pull.empty())
1321 var file = files[0]
1322 if (!file)
1323 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1324 repo.getObjectFromAny(file.id, function (err, obj) {
1325 if (err) return cb(err)
1326 cb(null, cat([
1327 pull.once('<section><h4><a name="readme">' +
1328 escapeHTML(file.name) + '</a></h4><hr/>'),
1329 renderObjectData(obj, file.name, repo),
1330 pull.once('</section>')
1331 ]))
1332 })
1333 })
1334 )
1335 })
1336 }
1337
1338 /* Repo commit */
1339
1340 function serveRepoCommit(repo, rev) {
1341 return renderRepoPage(repo, null, rev, cat([
1342 readNext(function (cb) {
1343 repo.getCommitParsed(rev, function (err, commit) {
1344 if (err) return cb(err)
1345 var commitPath = [repo.id, 'commit', commit.id]
1346 var treePath = [repo.id, 'tree', commit.id]
1347 cb(null, cat([pull.once(
1348 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1349 '<section class="collapse">' +
1350 '<div class="right-bar">' +
1351 link(treePath, 'Browse Files') +
1352 '</div>' +
1353 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1354 (commit.body ? pre(commit.body) : '') +
1355 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1356 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1357 : '') +
1358 escapeHTML(commit.committer.name) + ' committed on ' +
1359 commit.committer.date.toLocaleString() + '<br/>' +
1360 commit.parents.map(function (id) {
1361 return 'Parent: ' + link([repo.id, 'commit', id], id)
1362 }).join('<br>') +
1363 '</section>'),
1364 renderDiffStat(repo, commit.id, commit.parents)
1365 ]))
1366 })
1367 })
1368 ]))
1369 }
1370
1371 /* Diff stat */
1372
1373 function renderDiffStat(repo, id, parentIds) {
1374 if (parentIds.length == 0) parentIds = [null]
1375 var lastI = parentIds.length
1376 var oldTree = parentIds[0]
1377 var changedFiles = []
1378 return cat([
1379 pull.once('<section><h3>Files changed</h3>'),
1380 pull(
1381 repo.diffTrees(parentIds.concat(id), true),
1382 pull.map(function (item) {
1383 var filename = item.filename = escapeHTML(item.path.join('/'))
1384 var oldId = item.id && item.id[0]
1385 var newId = item.id && item.id[lastI]
1386 var oldMode = item.mode && item.mode[0]
1387 var newMode = item.mode && item.mode[lastI]
1388 var action =
1389 !oldId && newId ? 'added' :
1390 oldId && !newId ? 'deleted' :
1391 oldMode != newMode ?
1392 'changed mode from ' + oldMode.toString(8) +
1393 ' to ' + newMode.toString(8) :
1394 'changed'
1395 if (item.id)
1396 changedFiles.push(item)
1397 var fileHref = item.id ?
1398 '#' + encodeURIComponent(item.path.join('/')) :
1399 encodeLink([repo.id, 'blob', id].concat(item.path))
1400 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1401 }),
1402 table()
1403 ),
1404 pull(
1405 pull.values(changedFiles),
1406 paramap(function (item, cb) {
1407 var done = multicb({ pluck: 1, spread: true })
1408 getRepoObjectString(repo, item.id[0], done())
1409 getRepoObjectString(repo, item.id[lastI], done())
1410 done(function (err, strOld, strNew) {
1411 if (err) return cb(err)
1412 var commitId = item.id[lastI] ? id : parentIds.filter(Boolean)[0]
1413 cb(null, htmlLineDiff(item.filename, item.filename,
1414 strOld, strNew,
1415 encodeLink([repo.id, 'blob', commitId].concat(item.path))))
1416 })
1417 }, 4)
1418 ),
1419 pull.once('</section>' + hashHighlightScript),
1420 ])
1421 }
1422
1423 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref, rawHref) {
1424 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1425 var groups = diff.hunks.map(function (hunk) {
1426 var oldLine = hunk.oldStart
1427 var newLine = hunk.newStart
1428 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1429 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1430 '+' + newLine + ',' + hunk.newLines + ' @@' +
1431 '</td></tr>'
1432 return [header].concat(hunk.lines.map(function (line) {
1433 var s = line[0]
1434 if (s == '\\') return
1435 var html = highlight(line, getExtension(filename))
1436 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1437 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1438 var id = [filename].concat(lineNums).join('-')
1439 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1440 lineNums.map(function (num) {
1441 return '<td class="code-linenum">' +
1442 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1443 num + '</a>' : '') + '</td>'
1444 }).join('') +
1445 '<td class="code-text">' + html + '</td></tr>'
1446 }))
1447 })
1448 return '<pre><table class="code">' +
1449 '<tr><th colspan=3 id="' + anchor + '">' + filename +
1450 '<span class="right-bar">' +
1451 '<a href="' + blobHref + '">View</a> ' +
1452 '</span></th></tr>' +
1453 [].concat.apply([], groups).join('') +
1454 '</table></pre>'
1455 }
1456
1457 /* An unknown message linking to a repo */
1458
1459 function serveRepoSomething(req, repo, id, msg, path) {
1460 return renderRepoPage(repo, null, null,
1461 pull.once('<section><h3>' + link([id]) + '</h3>' +
1462 json(msg) + '</section>'))
1463 }
1464
1465 /* Repo update */
1466
1467 function objsArr(objs) {
1468 return Array.isArray(objs) ? objs :
1469 Object.keys(objs).map(function (sha1) {
1470 var obj = Object.create(objs[sha1])
1471 obj.sha1 = sha1
1472 return obj
1473 })
1474 }
1475
1476 function serveRepoUpdate(req, repo, id, msg, path) {
1477 var raw = req._u.query.raw != null
1478
1479 if (raw)
1480 return renderRepoPage(repo, 'activity', null, pull.once(
1481 '<a href="?" class="raw-link header-align">Info</a>' +
1482 '<h3>Update</h3>' +
1483 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1484
1485 // convert packs to old single-object style
1486 if (msg.content.indexes) {
1487 for (var i = 0; i < msg.content.indexes.length; i++) {
1488 msg.content.packs[i] = {
1489 pack: {link: msg.content.packs[i].link},
1490 idx: msg.content.indexes[i]
1491 }
1492 }
1493 }
1494
1495 return renderRepoPage(repo, 'activity', null, cat([
1496 pull.once(
1497 '<a href="?raw" class="raw-link header-align">Data</a>' +
1498 '<h3>Update</h3>' +
1499 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1500 (msg.content.objects ? '<h3>Objects</h3>' +
1501 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1502 (msg.content.packs ? '<h3>Packs</h3>' +
1503 msg.content.packs.map(renderPack).join('\n') : '')),
1504 cat(!msg.content.packs ? [] : [
1505 pull.once('<h3>Commits</h3>'),
1506 pull(
1507 pull.values(msg.content.packs),
1508 pull.asyncMap(function (pack, cb) {
1509 var done = multicb({ pluck: 1, spread: true })
1510 getBlob(pack.pack.link, done())
1511 getBlob(pack.idx.link, done())
1512 done(function (err, readPack, readIdx) {
1513 if (err) return cb(renderError(err))
1514 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1515 })
1516 }),
1517 pull.flatten(),
1518 pull.asyncMap(function (obj, cb) {
1519 if (obj.type == 'commit')
1520 Repo.getCommitParsed(obj, cb)
1521 else
1522 pull(obj.read, pull.drain(null, cb))
1523 }),
1524 pull.filter(),
1525 pull.map(function (commit) {
1526 return renderCommit(repo, commit)
1527 })
1528 )
1529 ])
1530 ]))
1531 }
1532
1533 function renderObject(obj) {
1534 return '<section class="collapse">' +
1535 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1536 obj.length + ' bytes' +
1537 '</section>'
1538 }
1539
1540 function renderPack(info) {
1541 return '<section class="collapse">' +
1542 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1543 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1544 }
1545
1546 /* Blob */
1547
1548 function serveRepoBlob(repo, rev, path) {
1549 return readNext(function (cb) {
1550 repo.getFile(rev, path, function (err, object) {
1551 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1552 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1553 var pathLinks = path.length === 0 ? '' :
1554 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1555 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1556 var filename = path[path.length-1]
1557 var extension = getExtension(filename)
1558 cb(null, renderRepoPage(repo, 'code', rev, cat([
1559 pull.once('<section><form action="" method="get">' +
1560 '<h3>' + type + ': ' + rev + ' '),
1561 revMenu(repo, rev),
1562 pull.once('</h3></form>'),
1563 type == 'Branch' && renderRepoLatest(repo, rev),
1564 pull.once('</section><section class="collapse">' +
1565 '<h3>Files' + pathLinks + '</h3>' +
1566 '<div>' + object.length + ' bytes' +
1567 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1568 '</div></section>' +
1569 '<section>'),
1570 extension in imgMimes
1571 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1572 '" alt="' + escapeHTML(filename) + '" />')
1573 : renderObjectData(object, filename, repo),
1574 pull.once('</section>')
1575 ])))
1576 })
1577 })
1578 }
1579
1580 function serveBlobNotFound(repoId, err) {
1581 return serveTemplate('Blob not found', 404, pull.values([
1582 '<h2>Blob not found</h2>',
1583 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1584 '<pre>' + escapeHTML(err.stack) + '</pre>'
1585 ]))
1586 }
1587
1588 /* Raw blob */
1589
1590 function serveRepoRaw(repo, branch, path) {
1591 return readNext(function (cb) {
1592 repo.getFile(branch, path, function (err, object) {
1593 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1594 var extension = getExtension(path[path.length-1])
1595 var contentType = imgMimes[extension]
1596 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1597 })
1598 })
1599 }
1600
1601 function serveRaw(length, contentType) {
1602 var inBody
1603 var headers = {
1604 'Content-Type': contentType || 'text/plain; charset=utf-8',
1605 'Cache-Control': 'max-age=31536000'
1606 }
1607 if (length != null)
1608 headers['Content-Length'] = length
1609 return function (read) {
1610 return function (end, cb) {
1611 if (inBody) return read(end, cb)
1612 if (end) return cb(true)
1613 cb(null, [200, headers])
1614 inBody = true
1615 }
1616 }
1617 }
1618
1619 function getBlob(key, cb) {
1620 ssb.blobs.want(key, function (err, got) {
1621 if (err) cb(err)
1622 else if (!got) cb(new Error('Missing blob ' + key))
1623 else cb(null, ssb.blobs.get(key))
1624 })
1625 }
1626
1627 function serveBlob(req, key) {
1628 getBlob(key, function (err, read) {
1629 if (err) cb(null, serveError(err))
1630 else if (!got) cb(null, serve404(req))
1631 else cb(null, serveRaw()(read))
1632 })
1633 }
1634
1635 /* Digs */
1636
1637 function serveRepoDigs(repo) {
1638 return readNext(function (cb) {
1639 getVotes(repo.id, function (err, votes) {
1640 cb(null, renderRepoPage(repo, null, null, cat([
1641 pull.once('<section><h3>Digs</h3>' +
1642 '<div>Total: ' + votes.upvotes + '</div>'),
1643 pull(
1644 pull.values(Object.keys(votes.upvoters)),
1645 paramap(function (feedId, cb) {
1646 about.getName(feedId, function (err, name) {
1647 if (err) return cb(err)
1648 cb(null, link([feedId], name))
1649 })
1650 }, 8),
1651 ul()
1652 ),
1653 pull.once('</section>')
1654 ])))
1655 })
1656 })
1657 }
1658
1659 /* Forks */
1660
1661 function serveRepoForks(repo) {
1662 var hasForks
1663 return renderRepoPage(repo, null, null, cat([
1664 pull.once('<h3>Forks</h3>'),
1665 pull(
1666 ssb.links({
1667 dest: repo.id,
1668 values: true,
1669 rel: 'upstream'
1670 }),
1671 pull.filter(function (msg) {
1672 var c = msg.value.content
1673 return (c && c.type == 'git-repo')
1674 }),
1675 paramap(function (msg, cb) {
1676 hasForks = true
1677 var author = msg.value.author
1678 var done = multicb({ pluck: 1, spread: true })
1679 getRepoName(about, author, msg.key, done())
1680 about.getName(author, done())
1681 done(function (err, repoName, authorName) {
1682 if (err) return cb(err)
1683 var authorLink = link([author], authorName)
1684 var repoLink = link([msg.key], repoName)
1685 cb(null, '<section class="collapse">' +
1686 authorLink + ' / ' + repoLink +
1687 '<span class="right-bar">' +
1688 timestamp(msg.value.timestamp) +
1689 '</span></section>')
1690 })
1691 }, 8)
1692 ),
1693 readOnce(function (cb) {
1694 cb(null, hasForks ? '' : 'No forks')
1695 })
1696 ]))
1697 }
1698
1699 /* Issues */
1700
1701 function serveRepoIssues(req, repo, issueId, path) {
1702 var numIssues = 0
1703 var state = req._u.query.state || 'open'
1704 return renderRepoPage(repo, 'issues', null, cat([
1705 pull.once(
1706 (isPublic ? '' :
1707 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1708 '<button class="btn">&plus; New Issue</button>', true) +
1709 '</div>') +
1710 '<h3>Issues</h3>' +
1711 nav([
1712 ['?state=open', 'Open', 'open'],
1713 ['?state=closed', 'Closed', 'closed'],
1714 ['?state=all', 'All', 'all']
1715 ], state)),
1716 pull(
1717 issues.createFeedStream({ project: repo.id }),
1718 pull.filter(function (issue) {
1719 return state == 'all' ? true : (state == 'closed') == !issue.open
1720 }),
1721 pull.map(function (issue) {
1722 numIssues++
1723 var state = (issue.open ? 'open' : 'closed')
1724 return '<section class="collapse">' +
1725 '<i class="issue-state issue-state-' + state + '"' +
1726 ' title="' + ucfirst(state) + '">◼</i> ' +
1727 '<a href="' + encodeLink(issue.id) + '">' +
1728 escapeHTML(issue.title) +
1729 '<span class="right-bar">' +
1730 new Date(issue.created_at).toLocaleString() +
1731 '</span>' +
1732 '</a>' +
1733 '</section>'
1734 })
1735 ),
1736 readOnce(function (cb) {
1737 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1738 })
1739 ]))
1740 }
1741
1742 /* New Issue */
1743
1744 function serveRepoNewIssue(repo, issueId, path) {
1745 return renderRepoPage(repo, 'issues', null, pull.once(
1746 '<h3>New Issue</h3>' +
1747 '<section><form action="" method="post">' +
1748 '<input type="hidden" name="action" value="new-issue">' +
1749 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1750 renderPostForm(repo, 'Description', 8) +
1751 '<button type="submit" class="btn">Create</button>' +
1752 '</form></section>'))
1753 }
1754
1755 /* Issue */
1756
1757 function serveRepoIssue(req, repo, issue, path, postId) {
1758 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1759 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1760 return renderRepoPage(repo, 'issues', null, cat([
1761 pull.once(
1762 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1763 'Rename the issue',
1764 '<h3>' + link([issue.id], issue.title) + '</h3>') +
1765 '<code>' + issue.id + '</code>' +
1766 '<section class="collapse">' +
1767 (issue.open
1768 ? '<strong class="issue-status open">Open</strong>'
1769 : '<strong class="issue-status closed">Closed</strong>')),
1770 readOnce(function (cb) {
1771 about.getName(issue.author, function (err, authorName) {
1772 if (err) return cb(err)
1773 var authorLink = link([issue.author], authorName)
1774 cb(null,
1775 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1776 '<hr/>' +
1777 markdown(issue.text, repo) +
1778 '</section>')
1779 })
1780 }),
1781 // render posts and edits
1782 pull(
1783 ssb.links({
1784 dest: issue.id,
1785 values: true
1786 }),
1787 pull.unique('key'),
1788 addAuthorName(about),
1789 sortMsgs(),
1790 pull.map(function (msg) {
1791 var authorLink = link([msg.value.author], msg.authorName)
1792 var msgTimeLink = link([msg.key],
1793 new Date(msg.value.timestamp).toLocaleString(), false,
1794 'name="' + escapeHTML(msg.key) + '"')
1795 var c = msg.value.content
1796 if (msg.value.timestamp > newestMsg.value.timestamp)
1797 newestMsg = msg
1798 switch (c.type) {
1799 case 'post':
1800 if (c.root == issue.id) {
1801 var changed = issues.isStatusChanged(msg, issue)
1802 return '<section class="collapse">' +
1803 (msg.key == postId ? '<div class="highlight">' : '') +
1804 authorLink +
1805 (changed == null ? '' : ' ' + (
1806 changed ? 'reopened this issue' : 'closed this issue')) +
1807 ' &middot; ' + msgTimeLink +
1808 (msg.key == postId ? '</div>' : '') +
1809 markdown(c.text, repo) +
1810 '</section>'
1811 } else {
1812 var text = c.text || (c.type + ' ' + msg.key)
1813 return '<section class="collapse mention-preview">' +
1814 authorLink + ' mentioned this issue in ' +
1815 '<a href="/' + msg.key + '#' + msg.key + '">' +
1816 String(text).substr(0, 140) + '</a>' +
1817 '</section>'
1818 }
1819 case 'issue':
1820 return '<section class="collapse mention-preview">' +
1821 authorLink + ' mentioned this issue in ' +
1822 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
1823 '</section>'
1824 case 'issue-edit':
1825 return '<section class="collapse">' +
1826 (c.title == null ? '' :
1827 authorLink + ' renamed this issue to <q>' +
1828 escapeHTML(c.title) + '</q>') +
1829 ' &middot; ' + msgTimeLink +
1830 '</section>'
1831 case 'git-update':
1832 var mention = issues.getMention(msg, issue)
1833 if (mention) {
1834 var commitLink = link([repo.id, 'commit', mention.object],
1835 mention.label || mention.object)
1836 return '<section class="collapse">' +
1837 authorLink + ' ' +
1838 (mention.open ? 'reopened this issue' :
1839 'closed this issue') +
1840 ' &middot; ' + msgTimeLink + '<br/>' +
1841 commitLink +
1842 '</section>'
1843 } else if ((mention = getMention(msg, issue.id))) {
1844 var commitLink = link(mention.object ?
1845 [repo.id, 'commit', mention.object] : [msg.key],
1846 mention.label || mention.object || msg.key)
1847 return '<section class="collapse">' +
1848 authorLink + ' mentioned this issue' +
1849 ' &middot; ' + msgTimeLink + '<br/>' +
1850 commitLink +
1851 '</section>'
1852 } else {
1853 // fallthrough
1854 }
1855
1856 default:
1857 return '<section class="collapse">' +
1858 authorLink +
1859 ' &middot; ' + msgTimeLink +
1860 json(c) +
1861 '</section>'
1862 }
1863 })
1864 ),
1865 isPublic ? pull.empty() : readOnce(renderCommentForm)
1866 ]))
1867
1868 function renderCommentForm(cb) {
1869 cb(null, '<section><form action="" method="post">' +
1870 '<input type="hidden" name="action" value="comment">' +
1871 '<input type="hidden" name="id" value="' + issue.id + '">' +
1872 '<input type="hidden" name="issue" value="' + issue.id + '">' +
1873 '<input type="hidden" name="repo" value="' + repo.id + '">' +
1874 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1875 renderPostForm(repo) +
1876 '<input type="submit" class="btn open" value="Comment" />' +
1877 (isAuthor ?
1878 '<input type="submit" class="btn"' +
1879 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1880 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1881 '/>' : '') +
1882 '</form></section>')
1883 }
1884 }
1885
1886}
1887

Built with git-ssb-web