git ssb

30+

cel / git-ssb-web



Tree: e92c15b1dae34409fb4c17abf69cce004e58f89c

Files: e92c15b1dae34409fb4c17abf69cce004e58f89c / index.js

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

Built with git-ssb-web