git ssb

30+

cel / git-ssb-web



Tree: 0dcd8bc291330e6ea6c2e7fe6836238e1f68f702

Files: 0dcd8bc291330e6ea6c2e7fe6836238e1f68f702 / index.js

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

Built with git-ssb-web