git ssb

30+

cel / git-ssb-web



Tree: 4158c83c87660fc6a59d13f7f5fb7fe7be4319d4

Files: 4158c83c87660fc6a59d13f7f5fb7fe7be4319d4 / index.js

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

Built with git-ssb-web