git ssb

30+

cel / git-ssb-web



Tree: 38f058f88ea3d8eb9e4f8ea5f16fb622289b3d88

Files: 38f058f88ea3d8eb9e4f8ea5f16fb622289b3d88 / index.js

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

Built with git-ssb-web