git ssb

30+

cel / git-ssb-web



Tree: 41444b07037d09738d6ca7efa64afde5b9d01433

Files: 41444b07037d09738d6ca7efa64afde5b9d01433 / index.js

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

Built with git-ssb-web