Files: 88eec92c154cb3894b4a853ba05d69069267687a / index.js
17222 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 | var msgTypes = { |
77 | 'git-repo': true, |
78 | 'git-update': true |
79 | } |
80 | |
81 | var refLabels = { |
82 | heads: 'Branches' |
83 | } |
84 | |
85 | module.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 + ' → ' + 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