Files: b3c91942323e21996377ef311a6f5004be3f95c9 / index.js
16937 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 | return pull( |
191 | ssb.links({ |
192 | reverse: true, |
193 | rel: 'repo', |
194 | source: feedId, |
195 | values: true |
196 | }), |
197 | pull.take(20), |
198 | pull.asyncMap(renderUpdate) |
199 | ) |
200 | } |
201 | |
202 | /* |
203 | function renderRepoCreated(msg, cb) { |
204 | var repoLink = link([msg.key]) |
205 | var authorLink = link([msg.value.author]) |
206 | cb(null, '<p>' + timestamp(msg.value.timestamp) + '<br>' + |
207 | authorLink + ' created repo ' + repoLink + '</p>') |
208 | } |
209 | */ |
210 | |
211 | function renderUpdate(msg, cb) { |
212 | about.getName(msg.value.author, function (err, name) { |
213 | if (err) return cb(err) |
214 | var repoLink = link([msg.value.content.repo]) |
215 | var authorLink = link([msg.value.author], name) |
216 | cb(null, '<p>' + timestamp(msg.value.timestamp) + '<br>' + |
217 | authorLink + ' pushed to ' + repoLink + '</p>') |
218 | }) |
219 | } |
220 | |
221 | /* Index */ |
222 | |
223 | function serveIndex() { |
224 | return cat([ |
225 | pull.values([ |
226 | [200, { |
227 | 'Content-Type': 'text/html' |
228 | }], |
229 | '<!doctype html><html><head><meta charset=utf-8>', |
230 | '<title>git ssb</title></head><body>', |
231 | '<h1><a href="/">git ssb</a></h1>' |
232 | ]), |
233 | renderTry(renderFeed()), |
234 | pull.once('</body></html>') |
235 | ]) |
236 | } |
237 | |
238 | function serveUserPage(feedId) { |
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 | readOnce(function (cb) { |
249 | about.getName(feedId, function (err, name) { |
250 | cb(null, '<h2>' + link([feedId], name) + '</h2>' + |
251 | '<p><small><code>' + feedId + '</code></small></p>') |
252 | }) |
253 | }), |
254 | renderFeed(feedId), |
255 | pull.once('</body></html>') |
256 | ]) |
257 | } |
258 | |
259 | /* Repo */ |
260 | |
261 | function serveRepoPage(id, path) { |
262 | var defaultBranch = 'master' |
263 | return readNext(function (cb) { |
264 | ssbGit.getRepo(ssb, id, function (err, repo) { |
265 | if (err) { |
266 | if (0) |
267 | cb(null, serveRepoNotFound(id, err)) |
268 | else |
269 | cb(null, serveError(id, err)) |
270 | return |
271 | } |
272 | repo = Repo(repo) |
273 | cb(null, (function () { |
274 | var branch = path[1] || defaultBranch |
275 | var filePath = path.slice(2) |
276 | switch (path[0]) { |
277 | case undefined: |
278 | return serveRepoTree(repo, branch, []) |
279 | case 'activity': |
280 | return serveRepoActivity(repo, branch) |
281 | case 'commits': |
282 | return serveRepoCommits(repo, branch) |
283 | case 'commit': |
284 | return serveRepoCommit(repo, path[1]) |
285 | case 'tree': |
286 | return serveRepoTree(repo, branch, filePath) |
287 | case 'blob': |
288 | return serveBlob(repo, branch, filePath) |
289 | default: |
290 | return serve404(req) |
291 | } |
292 | })()) |
293 | }) |
294 | }) |
295 | } |
296 | |
297 | function serveRepoNotFound(id, err) { |
298 | return pull.values([ |
299 | [404, { |
300 | 'Content-Type': 'text/html' |
301 | }], |
302 | '<!doctype html><html><head><meta charset=utf-8>', |
303 | '<title>Repo not found</title></head><body>', |
304 | '<h1><a href="/">git ssb</a></h1>', |
305 | '<h2>Repo not found</h2>', |
306 | '<p>Repo ' + id + ' was not found</p>', |
307 | '<pre>' + escapeHTML(err.stack) + '</pre>', |
308 | '</body></html>' |
309 | ]) |
310 | } |
311 | |
312 | function renderRepoPage(repo, branch, body) { |
313 | var gitUrl = 'ssb://' + repo.id |
314 | var gitLink = '<code>' + gitUrl + '</code>' |
315 | |
316 | return cat([ |
317 | pull.values([ |
318 | [200, { |
319 | 'Content-Type': 'text/html' |
320 | }], |
321 | '<!doctype html><html><head><meta charset=utf-8>' + |
322 | '<title>git ssb</title></head><body>' + |
323 | '<h1><a href="/">git ssb</a></h1>' + |
324 | '<h2>' + link([repo.id]) + '</h2>' + |
325 | '<p>git URL: ' + gitLink + '</p>']), |
326 | readOnce(function (cb) { |
327 | about.getName(repo.feed, function (err, name) { |
328 | cb(null, '<p>Author: ' + link([repo.feed], name) + '</p>') |
329 | }) |
330 | }), |
331 | pull.once( |
332 | '<p>' + link([repo.id], 'Code') + ' ' + |
333 | link([repo.id, 'activity'], 'Activity') + ' ' + |
334 | link([repo.id, 'commits', branch], 'Commits') + '</p>' + |
335 | '<hr/>' |
336 | ), |
337 | renderTry(body), |
338 | pull.once('<hr/></body></html>') |
339 | ]) |
340 | } |
341 | |
342 | function serveRepoTree(repo, rev, path) { |
343 | var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch' |
344 | return renderRepoPage(repo, rev, cat([ |
345 | pull.once('<h3>' + type + ': ' + rev + ' '), |
346 | revMenu(repo, rev), |
347 | pull.once('</h3>'), |
348 | type == 'Branch' && renderRepoLatest(repo, rev), |
349 | renderRepoTree(repo, rev, path), |
350 | renderRepoReadme(repo, rev, path), |
351 | ])) |
352 | } |
353 | |
354 | /* Repo activity */ |
355 | |
356 | function serveRepoActivity(repo, branch) { |
357 | return renderRepoPage(repo, branch, cat([ |
358 | pull.once('<h3>Activity</h3>'), |
359 | pull( |
360 | ssb.links({ |
361 | type: 'git-update', |
362 | dest: repo.id, |
363 | source: repo.feed, |
364 | rel: 'repo', |
365 | values: true, |
366 | reverse: true, |
367 | limit: 8 |
368 | }), |
369 | pull.map(renderRepoUpdate) |
370 | ) |
371 | ])) |
372 | |
373 | function renderRepoUpdate(msg) { |
374 | var c = msg.value.content |
375 | |
376 | var refs = c.refs ? Object.keys(c.refs).map(function (ref) { |
377 | return {name: ref, value: c.refs[ref]} |
378 | }) : [] |
379 | var numObjects = c.objects ? Object.keys(c.objects).length : 0 |
380 | |
381 | return '<p>' + timestamp(msg.value.timestamp) + '<br>' + |
382 | (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') + |
383 | refs.map(function (update) { |
384 | var name = escapeHTML(update.name) |
385 | if (!update.value) { |
386 | return 'Deleted ' + name |
387 | } else { |
388 | var commitLink = link([repo.id, 'commit', update.value]) |
389 | return name + ' → ' + commitLink |
390 | } |
391 | }).join('<br>') + |
392 | '</p>' |
393 | } |
394 | } |
395 | |
396 | /* Repo commits */ |
397 | |
398 | function serveRepoCommits(repo, branch) { |
399 | return renderRepoPage(repo, branch, cat([ |
400 | pull.once('<h3>Commits</h3><ul>'), |
401 | pull( |
402 | repo.readLog(branch), |
403 | pull.asyncMap(function (hash, cb) { |
404 | repo.getCommitParsed(hash, function (err, commit) { |
405 | if (err) return cb(err) |
406 | var commitPath = [repo.id, 'commit', commit.id] |
407 | var treePath = [repo.id, 'tree', commit.id] |
408 | cb(null, '<li>' + |
409 | '<strong>' + link(commitPath, escapeHTML(commit.title)) + '</strong><br>' + |
410 | '<code>' + commit.id + '</code> ' + |
411 | link(treePath, 'Tree') + '<br>' + |
412 | (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '<br>' : '') + |
413 | escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() + |
414 | '</li>') |
415 | }) |
416 | }) |
417 | ), |
418 | pull.once('</ul>') |
419 | ])) |
420 | } |
421 | |
422 | /* Repo tree */ |
423 | |
424 | function revMenu(repo, currentName) { |
425 | var baseHref = '/' + encodeURIComponent(repo.id) + '/tree/' |
426 | var onchange = 'location.href="' + baseHref + '" + this.value' |
427 | var currentGroup |
428 | return cat([ |
429 | pull.once('<select onchange="' + escapeHTML(onchange) + '">'), |
430 | pull( |
431 | repo.refs(), |
432 | pull.map(function (ref) { |
433 | var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name] |
434 | var group = m[1] |
435 | var name = m[2] |
436 | |
437 | var optgroup = (group === currentGroup) ? '' : |
438 | (currentGroup ? '</optgroup>' : '') + |
439 | '<optgroup label="' + (refLabels[group] || group) + '">' |
440 | currentGroup = group |
441 | var selected = (name == currentName) ? ' selected="selected"' : '' |
442 | var htmlName = escapeHTML(name) |
443 | return optgroup + |
444 | '<option value="' + htmlName + '"' + selected + '>' + |
445 | htmlName + '</option>' |
446 | }) |
447 | ), |
448 | readOnce(function (cb) { |
449 | cb(null, currentGroup ? '</optgroup>' : '') |
450 | }), |
451 | pull.once('</select>') |
452 | ]) |
453 | } |
454 | |
455 | function renderRepoLatest(repo, rev) { |
456 | return readOnce(function (cb) { |
457 | repo.getCommitParsed(rev, function (err, commit) { |
458 | if (err) return cb(err) |
459 | var commitPath = [repo.id, 'commit', commit.id] |
460 | cb(null, '<p>' + |
461 | 'Latest: <strong>' + link(commitPath, escapeHTML(commit.title)) + |
462 | '</strong><br>' + |
463 | '<code>' + commit.id + '</code><br> ' + |
464 | escapeHTML(commit.committer.name) + ' committed on ' + |
465 | commit.committer.date.toLocaleString() + |
466 | (commit.separateAuthor ? '<br>' + |
467 | escapeHTML(commit.author.name) + ' authored on ' + |
468 | commit.author.date.toLocaleString() : '') + |
469 | '</p>') |
470 | }) |
471 | }) |
472 | } |
473 | |
474 | // breadcrumbs |
475 | function linkPath(basePath, path) { |
476 | path = path.slice() |
477 | var last = path.pop() |
478 | return path.map(function (dir, i) { |
479 | return link(basePath.concat(path.slice(0, i+1)), dir) |
480 | }).concat(last).join(' / ') |
481 | } |
482 | |
483 | function renderRepoTree(repo, rev, path) { |
484 | var pathLinks = linkPath([repo.id, 'tree'], [rev].concat(path)) |
485 | return cat([ |
486 | pull.once('<h3>Files: ' + pathLinks + '</h3><ul>'), |
487 | pull( |
488 | repo.readDir(rev, path), |
489 | pull.map(function (file) { |
490 | var type = (file.mode === 040000) ? 'tree' : 'blob' |
491 | var filePath = [repo.id, type, rev].concat(path, file.name) |
492 | return '<li>' + link(filePath) + '</li>' |
493 | }) |
494 | ), |
495 | pull.once('</ul>') |
496 | ]) |
497 | } |
498 | |
499 | /* Repo readme */ |
500 | |
501 | function renderRepoReadme(repo, branch, path) { |
502 | return readNext(function (cb) { |
503 | pull( |
504 | repo.readDir(branch, path), |
505 | pull.filter(function (file) { |
506 | return /readme(\.|$)/i.test(file.name) |
507 | }), |
508 | pull.take(1), |
509 | pull.collect(function (err, files) { |
510 | if (err) return cb(null, pull.empty()) |
511 | var file = files[0] |
512 | if (!file) |
513 | return cb(null, pull.once(path.length ? '' : '<p>No readme</p>')) |
514 | repo.getObject(file.id, function (err, obj) { |
515 | if (err) return cb(null, pull.empty()) |
516 | cb(null, cat([ |
517 | pull.once('<h4>' + escapeHTML(file.name) + '</h4>' + |
518 | '<blockquote><pre>'), |
519 | pull( |
520 | obj.read, |
521 | escapeHTMLStream() |
522 | ), |
523 | pull.once('</pre></blockquote>') |
524 | ])) |
525 | }) |
526 | }) |
527 | ) |
528 | }) |
529 | } |
530 | |
531 | /* Repo commit */ |
532 | |
533 | function serveRepoCommit(repo, rev) { |
534 | return renderRepoPage(repo, rev, cat([ |
535 | pull.once('<h3>Commit ' + rev + '</h3>'), |
536 | readOnce(function (cb) { |
537 | repo.getCommitParsed(rev, function (err, commit) { |
538 | if (err) return cb(err) |
539 | var commitPath = [repo.id, 'commit', commit.id] |
540 | var treePath = [repo.id, 'tree', commit.tree] |
541 | cb(null, |
542 | '<p><strong>' + link(commitPath, escapeHTML(commit.title)) + |
543 | '</strong></p>' + |
544 | pre(commit.body) + |
545 | '<p>' + |
546 | (commit.separateAuthor ? escapeHTML(commit.author.name) + |
547 | ' authored on ' + commit.author.date.toLocaleString() + '<br>' |
548 | : '') + |
549 | escapeHTML(commit.committer.name) + ' committed on ' + |
550 | commit.committer.date.toLocaleString() + '</p>' + |
551 | '<p>' + commit.parents.map(function (id) { |
552 | return 'Parent: ' + link([repo.id, 'commit', id], id) |
553 | }).join('<br>') + '</p>' + |
554 | '<p>' + |
555 | (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') + |
556 | '</p>') |
557 | }) |
558 | }) |
559 | ])) |
560 | } |
561 | |
562 | /* Blob */ |
563 | |
564 | function serveBlob(repo, branch, path) { |
565 | return readNext(function (cb) { |
566 | repo.getFile(branch, path, function (err, object) { |
567 | if (err) return cb(null, serveBlobNotFound(repoId, err)) |
568 | cb(null, serveObjectRaw(object)) |
569 | }) |
570 | }) |
571 | } |
572 | |
573 | function serveBlobNotFound(repoId, err) { |
574 | return pull.values([ |
575 | [404, { |
576 | 'Content-Type': 'text/html' |
577 | }], |
578 | '<!doctype html><html><head><meta charset=utf-8>', |
579 | '<title>Blob not found</title></head><body>', |
580 | '<h1><a href="/">git ssb</a></h1>', |
581 | '<h2>Blob not found</h2>', |
582 | '<p>Blob in repo ' + link([repoId]) + ' was not found</p>', |
583 | '<pre>' + escapeHTML(err.stack) + '</pre>', |
584 | '</body></html>' |
585 | ]) |
586 | } |
587 | |
588 | function serveObjectRaw(object) { |
589 | return cat([ |
590 | pull.once([200, { |
591 | 'Content-Length': object.length, |
592 | 'Content-Type': 'text/plain' |
593 | }]), |
594 | object.read |
595 | ]) |
596 | } |
597 | |
598 | } |
599 |
Built with git-ssb-web