git ssb

30+

cel / git-ssb-web



Tree: db21c527b861c7c35e5a6ad60990403a8147b01b

Files: db21c527b861c7c35e5a6ad60990403a8147b01b / index.js

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

Built with git-ssb-web