git ssb

30+

cel / git-ssb-web



Tree: 11d710dc59aeff08bd0093012217deb84b8e2f00

Files: 11d710dc59aeff08bd0093012217deb84b8e2f00 / index.js

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

Built with git-ssb-web