git ssb

30+

cel / git-ssb-web



Tree: 7eac9610f25b563106240cb97750fd27e660a643

Files: 7eac9610f25b563106240cb97750fd27e660a643 / index.js

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

Built with git-ssb-web