git ssb

30+

cel / git-ssb-web



Tree: 904d0ff95d9058585ff4f87964d27cb15aeb9f43

Files: 904d0ff95d9058585ff4f87964d27cb15aeb9f43 / index.js

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

Built with git-ssb-web