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