git ssb

9+

cel / ssb-viewer



Tree: 1a47f5f10e7f528233fc2adfe33565b40ecb322f

Files: 1a47f5f10e7f528233fc2adfe33565b40ecb322f / index.js

17243 bytesRaw
1var fs = require('fs')
2var http = require('http')
3var qs = require('querystring')
4var path = require('path')
5var crypto = require('crypto')
6var pull = require('pull-stream')
7var paramap = require('pull-paramap')
8var sort = require('ssb-sort')
9var toPull = require('stream-to-pull-stream')
10var memo = require('asyncmemo')
11var lru = require('lrucache')
12var webresolve = require('ssb-web-resolver')
13var serveEmoji = require('emoji-server')()
14var refs = require('ssb-ref')
15var h = require('hyperscript')
16var {
17 MdRenderer,
18 renderEmoji,
19 formatMsgs,
20 wrapPage,
21 renderThread,
22 renderAbout,
23 renderShowAll,
24 renderRssItem,
25 wrapRss,
26} = require('./render');
27
28var appHash = hash([fs.readFileSync(__filename)])
29
30var urlIdRegex = /^(?:\/(([%&@]|%25|%26|%40)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3[Dd])\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
31
32function hash(arr) {
33 return arr.reduce(function (hash, item) {
34 return hash.update(String(item))
35 }, crypto.createHash('sha256')).digest('base64')
36}
37
38exports.name = 'viewer'
39exports.manifest = {}
40exports.version = require('./package').version
41
42exports.init = function (sbot, config) {
43 var conf = config.viewer || {}
44 var port = conf.port || 8807
45 var host = conf.host || config.host || '::'
46
47 var base = conf.base || '/'
48 var defaultOpts = {
49 base: base,
50 msg_base: conf.msg_base || base,
51 feed_base: conf.feed_base || base,
52 blob_base: conf.blob_base || base,
53 img_base: conf.img_base || base,
54 emoji_base: conf.emoji_base || (base + 'emoji/'),
55 requireOptIn: conf.require_opt_in == null ? true : conf.require_opt_in,
56 }
57
58 defaultOpts.marked = {
59 gfm: true,
60 mentions: true,
61 tables: true,
62 breaks: true,
63 pedantic: false,
64 sanitize: true,
65 smartLists: true,
66 smartypants: false,
67 emoji: renderEmoji,
68 renderer: new MdRenderer(defaultOpts)
69 }
70
71 var getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot)
72 var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
73 var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
74
75 http.createServer(serve).listen(port, host, function () {
76 console.log('[viewer] Listening on http://' + host + ':' + port)
77 })
78
79 function serve(req, res) {
80 if (req.method !== 'GET' && req.method !== 'HEAD') {
81 return respond(res, 405, 'Method must be GET or HEAD')
82 }
83
84 var m = urlIdRegex.exec(req.url)
85
86 if (m[4] === '/robots.txt') return serveRobots(req, res, conf)
87 if (req.url.startsWith('/static/')) return serveStatic(req, res, m[4])
88 if (req.url.startsWith('/emoji/')) return serveEmoji(req, res, m[4])
89 if (req.url.startsWith('/user-feed/')) return serveUserFeed(req, res, m[4])
90 else if (req.url.startsWith('/channel/')) return serveChannel(req, res, m[4])
91 else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
92 else if (req.url.startsWith('/web/')) return serveWeb(req, res, m[4])
93
94 if (m[2] && m[2].length === 3) {
95 m[1] = decodeURIComponent(m[1])
96 m[2] = m[1][0]
97 }
98 switch (m[2]) {
99 case '%': return serveId(req, res, m[1], m[3], m[5])
100 case '@': return serveFeed(req, res, m[1], m[3], m[5])
101 case '&': return serveBlob(req, res, sbot, m[1])
102 }
103
104 if (m[4] === '/') return serveHome(req, res, m[5])
105 return respond(res, 404, 'Not found')
106 }
107
108 function serveFeed(req, res, feedId, ext) {
109 console.log("serving feed: " + feedId)
110
111 var showAll = req.url.endsWith("?showAll");
112
113 getAbout(feedId, function (err, about) {
114 if (err) return respond(res, 500, err.stack || err)
115
116 function render() {
117 switch (ext) {
118 case 'rss':
119 return pull(
120 // formatMsgs(feedId, ext, defaultOpts)
121 renderRssItem(defaultOpts), wrapRss(about.name, defaultOpts)
122 );
123 default:
124 var publicWebHosting = about.publicWebHosting == null
125 ? !defaultOpts.requireOptIn : about.publicWebHosting
126 var name = publicWebHosting ? about.name : feedId.substr(0, 10) + '…'
127 return pull(
128 renderAbout(defaultOpts, about,
129 renderShowAll(showAll, req.url)), wrapPage(name)
130 );
131 }
132 }
133
134 pull(
135 sbot.createUserStream({ id: feedId, reverse: true, limit: showAll ? -1 : (ext == 'rss' ? 25 : 10) }),
136 pull.filter(function (data) {
137 return 'object' === typeof data.value.content
138 }),
139 pull.collect(function (err, logs) {
140 if (err) return respond(res, 500, err.stack || err)
141 res.writeHead(200, {
142 'Content-Type': ctype(ext)
143 })
144 pull(
145 pull.values(logs),
146 paramap(addAuthorAbout, 8),
147 paramap(addBlog, 8),
148 paramap(addFollowAbout, 8),
149 paramap(addVoteMessage, 8),
150 paramap(addGitLinks, 8),
151 paramap(addGatheringAbout, 8),
152 render(),
153 toPull(res, function (err) {
154 if (err) console.error('[viewer]', err)
155 })
156 )
157 })
158 )
159 })
160 }
161
162 function serveWeb (req, res, url) {
163 var self = this
164 var id = decodeURIComponent(url.substr(1))
165
166 var components = url.split('/')
167 if (components[0] === '') components.shift()
168 if (components[0] === 'web') components.shift()
169 components[0] = decodeURIComponent(components[0])
170
171 webresolve(sbot, components, function (err, data) {
172 if (err) {
173 return respond(res, 404, 'ERROR: ' + err)
174 }
175 return pull(
176 pull.once(data),
177 toPull(res, function (err) {
178 if (err) console.error('[viewer]', err)
179 })
180 )
181 })
182 }
183
184 function serveUserFeed(req, res, url) {
185 var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
186 console.log("serving user feed: " + feedId)
187
188 var following = []
189 var channelSubscriptions = []
190
191 getAbout(feedId, function (err, about) {
192 pull(
193 sbot.createUserStream({ id: feedId }),
194 pull.filter((msg) => {
195 return !msg.value ||
196 msg.value.content.type == 'contact' ||
197 (msg.value.content.type == 'channel' &&
198 typeof msg.value.content.subscribed != 'undefined')
199 }),
200 pull.collect(function (err, msgs) {
201 msgs.forEach((msg) => {
202 if (msg.value.content.type == 'contact')
203 {
204 if (msg.value.content.following)
205 following[msg.value.content.contact] = 1
206 else
207 delete following[msg.value.content.contact]
208 }
209 else // channel subscription
210 {
211 if (msg.value.content.subscribed)
212 channelSubscriptions[msg.value.content.channel] = 1
213 else
214 delete channelSubscriptions[msg.value.content.channel]
215 }
216 })
217
218 serveFeeds(req, res, following, channelSubscriptions, feedId,
219 'user feed ' + (about ? about.name : ""))
220 })
221 )
222 })
223 }
224
225 function serveFeeds(req, res, following, channelSubscriptions, feedId, name) {
226 var feedOpts = Object.assign({}, defaultOpts, {
227 renderPrivate: false,
228 renderSubscribe: false,
229 renderVote: false,
230 renderTalenet: false,
231 renderChess: false,
232 renderFollow: false,
233 renderPub: false,
234 renderAbout: false
235 })
236
237 pull(
238 sbot.createLogStream({ reverse: true, limit: 5000 }),
239 pull.filter((msg) => {
240 return !msg.value ||
241 (msg.value.author in following ||
242 msg.value.content.channel in channelSubscriptions)
243 }),
244 pull.take(150),
245 pull.collect(function (err, logs) {
246 if (err) return respond(res, 500, err.stack || err)
247 res.writeHead(200, {
248 'Content-Type': ctype("html")
249 })
250 pull(
251 pull.values(logs),
252 paramap(addAuthorAbout, 8),
253 paramap(addBlog, 8),
254 paramap(addFollowAbout, 8),
255 paramap(addVoteMessage, 8),
256 paramap(addGitLinks, 8),
257 paramap(addGatheringAbout, 8),
258 pull(renderThread(feedOpts), wrapPage(name)),
259 toPull(res, function (err) {
260 if (err) console.error('[viewer]', err)
261 })
262 )
263 })
264 )
265 }
266
267 function serveChannel(req, res, url) {
268 var channelId = url.substring(url.lastIndexOf('channel/')+8, 100)
269 console.log("serving channel: " + channelId)
270
271 var showAll = req.url.endsWith("?showAll")
272
273 pull(
274 sbot.query.read({ limit: showAll ? 300 : 10, reverse: true, query: [{$filter: { value: { content: { channel: channelId }}}}]}),
275 pull.collect(function (err, logs) {
276 if (err) return respond(res, 500, err.stack || err)
277 res.writeHead(200, {
278 'Content-Type': ctype("html")
279 })
280 pull(
281 pull.values(logs),
282 paramap(addAuthorAbout, 8),
283 paramap(addBlog, 8),
284 paramap(addVoteMessage, 8),
285 paramap(addGatheringAbout, 8),
286 pull(renderThread(defaultOpts, '', renderShowAll(showAll, req.url)),
287 wrapPage('#' + channelId)),
288 toPull(res, function (err) {
289 if (err) console.error('[viewer]', err)
290 })
291 )
292 })
293 )
294 }
295
296 function serveId(req, res, id, ext, query) {
297 var q = query ? qs.parse(query) : {}
298 var includeRoot = !('noroot' in q)
299 var base = q.base || conf.base
300 var baseToken
301 if (!base) {
302 if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_'
303 else base = '/'
304 }
305 var opts = {
306 base: base,
307 base_token: baseToken,
308 msg_base: q.msg_base || conf.msg_base || base,
309 feed_base: q.feed_base || conf.feed_base || base,
310 blob_base: q.blob_base || conf.blob_base || base,
311 img_base: q.img_base || conf.img_base || base,
312 emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'),
313 requireOptIn: defaultOpts.requireOptIn,
314 }
315 opts.marked = {
316 gfm: true,
317 mentions: true,
318 tables: true,
319 breaks: true,
320 pedantic: false,
321 sanitize: true,
322 smartLists: true,
323 smartypants: false,
324 emoji: renderEmoji,
325 renderer: new MdRenderer(opts)
326 }
327
328 var format = formatMsgs(id, ext, opts)
329 if (format === null) return respond(res, 415, 'Invalid format')
330
331 function render (links) {
332 var etag = hash(sort.heads(links).concat(appHash, ext, qs))
333 if (req.headers['if-none-match'] === etag) return respond(res, 304)
334 res.writeHead(200, {
335 'Content-Type': ctype(ext),
336 'etag': etag
337 })
338 pull(
339 pull.values(sort(links)),
340 paramap(addAuthorAbout, 8),
341 paramap(addBlog, 8),
342 paramap(addGatheringAbout, 8),
343 format,
344 toPull(res, function (err) {
345 if (err) console.error('[viewer]', err)
346 })
347 )
348 }
349
350 getMsgWithValue(sbot, id, function (err, root) {
351 if (err) return respond(res, 500, err.stack || err)
352 if('string' === typeof root.value.content)
353 return render([root])
354
355 pull(
356 sbot.links({dest: id, values: true, rel: 'root' }),
357 pull.unique('key'),
358 pull.collect(function (err, links) {
359 if (err) return respond(res, 500, err.stack || err)
360 if(includeRoot)
361 links.unshift(root)
362 render(links)
363 })
364 )
365 })
366 }
367
368 function addFollowAbout(msg, cb) {
369 if (msg.value.content.contact)
370 getAbout(msg.value.content.contact, function (err, about) {
371 if (err) return cb(err)
372 msg.value.content.contactAbout = about
373 cb(null, msg)
374 })
375 else
376 cb(null, msg)
377 }
378
379 function addVoteMessage(msg, cb) {
380 if (msg.value.content.type == 'vote' && msg.value.content.vote && msg.value.content.vote.link[0] == '%')
381 getMsg(msg.value.content.vote.link, function (err, linkedMsg) {
382 if (linkedMsg)
383 msg.value.content.vote.linkedText = linkedMsg.value.content.text
384 cb(null, msg)
385 })
386 else
387 cb(null, msg)
388 }
389
390 function addBlog(msg, cb) {
391 if (msg.value && msg.value.content.type == "blog") {
392 pull(
393 sbot.blobs.get(msg.value.content.blog),
394 pull.collect(function(err, blob) {
395 msg.value.content.blogContent = blob
396 cb(null, msg)
397 })
398 )
399 } else
400 cb(null, msg)
401 }
402
403 function addAuthorAbout(msg, cb) {
404 getAbout(msg.value.author, function (err, about) {
405 if (err) return cb(err)
406 msg.author = about
407 cb(null, msg)
408 })
409 }
410
411 function addGatheringAbout(msg, cb) {
412 if (msg.value && msg.value.content.type === 'gathering') {
413 getAbout(msg.key, (err, about) => {
414 if (err) { cb(err) }
415
416 msg.value.content.about = about
417
418 pull(
419 sbot.backlinks.read({
420 query: [{ $filter: {
421 dest: msg.key,
422 value: { content: { type: 'about' }},
423 }}],
424 index: 'DTA'
425 }),
426 // Only grab messages about attendance
427 pull.filter(o => o.value.content.attendee !== undefined),
428 // Filter "can't attend"-messages
429 pull.filter(o => !o.value.content.attendee.remove),
430 pull.unique(o => o.value.content.attendee.link),
431 pull.collect((err, arr) => {
432 if (err) { cb(err) }
433
434 msg.value.content.numberAttending = arr.length
435
436 cb(null, msg)
437 })
438 )
439 })
440 } else {
441 cb(null, msg)
442 }
443 }
444
445 function addGitLinks(msg, cb) {
446 if (msg.value.content.type == 'git-update')
447 getMsg(msg.value.content.repo, function (err, gitRepo) {
448 if (gitRepo)
449 msg.value.content.repoName = gitRepo.value.content.name
450 cb(null, msg)
451 })
452 else if (msg.value.content.type == 'issue')
453 getMsg(msg.value.content.project, function (err, gitRepo) {
454 if (gitRepo)
455 msg.value.content.repoName = gitRepo.value.content.name
456 cb(null, msg)
457 })
458 else
459 cb(null, msg)
460 }
461}
462
463function serveBlob(req, res, sbot, id) {
464 if (req.headers['if-none-match'] === id) return respond(res, 304)
465 sbot.blobs.has(id, function (err, has) {
466 if (err) {
467 if (/^invalid/.test(err.message)) return respond(res, 400, err.message)
468 else return respond(res, 500, err.message || err)
469 }
470 if (!has) return respond(res, 404, 'Not found')
471 res.writeHead(200, {
472 'Cache-Control': 'public, max-age=315360000',
473 'etag': id
474 })
475 pull(
476 sbot.blobs.get(id),
477 toPull(res, function (err) {
478 if (err) console.error('[viewer]', err)
479 })
480 )
481 })
482}
483
484function getMsgWithValue(sbot, id, cb) {
485 sbot.get(id, function (err, value) {
486 if (err) return cb(err)
487 cb(null, {key: id, value: value})
488 })
489}
490
491function respond(res, status, message) {
492 res.writeHead(status)
493 res.end(message)
494}
495
496function ctype(name) {
497 switch (name && /[^.\/]*$/.exec(name)[0] || 'html') {
498 case 'html': return 'text/html'
499 case 'js': return 'text/javascript'
500 case 'css': return 'text/css'
501 case 'json': return 'application/json'
502 case 'rss': return 'text/xml'
503 }
504}
505
506function ifModified(req, lastMod) {
507 var ifModSince = req.headers['if-modified-since']
508 if (!ifModSince) return false
509 var d = new Date(ifModSince)
510 return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
511}
512
513function serveStatic(req, res, file) {
514 serveFile(req, res, path.join(__dirname, 'static', file))
515}
516
517function serveFile(req, res, file) {
518 fs.stat(file, function (err, stat) {
519 if (err && err.code === 'ENOENT') return respond(res, 404, 'Not found')
520 if (err) return respond(res, 500, err.stack || err)
521 if (!stat.isFile()) return respond(res, 403, 'May only load files')
522 if (ifModified(req, stat.mtime)) return respond(res, 304, 'Not modified')
523 res.writeHead(200, {
524 'Content-Type': ctype(file),
525 'Content-Length': stat.size,
526 'Last-Modified': stat.mtime.toGMTString()
527 })
528 fs.createReadStream(file).pipe(res)
529 })
530}
531
532function asChannelLink(id) {
533 var channel = refs.normalizeChannel(id)
534 if (channel) return '#' + channel
535}
536
537function asLink(id) {
538 if (!id || typeof id !== 'string') return null
539 id = id.trim()
540 if (id[0] === '#') return asChannelLink(id)
541 if (refs.isLink(id)) return id
542 try {
543 id = decodeURIComponent(id)
544 } catch(e) {
545 return null
546 }
547 if (id[0] === '#') return asChannelLink(id)
548 if (refs.isLink(id)) return id
549}
550
551function serveHome(req, res, query, conf) {
552 var q = query ? qs.parse(query) : {}
553 var id = asLink(q.id)
554 if (id) {
555 res.writeHead(303, {
556 Location: '/' + (
557 id[0] === '#' ? 'channel/' + id.substr(1) :
558 refs.isMsgId(id) ? encodeURIComponent(id) : id)
559 })
560 return res.end()
561 }
562 res.writeHead(200, {
563 'Content-Type': 'text/html'
564 })
565 pull(
566 pull.once(h('form', {method: 'get', action: ''},
567 h('input', {name: 'id', placeholder: 'id', size: 60, value: q.id || ''}), ' ',
568 h('input', {type: 'submit', value: 'Go'})
569 ).outerHTML),
570 wrapPage('ssb-viewer'),
571 toPull(res, function (err) {
572 if (err) console.error('[viewer]', err)
573 })
574 )
575}
576
577function serveRobots(req, res, conf) {
578 var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
579 res.end('User-agent: *\n'
580 + (disallow ? 'Disallow: /\n' : ''))
581}
582
583function prepend(fn, arg) {
584 return function (read) {
585 return function (abort, cb) {
586 if (fn && !abort) {
587 var _fn = fn
588 fn = null
589 return _fn(arg, function (err, value) {
590 if (err) return read(err, function (err) {
591 cb(err || true)
592 })
593 cb(null, value)
594 })
595 }
596 read(abort, cb)
597 }
598 }
599}
600

Built with git-ssb-web