git ssb

30+

cel / git-ssb-web



Tree: 3b7fb07ebf965857f1104c75a902ed61ce3be03d

Files: 3b7fb07ebf965857f1104c75a902ed61ce3be03d / index.js

61058 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 ]))
1164 }
1165
1166 function renderRepoUpdate(repo, msg, full) {
1167 var c = msg.value.content
1168
1169 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1170 return {name: ref, value: c.refs[ref]}
1171 }) : []
1172 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1173
1174 return '<section class="collapse">' +
1175 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1176 '<br>' +
1177 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1178 refs.map(function (update) {
1179 var name = escapeHTML(update.name)
1180 if (!update.value) {
1181 return 'Deleted ' + name
1182 } else {
1183 var commitLink = link([repo.id, 'commit', update.value])
1184 return name + ' &rarr; ' + commitLink
1185 }
1186 }).join('<br>') +
1187 '</section>'
1188 }
1189
1190 /* Repo commits */
1191
1192 function serveRepoCommits(req, repo, branch) {
1193 var query = req._u.query
1194 return renderRepoPage(repo, 'commits', branch, cat([
1195 pull.once('<h3>Commits</h3>'),
1196 pull(
1197 repo.readLog(query.start || branch),
1198 pull.take(20),
1199 paginate(
1200 !query.start ? '' : function (first, cb) {
1201 cb(null, '&hellip;')
1202 },
1203 pull(
1204 paramap(repo.getCommitParsed.bind(repo), 8),
1205 pull.map(renderCommit.bind(this, repo))
1206 ),
1207 function (last, cb) {
1208 cb(null, '<a href="?start=' + last + '">Older</a>')
1209 }
1210 )
1211 )
1212 ]))
1213 }
1214
1215 function renderCommit(repo, commit) {
1216 var commitPath = [repo.id, 'commit', commit.id]
1217 var treePath = [repo.id, 'tree', commit.id]
1218 return '<section class="collapse">' +
1219 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1220 '<tt>' + commit.id + '</tt> ' +
1221 link(treePath, 'Tree') + '<br>' +
1222 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1223 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1224 '</section>'
1225}
1226
1227 /* Repo tree */
1228
1229 function revMenu(repo, currentName) {
1230 return readOnce(function (cb) {
1231 repo.getRefNames(true, function (err, refs) {
1232 if (err) return cb(err)
1233 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1234 Object.keys(refs).map(function (group) {
1235 return '<optgroup label="' + group + '">' +
1236 refs[group].map(function (name) {
1237 var htmlName = escapeHTML(name)
1238 return '<option value="' + htmlName + '"' +
1239 (name == currentName ? ' selected="selected"' : '') +
1240 '>' + htmlName + '</option>'
1241 }).join('') + '</optgroup>'
1242 }).join('') +
1243 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1244 })
1245 })
1246 }
1247
1248 function renderRepoLatest(repo, rev) {
1249 return readOnce(function (cb) {
1250 repo.getCommitParsed(rev, function (err, commit) {
1251 if (err) return cb(err)
1252 var commitPath = [repo.id, 'commit', commit.id]
1253 cb(null,
1254 'Latest: <strong>' + link(commitPath, commit.title) +
1255 '</strong><br>' +
1256 '<tt>' + commit.id + '</tt><br> ' +
1257 escapeHTML(commit.committer.name) + ' committed on ' +
1258 commit.committer.date.toLocaleString() +
1259 (commit.separateAuthor ? '<br>' +
1260 escapeHTML(commit.author.name) + ' authored on ' +
1261 commit.author.date.toLocaleString() : ''))
1262 })
1263 })
1264 }
1265
1266 // breadcrumbs
1267 function linkPath(basePath, path) {
1268 path = path.slice()
1269 var last = path.pop()
1270 return path.map(function (dir, i) {
1271 return link(basePath.concat(path.slice(0, i+1)), dir)
1272 }).concat(last).join(' / ')
1273 }
1274
1275 function renderRepoTree(repo, rev, path) {
1276 var pathLinks = path.length === 0 ? '' :
1277 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1278 return cat([
1279 pull.once('<h3>Files' + pathLinks + '</h3>'),
1280 pull(
1281 repo.readDir(rev, path),
1282 pull.map(function (file) {
1283 var type = (file.mode === 040000) ? 'tree' :
1284 (file.mode === 0160000) ? 'commit' : 'blob'
1285 if (type == 'commit')
1286 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1287 var filePath = [repo.id, type, rev].concat(path, file.name)
1288 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1289 link(filePath, file.name)]
1290 }),
1291 table('class="files"')
1292 )
1293 ])
1294 }
1295
1296 /* Repo readme */
1297
1298 function renderRepoReadme(repo, branch, path) {
1299 return readNext(function (cb) {
1300 pull(
1301 repo.readDir(branch, path),
1302 pull.filter(function (file) {
1303 return /readme(\.|$)/i.test(file.name)
1304 }),
1305 pull.take(1),
1306 pull.collect(function (err, files) {
1307 if (err) return cb(null, pull.empty())
1308 var file = files[0]
1309 if (!file)
1310 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1311 repo.getObjectFromAny(file.id, function (err, obj) {
1312 if (err) return cb(err)
1313 cb(null, cat([
1314 pull.once('<section><h4><a name="readme">' +
1315 escapeHTML(file.name) + '</a></h4><hr/>'),
1316 renderObjectData(obj, file.name, repo),
1317 pull.once('</section>')
1318 ]))
1319 })
1320 })
1321 )
1322 })
1323 }
1324
1325 /* Repo commit */
1326
1327 function serveRepoCommit(repo, rev) {
1328 return renderRepoPage(repo, null, rev, cat([
1329 readNext(function (cb) {
1330 repo.getCommitParsed(rev, function (err, commit) {
1331 if (err) return cb(err)
1332 var commitPath = [repo.id, 'commit', commit.id]
1333 var treePath = [repo.id, 'tree', commit.id]
1334 cb(null, cat([pull.once(
1335 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1336 '<section class="collapse">' +
1337 '<div class="right-bar">' +
1338 link(treePath, 'Browse Files') +
1339 '</div>' +
1340 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1341 (commit.body ? pre(commit.body) : '') +
1342 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1343 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1344 : '') +
1345 escapeHTML(commit.committer.name) + ' committed on ' +
1346 commit.committer.date.toLocaleString() + '<br/>' +
1347 commit.parents.map(function (id) {
1348 return 'Parent: ' + link([repo.id, 'commit', id], id)
1349 }).join('<br>') +
1350 '</section>'),
1351 renderDiffStat(repo, commit.id, commit.parents)
1352 ]))
1353 })
1354 })
1355 ]))
1356 }
1357
1358 /* Diff stat */
1359
1360 function renderDiffStat(repo, id, parentIds) {
1361 if (parentIds.length == 0) parentIds = [null]
1362 var lastI = parentIds.length
1363 var oldTree = parentIds[0]
1364 var changedFiles = []
1365 return cat([
1366 pull.once('<section><h3>Files changed</h3>'),
1367 pull(
1368 repo.diffTrees(parentIds.concat(id), true),
1369 pull.map(function (item) {
1370 var filename = item.filename = escapeHTML(item.path.join('/'))
1371 var oldId = item.id && item.id[0]
1372 var newId = item.id && item.id[lastI]
1373 var oldMode = item.mode && item.mode[0]
1374 var newMode = item.mode && item.mode[lastI]
1375 var action =
1376 !oldId && newId ? 'added' :
1377 oldId && !newId ? 'deleted' :
1378 oldMode != newMode ?
1379 'changed mode from ' + oldMode.toString(8) +
1380 ' to ' + newMode.toString(8) :
1381 'changed'
1382 if (item.id)
1383 changedFiles.push(item)
1384 var fileHref = item.id ?
1385 '#' + encodeURIComponent(item.path.join('/')) :
1386 encodeLink([repo.id, 'blob', id].concat(item.path))
1387 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1388 }),
1389 table()
1390 ),
1391 pull(
1392 pull.values(changedFiles),
1393 paramap(function (item, cb) {
1394 var done = multicb({ pluck: 1, spread: true })
1395 getRepoObjectString(repo, item.id[0], done())
1396 getRepoObjectString(repo, item.id[lastI], done())
1397 done(function (err, strOld, strNew) {
1398 if (err) return cb(err)
1399 var commitId = item.id[lastI] ? id : parentIds.filter(Boolean)[0]
1400 cb(null, htmlLineDiff(item.filename, item.filename,
1401 strOld, strNew,
1402 encodeLink([repo.id, 'blob', commitId].concat(item.path))))
1403 })
1404 }, 4)
1405 ),
1406 pull.once('</section>' + hashHighlightScript),
1407 ])
1408 }
1409
1410 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref, rawHref) {
1411 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1412 var groups = diff.hunks.map(function (hunk) {
1413 var oldLine = hunk.oldStart
1414 var newLine = hunk.newStart
1415 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1416 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1417 '+' + newLine + ',' + hunk.newLines + ' @@' +
1418 '</td></tr>'
1419 return [header].concat(hunk.lines.map(function (line) {
1420 var s = line[0]
1421 if (s == '\\') return
1422 var html = highlight(line, getExtension(filename))
1423 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1424 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1425 var id = [filename].concat(lineNums).join('-')
1426 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1427 lineNums.map(function (num) {
1428 return '<td class="code-linenum">' +
1429 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1430 num + '</a>' : '') + '</td>'
1431 }).join('') +
1432 '<td class="code-text">' + html + '</td></tr>'
1433 }))
1434 })
1435 return '<pre><table class="code">' +
1436 '<tr><th colspan=3 id="' + anchor + '">' + filename +
1437 '<span class="right-bar">' +
1438 '<a href="' + blobHref + '">View</a> ' +
1439 '</span></th></tr>' +
1440 [].concat.apply([], groups).join('') +
1441 '</table></pre>'
1442 }
1443
1444 /* An unknown message linking to a repo */
1445
1446 function serveRepoSomething(req, repo, id, msg, path) {
1447 return renderRepoPage(repo, null, null,
1448 pull.once('<section><h3>' + link([id]) + '</h3>' +
1449 json(msg) + '</section>'))
1450 }
1451
1452 /* Repo update */
1453
1454 function objsArr(objs) {
1455 return Array.isArray(objs) ? objs :
1456 Object.keys(objs).map(function (sha1) {
1457 var obj = Object.create(objs[sha1])
1458 obj.sha1 = sha1
1459 return obj
1460 })
1461 }
1462
1463 function serveRepoUpdate(req, repo, id, msg, path) {
1464 var raw = req._u.query.raw != null
1465
1466 if (raw)
1467 return renderRepoPage(repo, 'activity', null, pull.once(
1468 '<a href="?" class="raw-link header-align">Info</a>' +
1469 '<h3>Update</h3>' +
1470 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1471
1472 // convert packs to old single-object style
1473 if (msg.content.indexes) {
1474 for (var i = 0; i < msg.content.indexes.length; i++) {
1475 msg.content.packs[i] = {
1476 pack: {link: msg.content.packs[i].link},
1477 idx: msg.content.indexes[i]
1478 }
1479 }
1480 }
1481
1482 return renderRepoPage(repo, 'activity', null, cat([
1483 pull.once(
1484 '<a href="?raw" class="raw-link header-align">Data</a>' +
1485 '<h3>Update</h3>' +
1486 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1487 (msg.content.objects ? '<h3>Objects</h3>' +
1488 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1489 (msg.content.packs ? '<h3>Packs</h3>' +
1490 msg.content.packs.map(renderPack).join('\n') : '')),
1491 cat(!msg.content.packs ? [] : [
1492 pull.once('<h3>Commits</h3>'),
1493 pull(
1494 pull.values(msg.content.packs),
1495 pull.asyncMap(function (pack, cb) {
1496 var done = multicb({ pluck: 1, spread: true })
1497 getBlob(pack.pack.link, done())
1498 getBlob(pack.idx.link, done())
1499 done(function (err, readPack, readIdx) {
1500 if (err) return cb(renderError(err))
1501 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1502 })
1503 }),
1504 pull.flatten(),
1505 pull.asyncMap(function (obj, cb) {
1506 if (obj.type == 'commit')
1507 Repo.getCommitParsed(obj, cb)
1508 else
1509 pull(obj.read, pull.drain(null, cb))
1510 }),
1511 pull.filter(),
1512 pull.map(function (commit) {
1513 return renderCommit(repo, commit)
1514 })
1515 )
1516 ])
1517 ]))
1518 }
1519
1520 function renderObject(obj) {
1521 return '<section class="collapse">' +
1522 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1523 obj.length + ' bytes' +
1524 '</section>'
1525 }
1526
1527 function renderPack(info) {
1528 return '<section class="collapse">' +
1529 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1530 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1531 }
1532
1533 /* Blob */
1534
1535 function serveRepoBlob(repo, rev, path) {
1536 return readNext(function (cb) {
1537 repo.getFile(rev, path, function (err, object) {
1538 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1539 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1540 var pathLinks = path.length === 0 ? '' :
1541 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1542 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1543 var filename = path[path.length-1]
1544 var extension = getExtension(filename)
1545 cb(null, renderRepoPage(repo, 'code', rev, cat([
1546 pull.once('<section><form action="" method="get">' +
1547 '<h3>' + type + ': ' + rev + ' '),
1548 revMenu(repo, rev),
1549 pull.once('</h3></form>'),
1550 type == 'Branch' && renderRepoLatest(repo, rev),
1551 pull.once('</section><section class="collapse">' +
1552 '<h3>Files' + pathLinks + '</h3>' +
1553 '<div>' + object.length + ' bytes' +
1554 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1555 '</div></section>' +
1556 '<section>'),
1557 extension in imgMimes
1558 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1559 '" alt="' + escapeHTML(filename) + '" />')
1560 : renderObjectData(object, filename, repo),
1561 pull.once('</section>')
1562 ])))
1563 })
1564 })
1565 }
1566
1567 function serveBlobNotFound(repoId, err) {
1568 return serveTemplate('Blob not found', 404, pull.values([
1569 '<h2>Blob not found</h2>',
1570 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1571 '<pre>' + escapeHTML(err.stack) + '</pre>'
1572 ]))
1573 }
1574
1575 /* Raw blob */
1576
1577 function serveRepoRaw(repo, branch, path) {
1578 return readNext(function (cb) {
1579 repo.getFile(branch, path, function (err, object) {
1580 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1581 var extension = getExtension(path[path.length-1])
1582 var contentType = imgMimes[extension]
1583 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1584 })
1585 })
1586 }
1587
1588 function serveRaw(length, contentType) {
1589 var inBody
1590 var headers = {
1591 'Content-Type': contentType || 'text/plain; charset=utf-8',
1592 'Cache-Control': 'max-age=31536000'
1593 }
1594 if (length != null)
1595 headers['Content-Length'] = length
1596 return function (read) {
1597 return function (end, cb) {
1598 if (inBody) return read(end, cb)
1599 if (end) return cb(true)
1600 cb(null, [200, headers])
1601 inBody = true
1602 }
1603 }
1604 }
1605
1606 function getBlob(key, cb) {
1607 ssb.blobs.want(key, function (err, got) {
1608 if (err) cb(err)
1609 else if (!got) cb(new Error('Missing blob ' + key))
1610 else cb(null, ssb.blobs.get(key))
1611 })
1612 }
1613
1614 function serveBlob(req, key) {
1615 getBlob(key, function (err, read) {
1616 if (err) cb(null, serveError(err))
1617 else if (!got) cb(null, serve404(req))
1618 else cb(null, serveRaw()(read))
1619 })
1620 }
1621
1622 /* Digs */
1623
1624 function serveRepoDigs(repo) {
1625 return readNext(function (cb) {
1626 getVotes(repo.id, function (err, votes) {
1627 cb(null, renderRepoPage(repo, null, null, cat([
1628 pull.once('<section><h3>Digs</h3>' +
1629 '<div>Total: ' + votes.upvotes + '</div>'),
1630 pull(
1631 pull.values(Object.keys(votes.upvoters)),
1632 paramap(function (feedId, cb) {
1633 about.getName(feedId, function (err, name) {
1634 if (err) return cb(err)
1635 cb(null, link([feedId], name))
1636 })
1637 }, 8),
1638 ul()
1639 ),
1640 pull.once('</section>')
1641 ])))
1642 })
1643 })
1644 }
1645
1646 /* Forks */
1647
1648 function serveRepoForks(repo) {
1649 var hasForks
1650 return renderRepoPage(repo, null, null, cat([
1651 pull.once('<h3>Forks</h3>'),
1652 pull(
1653 ssb.links({
1654 dest: repo.id,
1655 values: true,
1656 rel: 'upstream'
1657 }),
1658 pull.filter(function (msg) {
1659 var c = msg.value.content
1660 return (c && c.type == 'git-repo')
1661 }),
1662 paramap(function (msg, cb) {
1663 hasForks = true
1664 var author = msg.value.author
1665 var done = multicb({ pluck: 1, spread: true })
1666 getRepoName(about, author, msg.key, done())
1667 about.getName(author, done())
1668 done(function (err, repoName, authorName) {
1669 if (err) return cb(err)
1670 var authorLink = link([author], authorName)
1671 var repoLink = link([msg.key], repoName)
1672 cb(null, '<section class="collapse">' +
1673 authorLink + ' / ' + repoLink +
1674 '<span class="right-bar">' +
1675 timestamp(msg.value.timestamp) +
1676 '</span></section>')
1677 })
1678 }, 8)
1679 ),
1680 readOnce(function (cb) {
1681 cb(null, hasForks ? '' : 'No forks')
1682 })
1683 ]))
1684 }
1685
1686 /* Issues */
1687
1688 function serveRepoIssues(req, repo, issueId, path) {
1689 var numIssues = 0
1690 var state = req._u.query.state || 'open'
1691 return renderRepoPage(repo, 'issues', null, cat([
1692 pull.once(
1693 (isPublic ? '' :
1694 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1695 '<button class="btn">&plus; New Issue</button>', true) +
1696 '</div>') +
1697 '<h3>Issues</h3>' +
1698 nav([
1699 ['?state=open', 'Open', 'open'],
1700 ['?state=closed', 'Closed', 'closed'],
1701 ['?state=all', 'All', 'all']
1702 ], state)),
1703 pull(
1704 issues.createFeedStream({ project: repo.id }),
1705 pull.filter(function (issue) {
1706 return state == 'all' ? true : (state == 'closed') == !issue.open
1707 }),
1708 pull.map(function (issue) {
1709 numIssues++
1710 var state = (issue.open ? 'open' : 'closed')
1711 return '<section class="collapse">' +
1712 '<i class="issue-state issue-state-' + state + '"' +
1713 ' title="' + ucfirst(state) + '">◼</i> ' +
1714 '<a href="' + encodeLink(issue.id) + '">' +
1715 escapeHTML(issue.title) +
1716 '<span class="right-bar">' +
1717 new Date(issue.created_at).toLocaleString() +
1718 '</span>' +
1719 '</a>' +
1720 '</section>'
1721 })
1722 ),
1723 readOnce(function (cb) {
1724 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1725 })
1726 ]))
1727 }
1728
1729 /* New Issue */
1730
1731 function serveRepoNewIssue(repo, issueId, path) {
1732 return renderRepoPage(repo, 'issues', null, pull.once(
1733 '<h3>New Issue</h3>' +
1734 '<section><form action="" method="post">' +
1735 '<input type="hidden" name="action" value="new-issue">' +
1736 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1737 renderPostForm(repo, 'Description', 8) +
1738 '<button type="submit" class="btn">Create</button>' +
1739 '</form></section>'))
1740 }
1741
1742 /* Issue */
1743
1744 function serveRepoIssue(req, repo, issue, path, postId) {
1745 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1746 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1747 return renderRepoPage(repo, 'issues', null, cat([
1748 pull.once(
1749 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1750 'Rename the issue',
1751 '<h3>' + link([issue.id], issue.title) + '</h3>') +
1752 '<code>' + issue.id + '</code>' +
1753 '<section class="collapse">' +
1754 (issue.open
1755 ? '<strong class="issue-status open">Open</strong>'
1756 : '<strong class="issue-status closed">Closed</strong>')),
1757 readOnce(function (cb) {
1758 about.getName(issue.author, function (err, authorName) {
1759 if (err) return cb(err)
1760 var authorLink = link([issue.author], authorName)
1761 cb(null,
1762 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1763 '<hr/>' +
1764 markdown(issue.text, repo) +
1765 '</section>')
1766 })
1767 }),
1768 // render posts and edits
1769 pull(
1770 ssb.links({
1771 dest: issue.id,
1772 values: true
1773 }),
1774 pull.unique('key'),
1775 addAuthorName(about),
1776 sortMsgs(),
1777 pull.map(function (msg) {
1778 var authorLink = link([msg.value.author], msg.authorName)
1779 var msgTimeLink = link([msg.key],
1780 new Date(msg.value.timestamp).toLocaleString(), false,
1781 'name="' + escapeHTML(msg.key) + '"')
1782 var c = msg.value.content
1783 if (msg.value.timestamp > newestMsg.value.timestamp)
1784 newestMsg = msg
1785 switch (c.type) {
1786 case 'post':
1787 if (c.root == issue.id) {
1788 var changed = issues.isStatusChanged(msg, issue)
1789 return '<section class="collapse">' +
1790 (msg.key == postId ? '<div class="highlight">' : '') +
1791 authorLink +
1792 (changed == null ? '' : ' ' + (
1793 changed ? 'reopened this issue' : 'closed this issue')) +
1794 ' &middot; ' + msgTimeLink +
1795 (msg.key == postId ? '</div>' : '') +
1796 markdown(c.text, repo) +
1797 '</section>'
1798 } else {
1799 var text = c.text || (c.type + ' ' + msg.key)
1800 return '<section class="collapse mention-preview">' +
1801 authorLink + ' mentioned this issue in ' +
1802 '<a href="/' + msg.key + '#' + msg.key + '">' +
1803 String(text).substr(0, 140) + '</a>' +
1804 '</section>'
1805 }
1806 case 'issue':
1807 return '<section class="collapse mention-preview">' +
1808 authorLink + ' mentioned this issue in ' +
1809 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
1810 '</section>'
1811 case 'issue-edit':
1812 return '<section class="collapse">' +
1813 (c.title == null ? '' :
1814 authorLink + ' renamed this issue to <q>' +
1815 escapeHTML(c.title) + '</q>') +
1816 ' &middot; ' + msgTimeLink +
1817 '</section>'
1818 case 'git-update':
1819 var mention = issues.getMention(msg, issue)
1820 if (mention) {
1821 var commitLink = link([repo.id, 'commit', mention.object],
1822 mention.label || mention.object)
1823 return '<section class="collapse">' +
1824 authorLink + ' ' +
1825 (mention.open ? 'reopened this issue' :
1826 'closed this issue') +
1827 ' &middot; ' + msgTimeLink + '<br/>' +
1828 commitLink +
1829 '</section>'
1830 } else if ((mention = getMention(msg, issue.id))) {
1831 var commitLink = link(mention.object ?
1832 [repo.id, 'commit', mention.object] : [msg.key],
1833 mention.label || mention.object || msg.key)
1834 return '<section class="collapse">' +
1835 authorLink + ' mentioned this issue' +
1836 ' &middot; ' + msgTimeLink + '<br/>' +
1837 commitLink +
1838 '</section>'
1839 } else {
1840 // fallthrough
1841 }
1842
1843 default:
1844 return '<section class="collapse">' +
1845 authorLink +
1846 ' &middot; ' + msgTimeLink +
1847 json(c) +
1848 '</section>'
1849 }
1850 })
1851 ),
1852 isPublic ? pull.empty() : readOnce(renderCommentForm)
1853 ]))
1854
1855 function renderCommentForm(cb) {
1856 cb(null, '<section><form action="" method="post">' +
1857 '<input type="hidden" name="action" value="comment">' +
1858 '<input type="hidden" name="id" value="' + issue.id + '">' +
1859 '<input type="hidden" name="issue" value="' + issue.id + '">' +
1860 '<input type="hidden" name="repo" value="' + repo.id + '">' +
1861 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1862 renderPostForm(repo) +
1863 '<input type="submit" class="btn open" value="Comment" />' +
1864 (isAuthor ?
1865 '<input type="submit" class="btn"' +
1866 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1867 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1868 '/>' : '') +
1869 '</form></section>')
1870 }
1871 }
1872
1873}
1874

Built with git-ssb-web