git ssb

30+

cel / git-ssb-web



Tree: cfc5b28733448e844b57db879704bbe89187d7b7

Files: cfc5b28733448e844b57db879704bbe89187d7b7 / index.js

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

Built with git-ssb-web