git ssb

30+

cel / git-ssb-web



Tree: 46b5b337be23ffadeb73a973c5007b8426840483

Files: 46b5b337be23ffadeb73a973c5007b8426840483 / index.js

41536 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 cb(null, '<section class="collapse">' + msgLink + '<br>' +
610 authorLink + ' opened issue ' + issueLink + '</section>')
611 }
612 }
613
614 /* Index */
615
616 function serveIndex() {
617 return serveTemplate('git ssb')(renderFeed())
618 }
619
620 function serveUserPage(feedId) {
621 return serveTemplate(feedId)(cat([
622 readOnce(function (cb) {
623 about.getName(feedId, function (err, name) {
624 cb(null, '<h2>' + link([feedId], name) +
625 '<code class="user-id">' + feedId + '</code></h2>')
626 })
627 }),
628 renderFeed(feedId),
629 ]))
630 }
631
632 /* Message */
633
634 function serveMessage(req, id, path) {
635 return readNext(function (cb) {
636 ssb.get(id, function (err, msg) {
637 if (err) return cb(null, serveError(err))
638 var c = msg.content || {}
639 switch (c.type) {
640 case 'git-repo':
641 return getRepo(id, function (err, repo) {
642 if (err) return cb(null, serveError(err))
643 cb(null, serveRepoPage(req, Repo(repo), path))
644 })
645 case 'git-update':
646 return getRepo(c.repo, function (err, repo) {
647 if (err) return cb(null, serveRepoNotFound(c.repo, err))
648 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
649 })
650 case 'issue':
651 return getRepo(c.project, function (err, repo) {
652 if (err) return cb(null, serveRepoNotFound(c.project, err))
653 issues.get(id, function (err, issue) {
654 if (err) return cb(null, serveError(err))
655 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
656 })
657 })
658 default:
659 if (ref.isMsgId(c.repo))
660 return getRepo(c.repo, function (err, repo) {
661 if (err) return cb(null, serveRepoNotFound(c.repo, err))
662 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
663 })
664 else
665 return cb(null, serveGenericMessage(req, id, msg, path))
666 }
667 })
668 })
669 }
670
671 function serveGenericMessage(req, id, msg, path) {
672 return serveTemplate(id)(pull.once(
673 '<section><h2>' + link([id]) + '</h2>' +
674 json(msg) +
675 '</section>'))
676 }
677
678 /* Repo */
679
680 function serveRepoPage(req, repo, path) {
681 var defaultBranch = 'master'
682 var query = req._u.query
683
684 if (query.rev != null) {
685 // Allow navigating revs using GET query param.
686 // Replace the branch in the path with the rev query value
687 path[0] = path[0] || 'tree'
688 path[1] = query.rev
689 req._u.pathname = flattenPath([repo.id].concat(path))
690 delete req._u.query.rev
691 delete req._u.search
692 return serveRedirect(url.format(req._u))
693 }
694
695 var branch = path[1] || defaultBranch
696 var filePath = path.slice(2)
697 switch (path[0]) {
698 case undefined:
699 return serveRepoTree(repo, branch, [])
700 case 'activity':
701 return serveRepoActivity(repo, branch)
702 case 'commits':
703 return serveRepoCommits(repo, branch)
704 case 'commit':
705 return serveRepoCommit(repo, path[1])
706 case 'tree':
707 return serveRepoTree(repo, branch, filePath)
708 case 'blob':
709 return serveRepoBlob(repo, branch, filePath)
710 case 'raw':
711 return serveRepoRaw(repo, branch, filePath)
712 case 'digs':
713 return serveRepoDigs(repo)
714 case 'issues':
715 switch (path[1]) {
716 case '':
717 case undefined:
718 return serveRepoIssues(repo, branch, filePath)
719 case 'new':
720 if (filePath.length == 0)
721 return serveRepoNewIssue(repo)
722 }
723 default:
724 return serve404(req)
725 }
726 }
727
728 function serveRepoNotFound(id, err) {
729 return serveTemplate('Repo not found', 404, pull.values([
730 '<h2>Repo not found</h2>',
731 '<p>Repo ' + id + ' was not found</p>',
732 '<pre>' + escapeHTML(err.stack) + '</pre>',
733 ]))
734 }
735
736 function renderRepoPage(repo, branch, body) {
737 var gitUrl = 'ssb://' + repo.id
738 var gitLink = '<input class="clone-url" readonly="readonly" ' +
739 'value="' + gitUrl + '" size="' + (2 + gitUrl.length) + '" ' +
740 'onclick="this.select()"/>'
741 var digsPath = [repo.id, 'digs']
742
743 var done = multicb({ pluck: 1, spread: true })
744 getRepoName(about, repo.feed, repo.id, done())
745 about.getName(repo.feed, done())
746 getVotes(repo.id, done())
747
748 return readNext(function (cb) {
749 done(function (err, repoName, authorName, votes) {
750 if (err) return cb(null, serveError(err))
751 var upvoted = votes.upvoters[myId] > 0
752 cb(null, serveTemplate(repo.id)(cat([
753 pull.once(
754 '<div class="repo-title">' +
755 '<form class="right-bar" action="" method="post">' +
756 '<button class="btn" ' +
757 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
758 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
759 '</button>' +
760 (isPublic ? '' : '<input type="hidden" name="vote" value="' +
761 (upvoted ? '0' : '1') + '">' +
762 '<input type="hidden" name="action" value="vote">' +
763 '<input type="hidden" name="id" value="' +
764 escapeHTML(repo.id) + '">') + ' ' +
765 '<strong>' + link(digsPath, votes.upvotes) + '</strong>' +
766 '</form>' +
767 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
768 'Rename the repo',
769 '<h2>' + link([repo.feed], authorName) + ' / ' +
770 link([repo.id], repoName) + '</h2>') +
771 '</div><div class="repo-nav">' + link([repo.id], 'Code') +
772 link([repo.id, 'activity'], 'Activity') +
773 link([repo.id, 'commits', branch || ''], 'Commits') +
774 link([repo.id, 'issues'], 'Issues') +
775 gitLink +
776 '</div>'),
777 body
778 ])))
779 })
780 })
781 }
782
783 function serveRepoTree(repo, rev, path) {
784 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
785 return renderRepoPage(repo, rev, cat([
786 pull.once('<section><form action="" method="get">' +
787 '<h3>' + type + ': ' + rev + ' '),
788 revMenu(repo, rev),
789 pull.once('</h3></form>'),
790 type == 'Branch' && renderRepoLatest(repo, rev),
791 pull.once('</section><section>'),
792 renderRepoTree(repo, rev, path),
793 pull.once('</section>'),
794 renderRepoReadme(repo, rev, path)
795 ]))
796 }
797
798 /* Repo activity */
799
800 function serveRepoActivity(repo, branch) {
801 return renderRepoPage(repo, branch, cat([
802 pull.once('<h3>Activity</h3>'),
803 pull(
804 ssb.links({
805 type: 'git-update',
806 dest: repo.id,
807 source: repo.feed,
808 rel: 'repo',
809 values: true,
810 reverse: true,
811 limit: 8
812 }),
813 pull.map(renderRepoUpdate.bind(this, repo))
814 )
815 ]))
816 }
817
818 function renderRepoUpdate(repo, msg, full) {
819 var c = msg.value.content
820
821 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
822 return {name: ref, value: c.refs[ref]}
823 }) : []
824 var numObjects = c.objects ? Object.keys(c.objects).length : 0
825
826 return '<section class="collapse">' +
827 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
828 '<br>' +
829 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
830 refs.map(function (update) {
831 var name = escapeHTML(update.name)
832 if (!update.value) {
833 return 'Deleted ' + name
834 } else {
835 var commitLink = link([repo.id, 'commit', update.value])
836 return name + ' &rarr; ' + commitLink
837 }
838 }).join('<br>') +
839 '</section>'
840 }
841
842 /* Repo commits */
843
844 function serveRepoCommits(repo, branch) {
845 return renderRepoPage(repo, branch, cat([
846 pull.once('<h3>Commits</h3>'),
847 pull(
848 repo.readLog(branch),
849 pull.asyncMap(function (hash, cb) {
850 repo.getCommitParsed(hash, function (err, commit) {
851 if (err) return cb(err)
852 var commitPath = [repo.id, 'commit', commit.id]
853 var treePath = [repo.id, 'tree', commit.id]
854 cb(null, '<section class="collapse">' +
855 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
856 '<code>' + commit.id + '</code> ' +
857 link(treePath, 'Tree') + '<br>' +
858 (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '<br>' : '') +
859 escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() +
860 '</section>')
861 })
862 })
863 )
864 ]))
865 }
866
867 /* Repo tree */
868
869 function revMenu(repo, currentName) {
870 var currentGroup
871 return cat([
872 pull.once('<select name="rev" onchange="this.form.submit()">'),
873 pull(
874 repo.refs(),
875 pull.map(function (ref) {
876 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
877 var group = m[1]
878 var name = m[2]
879
880 var optgroup = (group === currentGroup) ? '' :
881 (currentGroup ? '</optgroup>' : '') +
882 '<optgroup label="' + (refLabels[group] || group) + '">'
883 currentGroup = group
884 var selected = (name == currentName) ? ' selected="selected"' : ''
885 var htmlName = escapeHTML(name)
886 return optgroup +
887 '<option value="' + htmlName + '"' + selected + '>' +
888 htmlName + '</option>'
889 })
890 ),
891 readOnce(function (cb) {
892 cb(null, currentGroup ? '</optgroup>' : '')
893 }),
894 pull.once('</select> ' +
895 '<noscript><input type="submit" value="Go" /></noscript>')
896 ])
897 }
898
899 function renderRepoLatest(repo, rev) {
900 return readOnce(function (cb) {
901 repo.getCommitParsed(rev, function (err, commit) {
902 if (err) return cb(err)
903 var commitPath = [repo.id, 'commit', commit.id]
904 cb(null,
905 'Latest: <strong>' + link(commitPath, commit.title) +
906 '</strong><br>' +
907 '<code>' + commit.id + '</code><br> ' +
908 escapeHTML(commit.committer.name) + ' committed on ' +
909 commit.committer.date.toLocaleString() +
910 (commit.separateAuthor ? '<br>' +
911 escapeHTML(commit.author.name) + ' authored on ' +
912 commit.author.date.toLocaleString() : ''))
913 })
914 })
915 }
916
917 // breadcrumbs
918 function linkPath(basePath, path) {
919 path = path.slice()
920 var last = path.pop()
921 return path.map(function (dir, i) {
922 return link(basePath.concat(path.slice(0, i+1)), dir)
923 }).concat(last).join(' / ')
924 }
925
926 function renderRepoTree(repo, rev, path) {
927 var pathLinks = path.length === 0 ? '' :
928 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
929 return cat([
930 pull.once('<h3>Files' + pathLinks + '</h3>'),
931 pull(
932 repo.readDir(rev, path),
933 pull.map(function (file) {
934 var type = (file.mode === 040000) ? 'tree' :
935 (file.mode === 0160000) ? 'commit' : 'blob'
936 if (type == 'commit')
937 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
938 var filePath = [repo.id, type, rev].concat(path, file.name)
939 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
940 link(filePath, file.name)]
941 }),
942 table('class="files"')
943 )
944 ])
945 }
946
947 /* Repo readme */
948
949 function renderRepoReadme(repo, branch, path) {
950 return readNext(function (cb) {
951 pull(
952 repo.readDir(branch, path),
953 pull.filter(function (file) {
954 return /readme(\.|$)/i.test(file.name)
955 }),
956 pull.take(1),
957 pull.collect(function (err, files) {
958 if (err) return cb(null, pull.empty())
959 var file = files[0]
960 if (!file)
961 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
962 repo.getObjectFromAny(file.id, function (err, obj) {
963 if (err) return cb(err)
964 cb(null, cat([
965 pull.once('<section><h4>' + escapeHTML(file.name) + '</h4><hr/>'),
966 markdownFilenameRegex.test(file.name) ?
967 readOnce(function (cb) {
968 pull(obj.read, pull.collect(function (err, bufs) {
969 if (err) return cb(err)
970 var buf = Buffer.concat(bufs, obj.length)
971 cb(null, markdown(buf.toString()))
972 }))
973 })
974 : cat([
975 pull.once('<pre>'),
976 pull(obj.read, escapeHTMLStream()),
977 pull.once('</pre>')
978 ]),
979 pull.once('</section>')
980 ]))
981 })
982 })
983 )
984 })
985 }
986
987 /* Repo commit */
988
989 function serveRepoCommit(repo, rev) {
990 return renderRepoPage(repo, rev, cat([
991 pull.once('<h3>Commit ' + rev + '</h3>'),
992 readOnce(function (cb) {
993 repo.getCommitParsed(rev, function (err, commit) {
994 if (err) return cb(err)
995 var commitPath = [repo.id, 'commit', commit.id]
996 var treePath = [repo.id, 'tree', commit.tree]
997 cb(null,
998 '<p><strong>' + link(commitPath, commit.title) +
999 '</strong></p>' +
1000 pre(commit.body) +
1001 '<p>' +
1002 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1003 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1004 : '') +
1005 escapeHTML(commit.committer.name) + ' committed on ' +
1006 commit.committer.date.toLocaleString() + '</p>' +
1007 '<p>' + commit.parents.map(function (id) {
1008 return 'Parent: ' + link([repo.id, 'commit', id], id)
1009 }).join('<br>') + '</p>' +
1010 '<p>' +
1011 (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') +
1012 '</p>')
1013 })
1014 })
1015 ]))
1016 }
1017
1018 /* An unknown message linking to a repo */
1019
1020 function serveRepoSomething(req, repo, id, msg, path) {
1021 return renderRepoPage(repo, null,
1022 pull.once('<section><h3>' + link([id]) + '</h3>' +
1023 json(msg) + '</section>'))
1024 }
1025
1026 /* Repo update */
1027
1028 function objsArr(objs) {
1029 return Array.isArray(objs) ? objs :
1030 Object.keys(objs).map(function (sha1) {
1031 var obj = Object.create(objs[sha1])
1032 obj.sha1 = sha1
1033 return obj
1034 })
1035 }
1036
1037 function serveRepoUpdate(req, repo, id, msg, path) {
1038 var raw = req._u.query.raw != null
1039
1040 // convert packs to old single-object style
1041 if (msg.content.indexes) {
1042 for (var i = 0; i < msg.content.indexes.length; i++) {
1043 msg.content.packs[i] = {
1044 pack: {link: msg.content.packs[i].link},
1045 idx: msg.content.indexes[i]
1046 }
1047 }
1048 }
1049
1050 return renderRepoPage(repo, null, pull.once(
1051 (raw ? '<a href="?" class="raw-link header-align">Info</a>' :
1052 '<a href="?raw" class="raw-link header-align">Data</a>') +
1053 '<h3>Update</h3>' +
1054 (raw ? '<section class="collapse">' + json(msg) + '</section>' :
1055 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1056 (msg.content.objects ? '<h3>Objects</h3>' +
1057 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1058 (msg.content.packs ? '<h3>Packs</h3>' +
1059 msg.content.packs.map(renderPack).join('\n') : ''))))
1060 }
1061
1062 function renderObject(obj) {
1063 return '<section class="collapse">' +
1064 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1065 obj.length + ' bytes' +
1066 '</section>'
1067 }
1068
1069 function renderPack(info) {
1070 return '<section class="collapse">' +
1071 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1072 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1073 }
1074
1075 /* Blob */
1076
1077 function serveRepoBlob(repo, rev, path) {
1078 return readNext(function (cb) {
1079 repo.getFile(rev, path, function (err, object) {
1080 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1081 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1082 var pathLinks = path.length === 0 ? '' :
1083 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1084 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1085 var filename = path[path.length-1]
1086 var extension = filename.split('.').pop()
1087 cb(null, renderRepoPage(repo, rev, cat([
1088 pull.once('<section><form action="" method="get">' +
1089 '<h3>' + type + ': ' + rev + ' '),
1090 revMenu(repo, rev),
1091 pull.once('</h3></form>'),
1092 type == 'Branch' && renderRepoLatest(repo, rev),
1093 pull.once('</section><section class="collapse">' +
1094 '<h3>Files' + pathLinks + '</h3>' +
1095 '<div>' + object.length + ' bytes' +
1096 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1097 '</div></section>' +
1098 '<section>'),
1099 extension in imgMimes
1100 ? pull.once('<img src="' + escapeHTML(flattenPath(rawFilePath)) +
1101 '" alt="' + escapeHTML(filename) + '" />')
1102 : markdownFilenameRegex.test(filename)
1103 ? pull(object.read, escapeHTMLStream(), pull.map(markdown))
1104 : pull(object.read, escapeHTMLStream(), wrap('pre')),
1105 pull.once('</section>')
1106 ])))
1107 })
1108 })
1109 }
1110
1111 function serveBlobNotFound(repoId, err) {
1112 return serveTemplate(400, 'Blob not found', pull.values([
1113 '<h2>Blob not found</h2>',
1114 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1115 '<pre>' + escapeHTML(err.stack) + '</pre>'
1116 ]))
1117 }
1118
1119 /* Raw blob */
1120
1121 function serveRepoRaw(repo, branch, path) {
1122 return readNext(function (cb) {
1123 repo.getFile(branch, path, function (err, object) {
1124 if (err) return cb(null, servePlainError(404, 'Blob not found'))
1125 var extension = path[path.length-1].split('.').pop()
1126 var contentType = imgMimes[extension]
1127 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1128 })
1129 })
1130 }
1131
1132 function serveRaw(length, contentType) {
1133 var inBody
1134 var headers = {
1135 'Content-Type': contentType || 'text/plain; charset=utf-8',
1136 'Cache-Control': 'max-age=31536000'
1137 }
1138 if (length != null)
1139 headers['Content-Length'] = length
1140 return function (read) {
1141 return function (end, cb) {
1142 if (inBody) return read(end, cb)
1143 if (end) return cb(true)
1144 cb(null, [200, headers])
1145 inBody = true
1146 }
1147 }
1148 }
1149
1150 function serveBlob(req, key) {
1151 return readNext(function (cb) {
1152 ssb.blobs.want(key, function (err, got) {
1153 if (err) cb(null, serveError(err))
1154 else if (!got) cb(null, serve404(req))
1155 else cb(null, serveRaw()(ssb.blobs.get(key)))
1156 })
1157 })
1158 }
1159
1160 /* Digs */
1161
1162 function serveRepoDigs(repo) {
1163 return readNext(function (cb) {
1164 getVotes(repo.id, function (err, votes) {
1165 cb(null, renderRepoPage(repo, '', cat([
1166 pull.once('<section><h3>Digs</h3>' +
1167 '<div>Total: ' + votes.upvotes + '</div>'),
1168 pull(
1169 pull.values(Object.keys(votes.upvoters)),
1170 pull.asyncMap(function (feedId, cb) {
1171 about.getName(feedId, function (err, name) {
1172 if (err) return cb(err)
1173 cb(null, link([feedId], name))
1174 })
1175 }),
1176 ul()
1177 ),
1178 pull.once('</section>')
1179 ])))
1180 })
1181 })
1182 }
1183
1184 /* Issues */
1185
1186 function serveRepoIssues(repo, issueId, path) {
1187 var numIssues = 0
1188 return renderRepoPage(repo, '', cat([
1189 pull.once(
1190 (isPublic ? '' :
1191 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1192 '<button class="btn">&plus; New Issue</button>', true) +
1193 '</div>') +
1194 '<h3>Issues</h3>'),
1195 pull(
1196 issues.createFeedStream({ project: repo.id }),
1197 pull.map(function (issue) {
1198 numIssues++
1199 return '<section class="collapse">' +
1200 '<a href="' + encodeLink(issue.id) + '">' +
1201 escapeHTML(issue.title) +
1202 '<span class="issue-info">' +
1203 new Date(issue.created_at).toLocaleString() +
1204 '</span>' +
1205 '</a>' +
1206 '</section>'
1207 })
1208 ),
1209 readOnce(function (cb) {
1210 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1211 })
1212 ]))
1213 }
1214
1215 /* New Issue */
1216
1217 function serveRepoNewIssue(repo, issueId, path) {
1218 return renderRepoPage(repo, '', pull.once(
1219 '<h3>New Issue</h3>' +
1220 '<section><form action="" method="post">' +
1221 '<input type="hidden" name="action" value="new-issue">' +
1222 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1223 renderPostForm('Description', 8) +
1224 '<button type="submit" class="btn">Create</button>' +
1225 '</form></section>'))
1226 }
1227
1228 /* Issue */
1229
1230 function serveRepoIssue(req, repo, issue, path) {
1231 var isAuthor = (myId == issue.author) || (myId == repo.feed)
1232 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
1233 return renderRepoPage(repo, null, cat([
1234 pull.once(
1235 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
1236 'Rename the issue',
1237 '<h3>' + issue.title + '</h3>') +
1238 '<code>' + issue.id + '</code>' +
1239 '<section class="collapse">' +
1240 (issue.open
1241 ? '<strong class="issue-status open">Open</strong>'
1242 : '<strong class="issue-status closed">Closed</strong>')),
1243 readOnce(function (cb) {
1244 about.getName(issue.author, function (err, authorName) {
1245 if (err) return cb(err)
1246 var authorLink = link([issue.author], authorName)
1247 cb(null,
1248 authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
1249 '<hr/>' +
1250 markdown(issue.text) +
1251 '</section>')
1252 })
1253 }),
1254 // render posts and edits
1255 pull(
1256 ssb.links({
1257 dest: issue.id,
1258 values: true
1259 }),
1260 pull.unique('key'),
1261 addAuthorName(about),
1262 pull.map(function (msg) {
1263 var authorLink = link([msg.value.author], msg.authorName)
1264 var msgTimeLink = link([msg.key],
1265 new Date(msg.value.timestamp).toLocaleString())
1266 var c = msg.value.content
1267 if (msg.value.timestamp > newestMsg.value.timestamp)
1268 newestMsg = msg
1269 switch (c.type) {
1270 case 'post':
1271 if (c.root == issue.id) {
1272 var changed = issues.isStatusChanged(msg, issue)
1273 return '<section class="collapse">' +
1274 authorLink +
1275 (changed == null ? '' : ' ' + (
1276 changed ? 'reopened this issue' : 'closed this issue')) +
1277 ' &middot; ' + msgTimeLink +
1278 markdown(c.text) +
1279 '</section>'
1280 } else {
1281 var text = c.text || (c.type + ' ' + msg.key)
1282 return '<section class="collapse mention-preview">' +
1283 authorLink + ' mentioned this issue in ' +
1284 link([msg.key], String(text).substr(0, 140)) +
1285 '</section>'
1286 }
1287 case 'issue-edit':
1288 return '<section class="collapse">' +
1289 (c.title == null ? '' :
1290 authorLink + ' renamed this issue to <q>' +
1291 escapeHTML(c.title) + '</q>') +
1292 ' &middot; ' + msgTimeLink +
1293 '</section>'
1294 default:
1295 return '<section class="collapse">' +
1296 authorLink +
1297 ' &middot; ' + msgTimeLink +
1298 json(c) +
1299 '</section>'
1300 }
1301 })
1302 ),
1303 isPublic ? pull.empty() : readOnce(renderCommentForm)
1304 ]))
1305
1306 function renderCommentForm(cb) {
1307 cb(null, '<section><form action="" method="post">' +
1308 '<input type="hidden" name="action" value="comment">' +
1309 '<input type="hidden" name="id" value="' + issue.id + '">' +
1310 '<input type="hidden" name="branch" value="' + newestMsg.key + '">' +
1311 renderPostForm() +
1312 '<input type="submit" class="btn open" value="Comment" />' +
1313 (isAuthor ?
1314 '<input type="submit" class="btn"' +
1315 ' name="' + (issue.open ? 'close' : 'open') + '"' +
1316 ' value="' + (issue.open ? 'Close issue' : 'Reopen issue') + '"' +
1317 '/>' : '') +
1318 '</form></section>')
1319 }
1320 }
1321
1322}
1323

Built with git-ssb-web