git ssb

30+

cel / git-ssb-web



Tree: 395710809e2bb0e4cb1e7414e98bf061cb119c11

Files: 395710809e2bb0e4cb1e7414e98bf061cb119c11 / index.js

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

Built with git-ssb-web