git ssb

30+

cel / git-ssb-web



Tree: 682946ef9ff8ec327b436171254439b59dc3c365

Files: 682946ef9ff8ec327b436171254439b59dc3c365 / index.js

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

Built with git-ssb-web