git ssb

30+

cel / git-ssb-web



Tree: 938ec32c4cb0a8be01d3db6338e3fd585eb1b3ff

Files: 938ec32c4cb0a8be01d3db6338e3fd585eb1b3ff / index.js

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

Built with git-ssb-web