git ssb

30+

cel / git-ssb-web



Tree: dc7ec84172d5818fd3e4a31a04781ecb4edc7f27

Files: dc7ec84172d5818fd3e4a31a04781ecb4edc7f27 / index.js

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

Built with git-ssb-web