git ssb

30+

cel / git-ssb-web



Tree: 53bfcec9abb5e9b039c3f0c5ed66680cc677e14f

Files: 53bfcec9abb5e9b039c3f0c5ed66680cc677e14f / index.js

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

Built with git-ssb-web