git ssb

30+

cel / git-ssb-web



Tree: d5e0970d64c9af2207d041e9b6c2196ce7ec2fe9

Files: d5e0970d64c9af2207d041e9b6c2196ce7ec2fe9 / index.js

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

Built with git-ssb-web