git ssb

30+

cel / git-ssb-web



Tree: 88eec92c154cb3894b4a853ba05d69069267687a

Files: 88eec92c154cb3894b4a853ba05d69069267687a / index.js

17222 bytesRaw
1var http = require('http')
2var url = require('url')
3var ref = require('ssb-ref')
4var pull = require('pull-stream')
5var ssbGit = require('ssb-git-repo')
6var toPull = require('stream-to-pull-stream')
7var cat = require('pull-cat')
8var Repo = require('pull-git-repo')
9var ssbAbout = require('./about')
10
11function parseAddr(str, def) {
12 if (!str) return def
13 var i = str.lastIndexOf(':')
14 if (~i) return {host: str.substr(0, i), port: str.substr(i+1)}
15 if (isNaN(str)) return {host: str, port: def.port}
16 return {host: def.host, port: str}
17}
18
19function link(parts, html) {
20 var href = '/' + parts.map(encodeURIComponent).join('/')
21 var innerHTML = html || escapeHTML(parts[parts.length-1])
22 return '<a href="' + href + '">' + innerHTML + '</a>'
23}
24
25function timestamp(time) {
26 time = Number(time)
27 var d = new Date(time)
28 return '<span title="' + time + '">' + d.toLocaleString() + '</span>'
29}
30
31function pre(text) {
32 return '<pre>' + escapeHTML(text) + '</pre>'
33}
34
35function json(obj) {
36 return pre(JSON.stringify(obj, null, 2))
37}
38
39function escapeHTML(str) {
40 return String(str)
41 .replace(/&/g, '&amp;')
42 .replace(/</g, '&lt;')
43 .replace(/>/g, '&gt;')
44 .replace(/"/g, '&quot;')
45}
46
47function escapeHTMLStream() {
48 return pull.map(function (buf) {
49 return escapeHTML(buf.toString('utf8'))
50 })
51}
52
53function readNext(fn) {
54 var next
55 return function (end, cb) {
56 if (next) return next(end, cb)
57 fn(function (err, _next) {
58 if (err) return cb(err)
59 next = _next
60 next(null, cb)
61 })
62 }
63}
64
65function readOnce(fn) {
66 var ended
67 return function (end, cb) {
68 fn(function (err, data) {
69 if (err || ended) return cb(err || ended)
70 ended = true
71 cb(null, data)
72 })
73 }
74}
75
76var msgTypes = {
77 'git-repo': true,
78 'git-update': true
79}
80
81var refLabels = {
82 heads: 'Branches'
83}
84
85module.exports = function (listenAddr, cb) {
86 var ssb, reconnect, myId
87 var about = function (id, cb) { cb(null, {name: id}) }
88
89 var addr = parseAddr(listenAddr, {host: 'localhost', port: 7718})
90 http.createServer(onRequest).listen(addr.port, addr.host, onListening)
91
92 var server = {
93 setSSB: function (_ssb, _reconnect) {
94 ssb = _ssb
95 reconnect = _reconnect
96 ssb.whoami(function (err, feed) {
97 myId = feed.id
98 about = ssbAbout(ssb, ssb.id)
99 })
100 }
101 }
102
103 function onListening() {
104 var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host
105 console.error('Listening on http://' + host + ':' + addr.port + '/')
106 cb(null, server)
107 }
108
109 /* Serving a request */
110
111 function onRequest(req, res) {
112 console.log(req.method, req.url)
113 pull(
114 handleRequest(req),
115 pull.filter(function (data) {
116 if (Array.isArray(data)) {
117 res.writeHead.apply(res, data)
118 return false
119 }
120 return true
121 }),
122 toPull(res)
123 )
124 }
125
126 function handleRequest(req) {
127 var u = url.parse(req.url)
128 var dirs = u.pathname.slice(1).split(/\/+/).map(decodeURIComponent)
129 switch (dirs[0]) {
130 case '':
131 return serveIndex(req)
132 default:
133 if (ref.isMsgId(dirs[0]))
134 return serveRepoPage(dirs[0], dirs.slice(1))
135 else if (ref.isFeedId(dirs[0]))
136 return serveUserPage(dirs[0])
137 else
138 return serve404(req)
139 }
140 }
141
142 function serve404(req) {
143 var body = '404 Not Found'
144 return pull.values([
145 [404, {
146 'Content-Length': body.length,
147 'Content-Type': 'text/plain'
148 }],
149 body
150 ])
151 }
152
153 function renderTry(read) {
154 var ended
155 return function (end, cb) {
156 if (ended) return cb(ended)
157 read(end, function (err, data) {
158 if (err === true)
159 cb(true)
160 else if (err) {
161 ended = true
162 cb(null,
163 '<h3>' + err.name + '</h3>' +
164 '<pre>' + escapeHTML(err.stack) + '</pre>')
165 } else
166 cb(null, data)
167 })
168 }
169 }
170
171 function serveError(id, err) {
172 if (err.message == 'stream is closed')
173 reconnect()
174 return pull.values([
175 [500, {
176 'Content-Type': 'text/html'
177 }],
178 '<!doctype html><html><head><meta charset=utf-8>',
179 '<title>' + err.name + '</title></head><body>',
180 '<h1><a href="/">git ssb</a></h1>',
181 '<h2>' + err.name + '</h3>' +
182 '<pre>' + escapeHTML(err.stack) + '</pre>' +
183 '</body></html>'
184 ])
185 }
186
187 /* Feed */
188
189 function renderFeed(feedId) {
190 var opts = {
191 reverse: true,
192 id: feedId,
193 limit: 100,
194 }
195 return pull(
196 feedId ? ssb.createUserStream(opts) : ssb.createLogStream(opts),
197 pull.filter(function (msg) {
198 return msg.value.content.type in msgTypes
199 }),
200 pull.asyncMap(function (msg, cb) {
201 switch (msg.value.content.type) {
202 case 'git-repo': return renderRepoCreated(msg, cb)
203 case 'git-update': return renderUpdate(msg, cb)
204 }
205 })
206 )
207 }
208
209 function renderRepoCreated(msg, cb) {
210 var repoLink = link([msg.key])
211 var authorLink = link([msg.value.author])
212 cb(null, '<p>' + timestamp(msg.value.timestamp) + '<br>' +
213 authorLink + ' created repo ' + repoLink + '</p>')
214 }
215
216 function renderUpdate(msg, cb) {
217 about.getName(msg.value.author, function (err, name) {
218 if (err) return cb(err)
219 var repoLink = link([msg.value.content.repo])
220 var authorLink = link([msg.value.author], name)
221 cb(null, '<p>' + timestamp(msg.value.timestamp) + '<br>' +
222 authorLink + ' pushed to ' + repoLink + '</p>')
223 })
224 }
225
226 /* Index */
227
228 function serveIndex() {
229 return cat([
230 pull.values([
231 [200, {
232 'Content-Type': 'text/html'
233 }],
234 '<!doctype html><html><head><meta charset=utf-8>',
235 '<title>git ssb</title></head><body>',
236 '<h1><a href="/">git ssb</a></h1>'
237 ]),
238 renderTry(renderFeed()),
239 pull.once('</body></html>')
240 ])
241 }
242
243 function serveUserPage(feedId) {
244 return cat([
245 pull.values([
246 [200, {
247 'Content-Type': 'text/html'
248 }],
249 '<!doctype html><html><head><meta charset=utf-8>',
250 '<title>git ssb</title></head><body>',
251 '<h1><a href="/">git ssb</a></h1>',
252 ]),
253 readOnce(function (cb) {
254 about.getName(feedId, function (err, name) {
255 cb(null, '<h2>' + link([feedId], name) + '</h2>' +
256 '<p><small><code>' + feedId + '</code></small></p>')
257 })
258 }),
259 renderFeed(feedId),
260 pull.once('</body></html>')
261 ])
262 }
263
264 /* Repo */
265
266 function serveRepoPage(id, path) {
267 var defaultBranch = 'master'
268 return readNext(function (cb) {
269 ssbGit.getRepo(ssb, id, function (err, repo) {
270 if (err) {
271 if (0)
272 cb(null, serveRepoNotFound(id, err))
273 else
274 cb(null, serveError(id, err))
275 return
276 }
277 repo = Repo(repo)
278 cb(null, (function () {
279 var branch = path[1] || defaultBranch
280 var filePath = path.slice(2)
281 switch (path[0]) {
282 case undefined:
283 return serveRepoTree(repo, branch, [])
284 case 'activity':
285 return serveRepoActivity(repo, branch)
286 case 'commits':
287 return serveRepoCommits(repo, branch)
288 case 'commit':
289 return serveRepoCommit(repo, path[1])
290 case 'tree':
291 return serveRepoTree(repo, branch, filePath)
292 case 'blob':
293 return serveBlob(repo, branch, filePath)
294 default:
295 return serve404(req)
296 }
297 })())
298 })
299 })
300 }
301
302 function serveRepoNotFound(id, err) {
303 return pull.values([
304 [404, {
305 'Content-Type': 'text/html'
306 }],
307 '<!doctype html><html><head><meta charset=utf-8>',
308 '<title>Repo not found</title></head><body>',
309 '<h1><a href="/">git ssb</a></h1>',
310 '<h2>Repo not found</h2>',
311 '<p>Repo ' + id + ' was not found</p>',
312 '<pre>' + escapeHTML(err.stack) + '</pre>',
313 '</body></html>'
314 ])
315 }
316
317 function renderRepoPage(repo, branch, body) {
318 var gitUrl = 'ssb://' + repo.id
319 var gitLink = '<code>' + gitUrl + '</code>'
320
321 return cat([
322 pull.values([
323 [200, {
324 'Content-Type': 'text/html'
325 }],
326 '<!doctype html><html><head><meta charset=utf-8>' +
327 '<title>git ssb</title></head><body>' +
328 '<h1><a href="/">git ssb</a></h1>' +
329 '<h2>' + link([repo.id]) + '</h2>' +
330 '<p>git URL: ' + gitLink + '</p>']),
331 readOnce(function (cb) {
332 about.getName(repo.feed, function (err, name) {
333 cb(null, '<p>Author: ' + link([repo.feed], name) + '</p>')
334 })
335 }),
336 pull.once(
337 '<p>' + link([repo.id], 'Code') + ' ' +
338 link([repo.id, 'activity'], 'Activity') + ' ' +
339 link([repo.id, 'commits', branch], 'Commits') + '</p>' +
340 '<hr/>'
341 ),
342 renderTry(body),
343 pull.once('<hr/></body></html>')
344 ])
345 }
346
347 function serveRepoTree(repo, rev, path) {
348 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
349 return renderRepoPage(repo, rev, cat([
350 pull.once('<h3>' + type + ': ' + rev + ' '),
351 revMenu(repo, rev),
352 pull.once('</h3>'),
353 type == 'Branch' && renderRepoLatest(repo, rev),
354 renderRepoTree(repo, rev, path),
355 renderRepoReadme(repo, rev, path),
356 ]))
357 }
358
359 /* Repo activity */
360
361 function serveRepoActivity(repo, branch) {
362 return renderRepoPage(repo, branch, cat([
363 pull.once('<h3>Activity</h3>'),
364 pull(
365 ssb.links({
366 type: 'git-update',
367 dest: repo.id,
368 source: repo.feed,
369 rel: 'repo',
370 values: true,
371 reverse: true,
372 limit: 8
373 }),
374 pull.map(renderRepoUpdate)
375 )
376 ]))
377
378 function renderRepoUpdate(msg) {
379 var c = msg.value.content
380
381 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
382 return {name: ref, value: c.refs[ref]}
383 }) : []
384 var numObjects = c.objects ? Object.keys(c.objects).length : 0
385
386 return '<p>' + timestamp(msg.value.timestamp) + '<br>' +
387 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
388 refs.map(function (update) {
389 var name = escapeHTML(update.name)
390 if (!update.value) {
391 return 'Deleted ' + name
392 } else {
393 var commitLink = link([repo.id, 'commit', update.value])
394 return name + ' &rarr; ' + commitLink
395 }
396 }).join('<br>') +
397 '</p>'
398 }
399 }
400
401 /* Repo commits */
402
403 function serveRepoCommits(repo, branch) {
404 return renderRepoPage(repo, branch, cat([
405 pull.once('<h3>Commits</h3><ul>'),
406 pull(
407 repo.readLog(branch),
408 pull.asyncMap(function (hash, cb) {
409 repo.getCommitParsed(hash, function (err, commit) {
410 if (err) return cb(err)
411 var commitPath = [repo.id, 'commit', commit.id]
412 var treePath = [repo.id, 'tree', commit.id]
413 cb(null, '<li>' +
414 '<strong>' + link(commitPath, escapeHTML(commit.title)) + '</strong><br>' +
415 '<code>' + commit.id + '</code> ' +
416 link(treePath, 'Tree') + '<br>' +
417 (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '<br>' : '') +
418 escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() +
419 '</li>')
420 })
421 })
422 ),
423 pull.once('</ul>')
424 ]))
425 }
426
427 /* Repo tree */
428
429 function revMenu(repo, currentName) {
430 var baseHref = '/' + encodeURIComponent(repo.id) + '/tree/'
431 var onchange = 'location.href="' + baseHref + '" + this.value'
432 var currentGroup
433 return cat([
434 pull.once('<select onchange="' + escapeHTML(onchange) + '">'),
435 pull(
436 repo.refs(),
437 pull.map(function (ref) {
438 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
439 var group = m[1]
440 var name = m[2]
441
442 var optgroup = (group === currentGroup) ? '' :
443 (currentGroup ? '</optgroup>' : '') +
444 '<optgroup label="' + (refLabels[group] || group) + '">'
445 currentGroup = group
446 var selected = (name == currentName) ? ' selected="selected"' : ''
447 var htmlName = escapeHTML(name)
448 return optgroup +
449 '<option value="' + htmlName + '"' + selected + '>' +
450 htmlName + '</option>'
451 })
452 ),
453 readOnce(function (cb) {
454 cb(null, currentGroup ? '</optgroup>' : '')
455 }),
456 pull.once('</select>')
457 ])
458 }
459
460 function renderRepoLatest(repo, rev) {
461 return readOnce(function (cb) {
462 repo.getCommitParsed(rev, function (err, commit) {
463 if (err) return cb(err)
464 var commitPath = [repo.id, 'commit', commit.id]
465 cb(null, '<p>' +
466 'Latest: <strong>' + link(commitPath, escapeHTML(commit.title)) +
467 '</strong><br>' +
468 '<code>' + commit.id + '</code><br> ' +
469 escapeHTML(commit.committer.name) + ' committed on ' +
470 commit.committer.date.toLocaleString() +
471 (commit.separateAuthor ? '<br>' +
472 escapeHTML(commit.author.name) + ' authored on ' +
473 commit.author.date.toLocaleString() : '') +
474 '</p>')
475 })
476 })
477 }
478
479 // breadcrumbs
480 function linkPath(basePath, path) {
481 path = path.slice()
482 var last = path.pop()
483 return path.map(function (dir, i) {
484 return link(basePath.concat(path.slice(0, i+1)), dir)
485 }).concat(last).join(' / ')
486 }
487
488 function renderRepoTree(repo, rev, path) {
489 var pathLinks = linkPath([repo.id, 'tree'], [rev].concat(path))
490 return cat([
491 pull.once('<h3>Files: ' + pathLinks + '</h3><ul>'),
492 pull(
493 repo.readDir(rev, path),
494 pull.map(function (file) {
495 var type = (file.mode === 040000) ? 'tree' : 'blob'
496 var filePath = [repo.id, type, rev].concat(path, file.name)
497 return '<li>' + link(filePath) + '</li>'
498 })
499 ),
500 pull.once('</ul>')
501 ])
502 }
503
504 /* Repo readme */
505
506 function renderRepoReadme(repo, branch, path) {
507 return readNext(function (cb) {
508 pull(
509 repo.readDir(branch, path),
510 pull.filter(function (file) {
511 return /readme(\.|$)/i.test(file.name)
512 }),
513 pull.take(1),
514 pull.collect(function (err, files) {
515 if (err) return cb(null, pull.empty())
516 var file = files[0]
517 if (!file)
518 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
519 repo.getObject(file.id, function (err, obj) {
520 if (err) return cb(null, pull.empty())
521 cb(null, cat([
522 pull.once('<h4>' + escapeHTML(file.name) + '</h4>' +
523 '<blockquote><pre>'),
524 pull(
525 obj.read,
526 escapeHTMLStream()
527 ),
528 pull.once('</pre></blockquote>')
529 ]))
530 })
531 })
532 )
533 })
534 }
535
536 /* Repo commit */
537
538 function serveRepoCommit(repo, rev) {
539 return renderRepoPage(repo, rev, cat([
540 pull.once('<h3>Commit ' + rev + '</h3>'),
541 readOnce(function (cb) {
542 repo.getCommitParsed(rev, function (err, commit) {
543 if (err) return cb(err)
544 var commitPath = [repo.id, 'commit', commit.id]
545 var treePath = [repo.id, 'tree', commit.tree]
546 cb(null,
547 '<p><strong>' + link(commitPath, escapeHTML(commit.title)) +
548 '</strong></p>' +
549 pre(commit.body) +
550 '<p>' +
551 (commit.separateAuthor ? escapeHTML(commit.author.name) +
552 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
553 : '') +
554 escapeHTML(commit.committer.name) + ' committed on ' +
555 commit.committer.date.toLocaleString() + '</p>' +
556 '<p>' + commit.parents.map(function (id) {
557 return 'Parent: ' + link([repo.id, 'commit', id], id)
558 }).join('<br>') + '</p>' +
559 '<p>' +
560 (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') +
561 '</p>')
562 })
563 })
564 ]))
565 }
566
567 /* Blob */
568
569 function serveBlob(repo, branch, path) {
570 return readNext(function (cb) {
571 repo.getFile(branch, path, function (err, object) {
572 if (err) return cb(null, serveBlobNotFound(repoId, err))
573 cb(null, serveObjectRaw(object))
574 })
575 })
576 }
577
578 function serveBlobNotFound(repoId, err) {
579 return pull.values([
580 [404, {
581 'Content-Type': 'text/html'
582 }],
583 '<!doctype html><html><head><meta charset=utf-8>',
584 '<title>Blob not found</title></head><body>',
585 '<h1><a href="/">git ssb</a></h1>',
586 '<h2>Blob not found</h2>',
587 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
588 '<pre>' + escapeHTML(err.stack) + '</pre>',
589 '</body></html>'
590 ])
591 }
592
593 function serveObjectRaw(object) {
594 return cat([
595 pull.once([200, {
596 'Content-Length': object.length,
597 'Content-Type': 'text/plain'
598 }]),
599 object.read
600 ])
601 }
602
603}
604

Built with git-ssb-web