git ssb

30+

cel / git-ssb-web



Tree: 0195a494a9de4868b88311db38de11d614b6da6d

Files: 0195a494a9de4868b88311db38de11d614b6da6d / index.js

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

Built with git-ssb-web