git ssb

30+

cel / git-ssb-web



Tree: f6796afa2aa64a4b29be370cf2e10548f8745920

Files: f6796afa2aa64a4b29be370cf2e10548f8745920 / index.js

41530 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')
20
21var blockRenderer = new marked.Renderer()
22blockRenderer.urltransform = function (url) {
23 if (ref.isLink(url))
24 return encodeLink(url)
25 return url
26}
27
28marked.setOptions({
29 gfm: true,
30 mentions: true,
31 tables: true,
32 breaks: true,
33 pedantic: false,
34 sanitize: true,
35 smartLists: true,
36 smartypants: false,
37 renderer: blockRenderer
38})
39
40function markdown(text) {
41 if (!text) return ''
42 if (typeof text != 'string') text = String(text)
43 return marked(text)
44}
45
46function parseAddr(str, def) {
47 if (!str) return def
48 var i = str.lastIndexOf(':')
49 if (~i) return {host: str.substr(0, i), port: str.substr(i+1)}
50 if (isNaN(str)) return {host: str, port: def.port}
51 return {host: def.host, port: str}
52}
53
54function flattenPath(parts) {
55 return '/' + parts.map(encodeURIComponent).join('/')
56}
57
58function encodeLink(url) {
59 return '/' + encodeURIComponent(url)
60}
61
62function link(parts, text, raw) {
63 var href = flattenPath(parts)
64 if (text == null) text = parts[parts.length-1]
65 if (!raw) text = escapeHTML(text)
66 return '<a href="' + escapeHTML(href) + '">' + text + '</a>'
67}
68
69function timestamp(time) {
70 time = Number(time)
71 var d = new Date(time)
72 return '<span title="' + time + '">' + d.toLocaleString() + '</span>'
73}
74
75function pre(text) {
76 return '<pre>' + escapeHTML(text) + '</pre>'
77}
78
79function json(obj) {
80 return pre(JSON.stringify(obj, null, 2))
81}
82
83function escapeHTML(str) {
84 return String(str)
85 .replace(/&/g, '&amp;')
86 .replace(/</g, '&lt;')
87 .replace(/>/g, '&gt;')
88 .replace(/"/g, '&quot;')
89}
90
91function escapeHTMLStream() {
92 return pull.map(function (buf) {
93 return escapeHTML(buf.toString('utf8'))
94 })
95}
96
97function table(props) {
98 return function (read) {
99 return cat([
100 pull.once('<table' + (props ? ' ' + props : '') + '>'),
101 pull(
102 read,
103 pull.map(function (row) {
104 return row ? '<tr>' + row.map(function (cell) {
105 return '<td>' + cell + '</td>'
106 }).join('') + '</tr>' : ''
107 })
108 ),
109 pull.once('</table>')
110 ])
111 }
112}
113
114function ul(props) {
115 return function (read) {
116 return cat([
117 pull.once('<ul' + (props ? ' ' + props : '') + '>'),
118 pull(
119 read,
120 pull.map(function (li) {
121 return '<li>' + li + '</li>'
122 })
123 ),
124 pull.once('</ul>')
125 ])
126 }
127}
128
129function renderNameForm(enabled, id, name, action, inputId, title, header) {
130 if (!inputId) inputId = action
131 return '<form class="petname" action="" method="post">' +
132 (enabled ?
133 '<input type="checkbox" class="name-checkbox" id="' + inputId + '" ' +
134 'onfocus="this.form.name.focus()" />' +
135 '<input name="name" class="name" value="' + escapeHTML(name) + '" ' +
136 'onkeyup="if (event.keyCode == 27) this.form.reset()" />' +
137 '<input type="hidden" name="action" value="' + action + '">' +
138 '<input type="hidden" name="id" value="' +
139 escapeHTML(id) + '">' +
140 '<label class="name-toggle" for="' + inputId + '" ' +
141 'title="' + title + '"><i>✍</i></label> ' +
142 '<input class="btn name-btn" type="submit" value="Rename">' +
143 header :
144 header + '<br clear="all"/>'
145 ) +
146 '</form>'
147}
148
149function wrap(tag) {
150 return function (read) {
151 return cat([
152 pull.once('<' + tag + '>'),
153 read,
154 pull.once('</' + tag + '>')
155 ])
156 }
157}
158
159function readNext(fn) {
160 var next
161 return function (end, cb) {
162 if (next) return next(end, cb)
163 fn(function (err, _next) {
164 if (err) return cb(err)
165 next = _next
166 next(null, cb)
167 })
168 }
169}
170
171function readOnce(fn) {
172 var ended
173 return function (end, cb) {
174 fn(function (err, data) {
175 if (err || ended) return cb(err || ended)
176 ended = true
177 cb(null, data)
178 })
179 }
180}
181
182function tryDecodeURIComponent(str) {
183 if (!str || (str[0] == '%' && ref.isBlobId(str)))
184 return str
185 try {
186 str = decodeURIComponent(str)
187 } finally {
188 return str
189 }
190}
191
192function getRepoName(about, ownerId, repoId, cb) {
193 about.getName({
194 owner: ownerId,
195 target: repoId,
196 toString: function () {
197 // hack to fit two parameters into asyncmemo
198 return ownerId + '/' + repoId
199 }
200 }, cb)
201}
202
203function addAuthorName(about) {
204 return paramap(function (msg, cb) {
205 about.getName(msg.value.author, function (err, authorName) {
206 msg.authorName = authorName
207 cb(err, msg)
208 })
209 }, 8)
210}
211
212var hasOwnProp = Object.prototype.hasOwnProperty
213
214function getContentType(filename) {
215 var ext = filename.split('.').pop()
216 return hasOwnProp.call(contentTypes, ext)
217 ? contentTypes[ext]
218 : 'text/plain; charset=utf-8'
219}
220
221var contentTypes = {
222 css: 'text/css'
223}
224
225var staticBase = path.join(__dirname, 'static')
226
227function readReqJSON(req, cb) {
228 pull(
229 toPull(req),
230 pull.collect(function (err, bufs) {
231 if (err) return cb(err)
232 var data
233 try {
234 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
235 } catch(e) {
236 return cb(e)
237 }
238 cb(null, data)
239 })
240 )
241}
242
243var issueCommentScript = '(' + function () {
244 var $ = document.getElementById.bind(document)
245 $('preview-tab-link').onclick = function (e) {
246 with (new XMLHttpRequest()) {
247 open('POST', '', true)
248 onload = function() {
249 $('preview-tab').innerHTML = responseText
250 }
251 send('action=markdown&text=' +
252 encodeURIComponent($('comment-text').value))
253 }
254 }
255}.toString() + ')()'
256
257var msgTypes = {
258 'git-repo': true,
259 'git-update': true,
260 'issue': true
261}
262
263var refLabels = {
264 heads: 'Branches',
265 tags: 'Tags'
266}
267
268var imgMimes = {
269 png: 'image/png',
270 jpeg: 'image/jpeg',
271 jpg: 'image/jpeg',
272 gif: 'image/gif',
273 tif: 'image/tiff',
274 svg: 'image/svg+xml',
275 bmp: 'image/bmp'
276}
277
278var markdownFilenameRegex = /\.md$|\/.markdown$/i
279
280module.exports = function (opts, cb) {
281 var ssb, reconnect, myId, getRepo, getVotes, getMsg, issues
282 var about = function (id, cb) { cb(null, {name: id}) }
283 var reqQueue = []
284 var isPublic = opts.public
285 var ssbAppname = opts.appname || 'ssb'
286
287 var addr = parseAddr(opts.listenAddr, {host: 'localhost', port: 7718})
288 http.createServer(onRequest).listen(addr.port, addr.host, onListening)
289
290 var server = {
291 setSSB: function (_ssb, _reconnect) {
292 _ssb.whoami(function (err, feed) {
293 if (err) throw err
294 ssb = _ssb
295 reconnect = _reconnect
296 myId = feed.id
297 about = ssbAbout(ssb, myId)
298 while (reqQueue.length)
299 onRequest.apply(this, reqQueue.shift())
300 getRepo = asyncMemo(function (id, cb) {
301 getMsg(id, function (err, msg) {
302 if (err) return cb(err)
303 ssbGit.getRepo(ssb, {key: id, value: msg}, {live: true}, cb)
304 })
305 })
306 getVotes = ssbVotes(ssb)
307 getMsg = asyncMemo(ssb.get)
308 issues = Issues.init(ssb)
309 })
310 }
311 }
312
313 function onListening() {
314 var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host
315 console.log('Listening on http://' + host + ':' + addr.port + '/')
316 cb(null, server)
317 }
318
319 /* Serving a request */
320
321 function onRequest(req, res) {
322 console.log(req.method, req.url)
323 if (!ssb) return reqQueue.push(arguments)
324 pull(
325 handleRequest(req),
326 pull.filter(function (data) {
327 if (Array.isArray(data)) {
328 res.writeHead.apply(res, data)
329 return false
330 }
331 return true
332 }),
333 toPull(res)
334 )
335 }
336
337 function handleRequest(req) {
338 var u = req._u = url.parse(req.url, true)
339 var dirs = u.pathname.slice(1).split(/\/+/).map(tryDecodeURIComponent)
340 var dir = dirs[0]
341
342 if (req.method == 'POST') {
343 if (isPublic)
344 return servePlainError(405, 'POST not allowed on public site')
345 return readNext(function (cb) {
346 readReqJSON(req, function (err, data) {
347 if (err) return cb(null, serveError(err, 400))
348 if (!data) return cb(null, serveError(new Error('No data'), 400))
349
350 switch (data.action) {
351 case 'vote':
352 var voteValue = +data.vote || 0
353 if (!data.id)
354 return cb(null, serveError(new Error('Missing vote id'), 400))
355 var msg = schemas.vote(data.id, voteValue)
356 return ssb.publish(msg, function (err) {
357 if (err) return cb(null, serveError(err))
358 cb(null, serveRedirect(req.url))
359 })
360 return
361
362 case 'repo-name':
363 if (!data.name)
364 return cb(null, serveError(new Error('Missing name'), 400))
365 if (!data.id)
366 return cb(null, serveError(new Error('Missing id'), 400))
367 var msg = schemas.name(data.id, data.name)
368 return ssb.publish(msg, function (err) {
369 if (err) return cb(null, serveError(err))
370 cb(null, serveRedirect(req.url))
371 })
372
373 case 'issue-title':
374 if (!data.name)
375 return cb(null, serveError(new Error('Missing name'), 400))
376 if (!data.id)
377 return cb(null, serveError(new Error('Missing id'), 400))
378 var msg = Issues.schemas.edit(data.id, {title: data.name})
379 return ssb.publish(msg, function (err) {
380 if (err) return cb(null, serveError(err))
381 cb(null, serveRedirect(req.url))
382 })
383
384 case 'comment':
385 if (!data.id)
386 return cb(null, serveError(new Error('Missing id'), 400))
387
388 // TODO: add ref mentions
389 var msg = schemas.post(data.text, data.id, data.branch || data.id)
390 if (data.open != null)
391 Issues.schemas.opens(msg, data.id)
392 if (data.close != null)
393 Issues.schemas.closes(msg, data.id)
394 return ssb.publish(msg, function (err) {
395 if (err) return cb(null, serveError(err))
396 cb(null, serveRedirect(req.url))
397 })
398
399 case 'new-issue':
400 return issues.new({
401 project: dir,
402 title: data.title,
403 text: data.text
404 }, function (err, issue) {
405 if (err) return cb(null, serveError(err))
406 cb(null, serveRedirect(encodeLink(issue.id)))
407 })
408
409 case 'markdown':
410 return cb(null, serveMarkdown(data.text))
411
412 default:
413 cb(null, servePlainError(400, 'What are you trying to do?'))
414 }
415 })
416 })
417 }
418
419 if (dir == '')
420 return serveIndex(req)
421 else if (ref.isBlobId(dir))
422 return serveBlob(req, dir)
423 else if (ref.isMsgId(dir))
424 return serveMessage(req, dir, dirs.slice(1))
425 else if (ref.isFeedId(dir))
426 return serveUserPage(dir)
427 else
428 return serveFile(req, dirs)
429 }
430
431 function serveFile(req, dirs) {
432 var filename = path.join.apply(path, [staticBase].concat(dirs))
433 // prevent escaping base dir
434 if (filename.indexOf(staticBase) !== 0)
435 return servePlainError(403, '403 Forbidden')
436
437 return readNext(function (cb) {
438 fs.stat(filename, function (err, stats) {
439 cb(null, err ?
440 err.code == 'ENOENT' ? serve404(req)
441 : servePlainError(500, err.message)
442 : 'if-modified-since' in req.headers &&
443 new Date(req.headers['if-modified-since']) >= stats.mtime ?
444 pull.once([304])
445 : stats.isDirectory() ?
446 servePlainError(403, 'Directory not listable')
447 : cat([
448 pull.once([200, {
449 'Content-Type': getContentType(filename),
450 'Content-Length': stats.size,
451 'Last-Modified': stats.mtime.toGMTString()
452 }]),
453 toPull(fs.createReadStream(filename))
454 ]))
455 })
456 })
457 }
458
459 function servePlainError(code, msg) {
460 return pull.values([
461 [code, {
462 'Content-Length': Buffer.byteLength(msg),
463 'Content-Type': 'text/plain; charset=utf-8'
464 }],
465 msg
466 ])
467 }
468
469 function serve404(req) {
470 return servePlainError(404, '404 Not Found')
471 }
472
473 function serveRedirect(path) {
474 var msg = '<!doctype><html><head><meta charset=utf-8>' +
475 '<title>Redirect</title></head>' +
476 '<body><p><a href="' + path + '">Continue</a></p></body></html>'
477 return pull.values([
478 [302, {
479 'Content-Length': Buffer.byteLength(msg),
480 'Content-Type': 'text/html',
481 Location: path
482 }],
483 msg
484 ])
485 }
486
487 function serveMarkdown(text) {
488 var html = markdown(text)
489 return pull.values([
490 [200, {
491 'Content-Length': Buffer.byteLength(html),
492 'Content-Type': 'text/html; charset=utf-8'
493 }],
494 html
495 ])
496 }
497
498 function renderTry(read) {
499 var ended
500 return function (end, cb) {
501 if (ended) return cb(ended)
502 read(end, function (err, data) {
503 if (err === true)
504 cb(true)
505 else if (err) {
506 ended = true
507 cb(null,
508 '<h3>' + err.name + '</h3>' +
509 '<pre>' + escapeHTML(err.stack) + '</pre>')
510 } else
511 cb(null, data)
512 })
513 }
514 }
515
516 function serveTemplate(title, code, read) {
517 if (read === undefined) return serveTemplate.bind(this, title, code)
518 return cat([
519 pull.values([
520 [code || 200, {
521 'Content-Type': 'text/html'
522 }],
523 '<!doctype html><html><head><meta charset=utf-8>',
524 '<title>' + escapeHTML(title || 'git ssb') + '</title>',
525 '<link rel=stylesheet href="/styles.css"/>',
526 '</head>\n',
527 '<body>',
528 '<header>',
529 '<h1><a href="/">git ssb' +
530 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
531 '</a></h1>',
532 '</header>',
533 '<article>']),
534 renderTry(read),
535 pull.once('<hr/></article></body></html>')
536 ])
537 }
538
539 function serveError(err, status) {
540 if (err.message == 'stream is closed')
541 reconnect()
542 return pull(
543 pull.once(
544 '<h2>' + err.name + '</h3>' +
545 '<pre>' + escapeHTML(err.stack) + '</pre>'),
546 serveTemplate(err.name, status || 500)
547 )
548 }
549
550 /* Feed */
551
552 function renderFeed(feedId) {
553 var opts = {
554 reverse: true,
555 id: feedId
556 }
557 return pull(
558 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
559 pull.filter(function (msg) {
560 return msg.value.content.type in msgTypes &&
561 msg.value.timestamp < Date.now()
562 }),
563 pull.take(20),
564 addAuthorName(about),
565 pull.asyncMap(renderFeedItem)
566 )
567 }
568
569 function renderFeedItem(msg, cb) {
570 var c = msg.value.content
571 var msgLink = link([msg.key],
572 new Date(msg.value.timestamp).toLocaleString())
573 var author = msg.value.author
574 var authorLink = link([msg.value.author], msg.authorName)
575 switch (c.type) {
576 case 'git-repo':
577 return getRepoName(about, author, msg.key, function (err, repoName) {
578 if (err) return cb(err)
579 var repoLink = link([msg.key], repoName)
580 cb(null, '<section class="collapse">' + msgLink + '<br>' +
581 authorLink + ' created repo ' + repoLink + '</section>')
582 })
583 case 'git-update':
584 return getRepoName(about, author, c.repo, function (err, repoName) {
585 if (err) return cb(err)
586 var repoLink = link([c.repo], repoName)
587 cb(null, '<section class="collapse">' + msgLink + '<br>' +
588 authorLink + ' pushed to ' + repoLink + '</section>')
589 })
590 case 'issue':
591 var issueLink = link([msg.key], c.title)
592 return cb(null, '<section class="collapse">' + msgLink + '<br>' +
593 authorLink + ' opened issue ' + issueLink + '</section>')
594 }
595 }
596
597 /* Index */
598
599 function serveIndex() {
600 return serveTemplate('git ssb')(renderFeed())
601 }
602
603 function serveUserPage(feedId) {
604 return serveTemplate(feedId)(cat([
605 readOnce(function (cb) {
606 about.getName(feedId, function (err, name) {
607 cb(null, '<h2>' + link([feedId], name) +
608 '<code class="user-id">' + feedId + '</code></h2>')
609 })
610 }),
611 renderFeed(feedId),
612 ]))
613 }
614
615 /* Message */
616
617 function serveMessage(req, id, path) {
618 return readNext(function (cb) {
619 ssb.get(id, function (err, msg) {
620 if (err) return cb(null, serveError(err))
621 var c = msg.content || {}
622 switch (c.type) {
623 case 'git-repo':
624 return getRepo(id, function (err, repo) {
625 if (err) return cb(null, serveError(err))
626 cb(null, serveRepoPage(req, Repo(repo), path))
627 })
628 case 'git-update':
629 return getRepo(c.repo, function (err, repo) {
630 if (err) return cb(null, serveRepoNotFound(c.repo, err))
631 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
632 })
633 case 'issue':
634 return getRepo(c.project, function (err, repo) {
635 if (err) return cb(null, serveRepoNotFound(c.project, err))
636 issues.get(id, function (err, issue) {
637 if (err) return cb(null, serveError(err))
638 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
639 })
640 })
641 default:
642 if (ref.isMsgId(c.repo))
643 return getRepo(c.repo, function (err, repo) {
644 if (err) return cb(null, serveRepoNotFound(c.repo, err))
645 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
646 })
647 else
648 return cb(null, serveGenericMessage(req, id, msg, path))
649 }
650 })
651 })
652 }
653
654 function serveGenericMessage(req, id, msg, path) {
655 return serveTemplate(id)(pull.once(
656 '<section><h2>' + link([id]) + '</h2>' +
657 json(msg) +
658 '</section>'))
659 }
660
661 /* Repo */
662
663 function serveRepoPage(req, repo, path) {
664 var defaultBranch = 'master'
665 var query = req._u.query
666
667 if (query.rev != null) {
668 // Allow navigating revs using GET query param.
669 // Replace the branch in the path with the rev query value
670 path[0] = path[0] || 'tree'
671 path[1] = query.rev
672 req._u.pathname = flattenPath([repo.id].concat(path))
673 delete req._u.query.rev
674 delete req._u.search
675 return serveRedirect(url.format(req._u))
676 }
677
678 var branch = path[1] || defaultBranch
679 var filePath = path.slice(2)
680 switch (path[0]) {
681 case undefined:
682 return serveRepoTree(repo, branch, [])
683 case 'activity':
684 return serveRepoActivity(repo, branch)
685 case 'commits':
686 return serveRepoCommits(repo, branch)
687 case 'commit':
688 return serveRepoCommit(repo, path[1])
689 case 'tree':
690 return serveRepoTree(repo, branch, filePath)
691 case 'blob':
692 return serveRepoBlob(repo, branch, filePath)
693 case 'raw':
694 return serveRepoRaw(repo, branch, filePath)
695 case 'digs':
696 return serveRepoDigs(repo)
697 case 'issues':
698 switch (path[1]) {
699 case '':
700 case undefined:
701 return serveRepoIssues(repo, branch, filePath)
702 case 'new':
703 if (filePath.length == 0)
704 return serveRepoNewIssue(repo)
705 }
706 default:
707 return serve404(req)
708 }
709 }
710
711 function serveRepoNotFound(id, err) {
712 return serveTemplate('Repo not found', 404, pull.values([
713 '<h2>Repo not found</h2>',
714 '<p>Repo ' + id + ' was not found</p>',
715 '<pre>' + escapeHTML(err.stack) + '</pre>',
716 ]))
717 }
718
719 function renderRepoPage(repo, branch, body) {
720 var gitUrl = 'ssb://' + repo.id
721 var gitLink = '<input class="clone-url" readonly="readonly" ' +
722 'value="' + gitUrl + '" size="' + (2 + gitUrl.length) + '" ' +
723 'onclick="this.select()"/>'
724 var digsPath = [repo.id, 'digs']
725
726 var done = multicb({ pluck: 1, spread: true })
727 getRepoName(about, repo.feed, repo.id, done())
728 about.getName(repo.feed, done())
729 getVotes(repo.id, done())
730
731 return readNext(function (cb) {
732 done(function (err, repoName, authorName, votes) {
733 if (err) return cb(null, serveError(err))
734 var upvoted = votes.upvoters[myId] > 0
735 cb(null, serveTemplate(repo.id)(cat([
736 pull.once(
737 '<div class="repo-title">' +
738 '<form class="right-bar" action="" method="post">' +
739 '<button class="btn" ' +
740 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
741 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
742 '</button>' +
743 (isPublic ? '' : '<input type="hidden" name="vote" value="' +
744 (upvoted ? '0' : '1') + '">' +
745 '<input type="hidden" name="action" value="vote">' +
746 '<input type="hidden" name="id" value="' +
747 escapeHTML(repo.id) + '">') + ' ' +
748 '<strong>' + link(digsPath, votes.upvotes) + '</strong>' +
749 '</form>' +
750 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
751 'Rename the repo',
752 '<h2>' + link([repo.feed], authorName) + ' / ' +
753 link([repo.id], repoName) + '</h2>') +
754 '</div><div class="repo-nav">' + link([repo.id], 'Code') +
755 link([repo.id, 'activity'], 'Activity') +
756 link([repo.id, 'commits', branch || ''], 'Commits') +
757 link([repo.id, 'issues'], 'Issues') +
758 gitLink +
759 '</div>'),
760 body
761 ])))
762 })
763 })
764 }
765
766 function serveRepoTree(repo, rev, path) {
767 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
768 return renderRepoPage(repo, rev, cat([
769 pull.once('<section><form action="" method="get">' +
770 '<h3>' + type + ': ' + rev + ' '),
771 revMenu(repo, rev),
772 pull.once('</h3></form>'),
773 type == 'Branch' && renderRepoLatest(repo, rev),
774 pull.once('</section><section>'),
775 renderRepoTree(repo, rev, path),
776 pull.once('</section>'),
777 renderRepoReadme(repo, rev, path)
778 ]))
779 }
780
781 /* Repo activity */
782
783 function serveRepoActivity(repo, branch) {
784 return renderRepoPage(repo, branch, cat([
785 pull.once('<h3>Activity</h3>'),
786 pull(
787 ssb.links({
788 type: 'git-update',
789 dest: repo.id,
790 source: repo.feed,
791 rel: 'repo',
792 values: true,
793 reverse: true,
794 limit: 8
795 }),
796 pull.map(renderRepoUpdate.bind(this, repo))
797 )
798 ]))
799 }
800
801 function renderRepoUpdate(repo, msg, full) {
802 var c = msg.value.content
803
804 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
805 return {name: ref, value: c.refs[ref]}
806 }) : []
807 var numObjects = c.objects ? Object.keys(c.objects).length : 0
808
809 return '<section class="collapse">' +
810 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
811 '<br>' +
812 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
813 refs.map(function (update) {
814 var name = escapeHTML(update.name)
815 if (!update.value) {
816 return 'Deleted ' + name
817 } else {
818 var commitLink = link([repo.id, 'commit', update.value])
819 return name + ' &rarr; ' + commitLink
820 }
821 }).join('<br>') +
822 '</section>'
823 }
824
825 /* Repo commits */
826
827 function serveRepoCommits(repo, branch) {
828 return renderRepoPage(repo, branch, cat([
829 pull.once('<h3>Commits</h3>'),
830 pull(
831 repo.readLog(branch),
832 pull.asyncMap(function (hash, cb) {
833 repo.getCommitParsed(hash, function (err, commit) {
834 if (err) return cb(err)
835 var commitPath = [repo.id, 'commit', commit.id]
836 var treePath = [repo.id, 'tree', commit.id]
837 cb(null, '<section class="collapse">' +
838 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
839 '<code>' + commit.id + '</code> ' +
840 link(treePath, 'Tree') + '<br>' +
841 (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '<br>' : '') +
842 escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() +
843 '</section>')
844 })
845 })
846 )
847 ]))
848 }
849
850 /* Repo tree */
851
852 function revMenu(repo, currentName) {
853 var currentGroup
854 return cat([
855 pull.once('<select name="rev" onchange="this.form.submit()">'),
856 pull(
857 repo.refs(),
858 pull.map(function (ref) {
859 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
860 var group = m[1]
861 var name = m[2]
862
863 var optgroup = (group === currentGroup) ? '' :
864 (currentGroup ? '</optgroup>' : '') +
865 '<optgroup label="' + (refLabels[group] || group) + '">'
866 currentGroup = group
867 var selected = (name == currentName) ? ' selected="selected"' : ''
868 var htmlName = escapeHTML(name)
869 return optgroup +
870 '<option value="' + htmlName + '"' + selected + '>' +
871 htmlName + '</option>'
872 })
873 ),
874 readOnce(function (cb) {
875 cb(null, currentGroup ? '</optgroup>' : '')
876 }),
877 pull.once('</select> ' +
878 '<noscript><input type="submit" value="Go" /></noscript>')
879 ])
880 }
881
882 function renderRepoLatest(repo, rev) {
883 return readOnce(function (cb) {
884 repo.getCommitParsed(rev, function (err, commit) {
885 if (err) return cb(err)
886 var commitPath = [repo.id, 'commit', commit.id]
887 cb(null,
888 'Latest: <strong>' + link(commitPath, commit.title) +
889 '</strong><br>' +
890 '<code>' + commit.id + '</code><br> ' +
891 escapeHTML(commit.committer.name) + ' committed on ' +
892 commit.committer.date.toLocaleString() +
893 (commit.separateAuthor ? '<br>' +
894 escapeHTML(commit.author.name) + ' authored on ' +
895 commit.author.date.toLocaleString() : ''))
896 })
897 })
898 }
899
900 // breadcrumbs
901 function linkPath(basePath, path) {
902 path = path.slice()
903 var last = path.pop()
904 return path.map(function (dir, i) {
905 return link(basePath.concat(path.slice(0, i+1)), dir)
906 }).concat(last).join(' / ')
907 }
908
909 function renderRepoTree(repo, rev, path) {
910 var pathLinks = path.length === 0 ? '' :
911 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
912 return cat([
913 pull.once('<h3>Files' + pathLinks + '</h3>'),
914 pull(
915 repo.readDir(rev, path),
916 pull.map(function (file) {
917 var type = (file.mode === 040000) ? 'tree' :
918 (file.mode === 0160000) ? 'commit' : 'blob'
919 if (type == 'commit')
920 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
921 var filePath = [repo.id, type, rev].concat(path, file.name)
922 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
923 link(filePath, file.name)]
924 }),
925 table('class="files"')
926 )
927 ])
928 }
929
930 /* Repo readme */
931
932 function renderRepoReadme(repo, branch, path) {
933 return readNext(function (cb) {
934 pull(
935 repo.readDir(branch, path),
936 pull.filter(function (file) {
937 return /readme(\.|$)/i.test(file.name)
938 }),
939 pull.take(1),
940 pull.collect(function (err, files) {
941 if (err) return cb(null, pull.empty())
942 var file = files[0]
943 if (!file)
944 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
945 repo.getObjectFromAny(file.id, function (err, obj) {
946 if (err) return cb(err)
947 cb(null, cat([
948 pull.once('<section><h4>' + escapeHTML(file.name) + '</h4><hr/>'),
949 markdownFilenameRegex.test(file.name) ?
950 readOnce(function (cb) {
951 pull(obj.read, pull.collect(function (err, bufs) {
952 if (err) return cb(err)
953 var buf = Buffer.concat(bufs, obj.length)
954 cb(null, markdown(buf.toString()))
955 }))
956 })
957 : cat([
958 pull.once('<pre>'),
959 pull(obj.read, escapeHTMLStream()),
960 pull.once('</pre>')
961 ]),
962 pull.once('</section>')
963 ]))
964 })
965 })
966 )
967 })
968 }
969
970 /* Repo commit */
971
972 function serveRepoCommit(repo, rev) {
973 return renderRepoPage(repo, rev, cat([
974 pull.once('<h3>Commit ' + rev + '</h3>'),
975 readOnce(function (cb) {
976 repo.getCommitParsed(rev, function (err, commit) {
977 if (err) return cb(err)
978 var commitPath = [repo.id, 'commit', commit.id]
979 var treePath = [repo.id, 'tree', commit.tree]
980 cb(null,
981 '<p><strong>' + link(commitPath, commit.title) +
982 '</strong></p>' +
983 pre(commit.body) +
984 '<p>' +
985 (commit.separateAuthor ? escapeHTML(commit.author.name) +
986 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
987 : '') +
988 escapeHTML(commit.committer.name) + ' committed on ' +
989 commit.committer.date.toLocaleString() + '</p>' +
990 '<p>' + commit.parents.map(function (id) {
991 return 'Parent: ' + link([repo.id, 'commit', id], id)
992 }).join('<br>') + '</p>' +
993 '<p>' +
994 (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') +
995 '</p>')
996 })
997 })
998 ]))
999 }
1000
1001 /* An unknown message linking to a repo */
1002
1003 function serveRepoSomething(req, repo, id, msg, path) {
1004 return renderRepoPage(repo, null,
1005 pull.once('<section><h3>' + link([id]) + '</h3>' +
1006 json(msg) + '</section>'))
1007 }
1008
1009 /* Repo update */
1010
1011 function objsArr(objs) {
1012 return Array.isArray(objs) ? objs :
1013 Object.keys(objs).map(function (sha1) {
1014 var obj = Object.create(objs[sha1])
1015 obj.sha1 = sha1
1016 return obj
1017 })
1018 }
1019
1020 function serveRepoUpdate(req, repo, id, msg, path) {
1021 var raw = req._u.query.raw != null
1022
1023 // convert packs to old single-object style
1024 if (msg.content.indexes) {
1025 for (var i = 0; i < msg.content.indexes.length; i++) {
1026 msg.content.packs[i] = {
1027 pack: {link: msg.content.packs[i].link},
1028 idx: msg.content.indexes[i]
1029 }
1030 }
1031 }
1032
1033 return renderRepoPage(repo, null, pull.once(
1034 (raw ? '<a href="?" class="raw-link header-align">Info</a>' :
1035 '<a href="?raw" class="raw-link header-align">Data</a>') +
1036 '<h3>Update</h3>' +
1037 (raw ? '<section class="collapse">' + json(msg) + '</section>' :
1038 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1039 (msg.content.objects ? '<h3>Objects</h3>' +
1040 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1041 (msg.content.packs ? '<h3>Packs</h3>' +
1042 msg.content.packs.map(renderPack).join('\n') : ''))))
1043 }
1044
1045 function renderObject(obj) {
1046 return '<section class="collapse">' +
1047 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1048 obj.length + ' bytes' +
1049 '</section>'
1050 }
1051
1052 function renderPack(info) {
1053 return '<section class="collapse">' +
1054 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1055 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1056 }
1057
1058 /* Blob */
1059
1060 function serveRepoBlob(repo, rev, path) {
1061 return readNext(function (cb) {
1062 repo.getFile(rev, path, function (err, object) {
1063 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1064 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1065 var pathLinks = path.length === 0 ? '' :
1066 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1067 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1068 var filename = path[path.length-1]
1069 var extension = filename.split('.').pop()
1070 cb(null, renderRepoPage(repo, 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 class="collapse">' +
1077 '<h3>Files' + pathLinks + '</h3>' +
1078 '<div>' + object.length + ' bytes' +
1079 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1080 '</div></section>' +
1081 '<section>'),
1082 extension in imgMimes
1083 ? pull.once('<img src="' + escapeHTML(flattenPath(rawFilePath)) +
1084 '" alt="' + escapeHTML(filename) + '" />')
1085 : markdownFilenameRegex.test(filename)
1086 ? pull(object.read, escapeHTMLStream(), pull.map(markdown))
1087 : pull(object.read, escapeHTMLStream(), wrap('pre')),
1088 pull.once('</section>')
1089 ])))
1090 })
1091 })
1092 }
1093
1094 function serveBlobNotFound(repoId, err) {
1095 return serveTemplate(400, 'Blob not found', pull.values([
1096 '<h2>Blob not found</h2>',
1097 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1098 '<pre>' + escapeHTML(err.stack) + '</pre>'
1099 ]))
1100 }
1101
1102 /* Raw blob */
1103
1104 function serveRepoRaw(repo, branch, path) {
1105 return readNext(function (cb) {
1106 repo.getFile(branch, path, function (err, object) {
1107 if (err) return cb(null, servePlainError(404, 'Blob not found'))
1108 var extension = path[path.length-1].split('.').pop()
1109 var contentType = imgMimes[extension]
1110 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1111 })
1112 })
1113 }
1114
1115 function serveRaw(length, contentType) {
1116 var inBody
1117 var headers = {
1118 'Content-Type': contentType || 'text/plain; charset=utf-8',
1119 'Cache-Control': 'max-age=31536000'
1120 }
1121 if (length != null)
1122 headers['Content-Length'] = length
1123 return function (read) {
1124 return function (end, cb) {
1125 if (inBody) return read(end, cb)
1126 if (end) return cb(true)
1127 cb(null, [200, headers])
1128 inBody = true
1129 }
1130 }
1131 }
1132
1133 function serveBlob(req, key) {
1134 return readNext(function (cb) {
1135 ssb.blobs.want(key, function (err, got) {
1136 if (err) cb(null, serveError(err))
1137 else if (!got) cb(null, serve404(req))
1138 else cb(null, serveRaw()(ssb.blobs.get(key)))
1139 })
1140 })
1141 }
1142
1143 /* Digs */
1144
1145 function serveRepoDigs(repo) {
1146 return readNext(function (cb) {
1147 getVotes(repo.id, function (err, votes) {
1148 cb(null, renderRepoPage(repo, '', cat([
1149 pull.once('<section><h3>Digs</h3>' +
1150 '<div>Total: ' + votes.upvotes + '</div>'),
1151 pull(
1152 pull.values(Object.keys(votes.upvoters)),
1153 pull.asyncMap(function (feedId, cb) {
1154 about.getName(feedId, function (err, name) {
1155 if (err) return cb(err)
1156 cb(null, link([feedId], name))
1157 })
1158 }),
1159 ul()
1160 ),
1161 pull.once('</section>')
1162 ])))
1163 })
1164 })
1165 }
1166
1167 /* Issues */
1168
1169 function serveRepoIssues(repo, issueId, path) {
1170 var numIssues = 0
1171 return renderRepoPage(repo, '', cat([
1172 pull.once(
1173 (isPublic ? '' :
1174 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1175 '<button class="btn">&plus; New Issue</button>', true) +
1176 '</div>') +
1177 '<h3>Issues</h3>'),
1178 pull(
1179 issues.createFeedStream({ project: repo.id }),
1180 pull.map(function (issue) {
1181 numIssues++
1182 return '<section class="collapse">' +
1183 '<a href="' + encodeLink(issue.id) + '">' +
1184 escapeHTML(issue.title) +
1185 '<span class="issue-info">' +
1186 new Date(issue.created_at).toLocaleString() +
1187 '</span>' +
1188 '</a>' +
1189 '</section>'
1190 })
1191 ),
1192 readOnce(function (cb) {
1193 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1194 })
1195 ]))
1196 }
1197
1198 /* New Issue */
1199
1200 function serveRepoNewIssue(repo, issueId, path) {
1201 return renderRepoPage(repo, '', pull.once(
1202 '<h3>New Issue</h3>' +
1203 '<section><form class="new-issue" action="" method="post">' +
1204 '<input type="hidden" name="action" value="new-issue">' +
1205 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1206 '<p><textarea class="wide-input" name="text" placeholder="Description" rows="12" cols="77"></textarea></p>' +
1207 '<button type="submit" class="btn">Create</button>' +
1208 '</form></section>'))
1209 }
1210
1211 /* Issue */
1212
1213 function serveRepoIssue(req, repo, issue, path) {
1214 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1215 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1216 return renderRepoPage(repo, null, cat([
1217 pull.once(
1218 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1219 'Rename the issue',
1220 '<h3>' + issue.title + '</h3>') +
1221 '<code>' + issue.id + '</code>' +
1222 '<section class="collapse">' +
1223 (issue.open
1224 ? '<strong class="issue-status open">Open</strong>'
1225 : '<strong class="issue-status closed">Closed</strong>')),
1226 readOnce(function (cb) {
1227 about.getName(issue.author, function (err, authorName) {
1228 if (err) return cb(err)
1229 var authorLink = link([issue.author], authorName)
1230 cb(null,
1231 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1232 '<hr/>' +
1233 markdown(issue.text) +
1234 '</section>')
1235 })
1236 }),
1237 // render posts and edits
1238 pull(
1239 ssb.links({
1240 dest: issue.id,
1241 values: true
1242 }),
1243 pull.unique('key'),
1244 addAuthorName(about),
1245 pull.map(function (msg) {
1246 var authorLink = link([msg.value.author], msg.authorName)
1247 var msgTimeLink = link([msg.key],
1248 new Date(msg.value.timestamp).toLocaleString())
1249 var c = msg.value.content
1250 if (msg.value.timestamp > newestMsg.value.timestamp)
1251 newestMsg = msg
1252 switch (c.type) {
1253 case 'post':
1254 if (c.root == issue.id) {
1255 var changed = issues.isStatusChanged(msg, issue)
1256 return '<section class="collapse">' +
1257 authorLink +
1258 (changed == null ? '' : ' ' + (
1259 changed ? 'reopened this issue' : 'closed this issue')) +
1260 ' &middot; ' + msgTimeLink +
1261 markdown(c.text) +
1262 '</section>'
1263 } else {
1264 var text = c.text || (c.type + ' ' + msg.key)
1265 return '<section class="collapse mention-preview">' +
1266 authorLink + ' mentioned this issue in ' +
1267 link([msg.key], String(text).substr(0, 140)) +
1268 '</section>'
1269 }
1270 case 'issue-edit':
1271 return '<section class="collapse">' +
1272 (c.title == null ? '' :
1273 authorLink + ' renamed this issue to <q>' +
1274 escapeHTML(c.title) + '</q>') +
1275 ' &middot; ' + msgTimeLink +
1276 '</section>'
1277 default:
1278 return '<section class="collapse">' +
1279 authorLink +
1280 ' &middot; ' + msgTimeLink +
1281 json(c) +
1282 '</section>'
1283 }
1284 })
1285 ),
1286 isPublic ? pull.empty() : readOnce(renderCommentForm)
1287 ]))
1288
1289 function renderCommentForm(cb) {
1290 cb(null, '<section><form action="" method="post">' +
1291 '<input type="radio" class="tab-radio" id="tab1" name="tab" checked="checked"/>' +
1292 '<input type="radio" class="tab-radio" id="tab2" name="tab"/>' +
1293 '<div class="tab-links">' +
1294 '<label for="tab1" id="write-tab-link" class="tab1-link">Write</label>' +
1295 '<label for="tab2" id="preview-tab-link" class="tab2-link">Preview</label>' +
1296 '</div>' +
1297 '<div id="write-tab" class="tab1">' +
1298 '<input type="hidden" name="action" value="comment">' +
1299 '<input type="hidden" name="id" value="' + issue.id + '">' +
1300 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1301 '<textarea id="comment-text" name="text" class="wide-input" rows="4" cols="77"></textarea>' +
1302 '</div>' +
1303 '<div class="preview-text tab2" id="preview-tab">' +
1304 '</div>' +
1305 (isAuthor ?
1306 '<input type="submit" class="btn"' +
1307 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1308 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1309 '/>' : '') +
1310 '<input type="submit" class="btn open" value="Comment" />' +
1311 '<script>' + issueCommentScript + '</script>' +
1312 '</section></form>')
1313 }
1314 }
1315
1316}
1317

Built with git-ssb-web