git ssb

9+

cel / ssb-viewer



Tree: 8c84e9ec8dfd1770ec8755f993ae9deea4aab61a

Files: 8c84e9ec8dfd1770ec8755f993ae9deea4aab61a / index.js

12986 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 serveEmoji = require('emoji-server')()
13var {
14 MdRenderer,
15 renderEmoji,
16 formatMsgs,
17 wrapPage,
18 renderThread,
19 renderAbout,
20 renderShowAll,
21 renderRssItem,
22 wrapRss,
23} = require('./render');
24
25var appHash = hash([fs.readFileSync(__filename)])
26
27var urlIdRegex = /^(?:\/(([%&@]|%25|%26|%40)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3[Dd])\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
28
29function hash(arr) {
30 return arr.reduce(function (hash, item) {
31 return hash.update(String(item))
32 }, crypto.createHash('sha256')).digest('base64')
33}
34
35exports.name = 'viewer'
36exports.manifest = {}
37exports.version = require('./package').version
38
39exports.init = function (sbot, config) {
40 var conf = config.viewer || {}
41
42 var getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot)
43 var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
44
45 var base = conf.base || '/'
46 var defaultOpts = {
47 base: base,
48 msg_base: conf.msg_base || base,
49 feed_base: conf.feed_base || base,
50 blob_base: conf.blob_base || base,
51 img_base: conf.img_base || base,
52 emoji_base: conf.emoji_base || (base + 'emoji/'),
53 }
54
55 defaultOpts.marked = {
56 gfm: true,
57 mentions: true,
58 tables: true,
59 breaks: true,
60 pedantic: false,
61 sanitize: true,
62 smartLists: true,
63 smartypants: false,
64 emoji: renderEmoji,
65 renderer: new MdRenderer(defaultOpts)
66 }
67
68 exports.serveFeed = function(req, res, feedId, ext) {
69 console.log("serving feed: " + feedId)
70
71 var showAll = req.url.endsWith("?showAll");
72
73 getAbout(feedId, function (err, about) {
74 if (err) return respond(res, 500, err.stack || err)
75
76 function render() {
77 switch (ext) {
78 case 'rss':
79 return pull(
80 // formatMsgs(feedId, ext, defaultOpts)
81 renderRssItem(defaultOpts), wrapRss(about.name, defaultOpts)
82 );
83 default:
84 return pull(
85 renderAbout(defaultOpts, about,
86 renderShowAll(req.url)), wrapPage(about.name)
87 );
88 }
89 }
90
91 pull(
92 sbot.createUserStream({ id: feedId, reverse: true,
93 limit: showAll ? -1 : (ext == 'rss' ? 25 : 10) }),
94 pull.collect(function (err, logs) {
95 if (err) return respond(res, 500, err.stack || err)
96 res.writeHead(200, {
97 'Content-Type': ctype(ext)
98 })
99 pull(
100 pull.values(logs),
101 paramap(addAuthorAbout, 8),
102 paramap(addFollowAbout, 8),
103 paramap(addVoteMessage, 8),
104 paramap(addGitLinks, 8),
105 render(),
106 toPull(res, function (err) {
107 if (err) console.error('[viewer]', err)
108 })
109 )
110 })
111 )
112 })
113 }
114
115 exports.serveUserFeed = function(req, res, feedId) {
116 console.log("serving user feed: " + feedId)
117
118 var following = []
119 var channelSubscriptions = []
120
121 getAbout(feedId, function (err, about) {
122 pull(
123 sbot.createUserStream({ id: feedId }),
124 pull.filter((msg) => {
125 return !msg.value ||
126 msg.value.content.type == 'contact' ||
127 (msg.value.content.type == 'channel' &&
128 typeof msg.value.content.subscribed != 'undefined')
129 }),
130 pull.collect(function (err, msgs) {
131 msgs.forEach((msg) => {
132 if (msg.value.content.type == 'contact')
133 {
134 if (msg.value.content.following)
135 following[msg.value.content.contact] = 1
136 else
137 delete following[msg.value.content.contact]
138 }
139 else // channel subscription
140 {
141 if (msg.value.content.subscribed)
142 channelSubscriptions[msg.value.content.channel] = 1
143 else
144 delete channelSubscriptions[msg.value.content.channel]
145 }
146 })
147
148 serveFeeds(req, res, following, channelSubscriptions, feedId,
149 'user feed ' + (about ? about.name : ""))
150 })
151 )
152 })
153 }
154
155 function serveFeeds(req, res, following, channelSubscriptions, feedId, name) {
156 var feedOpts = Object.assign({}, defaultOpts, {
157 renderPrivate: false,
158 renderSubscribe: false,
159 renderAbout: false
160 })
161
162 pull(
163 sbot.createLogStream({ reverse: true, limit: 5000 }),
164 pull.filter((msg) => {
165 return !msg.value ||
166 (msg.value.author in following ||
167 msg.value.content.channel in channelSubscriptions)
168 }),
169 pull.take(150),
170 pull.collect(function (err, logs) {
171 if (err) return respond(res, 500, err.stack || err)
172 res.writeHead(200, {
173 'Content-Type': ctype("html")
174 })
175 pull(
176 pull.values(logs),
177 paramap(addAuthorAbout, 8),
178 paramap(addFollowAbout, 8),
179 paramap(addVoteMessage, 8),
180 paramap(addGitLinks, 8),
181 pull(renderThread(feedOpts), wrapPage(name)),
182 toPull(res, function (err) {
183 if (err) console.error('[viewer]', err)
184 })
185 )
186 })
187 )
188 }
189
190 exports.serveChannel = function(req, res, channelId) {
191 console.log("serving channel: " + channelId)
192
193 var showAll = req.url.endsWith("?showAll")
194
195 pull(
196 sbot.query.read({ limit: showAll ? 300 : 10, reverse: true,
197 query: [{$filter: { value: { content: { channel: channelId }}}}]}),
198 pull.collect(function (err, logs) {
199 if (err) return respond(res, 500, err.stack || err)
200 res.writeHead(200, {
201 'Content-Type': ctype("html")
202 })
203 pull(
204 pull.values(logs),
205 paramap(addAuthorAbout, 8),
206 paramap(addVoteMessage, 8),
207 pull(renderThread(defaultOpts, '', renderShowAll(req.url)),
208 wrapPage('#' + channelId)),
209 toPull(res, function (err) {
210 if (err) console.error('[viewer]', err)
211 })
212 )
213 })
214 )
215 }
216
217 exports.serveId = function(req, res, id, ext, query) {
218 var q = query ? qs.parse(query) : {}
219 var includeRoot = !('noroot' in q)
220 var base = q.base || conf.base
221 var baseToken
222 if (!base) {
223 if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_'
224 else base = '/'
225 }
226 var opts = {
227 base: base,
228 base_token: baseToken,
229 msg_base: q.msg_base || conf.msg_base || base,
230 feed_base: q.feed_base || conf.feed_base || base,
231 blob_base: q.blob_base || conf.blob_base || base,
232 img_base: q.img_base || conf.img_base || base,
233 emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'),
234 }
235 opts.marked = {
236 gfm: true,
237 mentions: true,
238 tables: true,
239 breaks: true,
240 pedantic: false,
241 sanitize: true,
242 smartLists: true,
243 smartypants: false,
244 emoji: renderEmoji,
245 renderer: new MdRenderer(opts)
246 }
247
248 var format = formatMsgs(id, ext, opts)
249 if (format === null) return respond(res, 415, 'Invalid format')
250
251 pull(
252 sbot.links({dest: id, values: true }),
253 includeRoot && prepend(getMsg, id),
254 pull.unique('key'),
255 pull.collect(function (err, links) {
256 if (err) return respond(res, 500, err.stack || err)
257 var etag = hash(sort.heads(links).concat(appHash, ext, qs))
258 if (req.headers['if-none-match'] === etag) return respond(res, 304)
259 res.writeHead(200, {
260 'Content-Type': ctype(ext),
261 'etag': etag
262 })
263 pull(
264 pull.values(sort(links)),
265 paramap(addAuthorAbout, 8),
266 format,
267 toPull(res, function (err) {
268 if (err) console.error('[viewer]', err)
269 })
270 )
271 })
272 )
273 }
274
275 function addFollowAbout(msg, cb) {
276 if (msg.value.content.contact)
277 getAbout(msg.value.content.contact, function (err, about) {
278 if (err) return cb(err)
279 msg.value.content.contactAbout = about
280 cb(null, msg)
281 })
282 else
283 cb(null, msg)
284 }
285
286 function addVoteMessage(msg, cb) {
287 if (msg.value.content.type == 'vote' && msg.value.content.vote.link[0] == '%')
288 getMsg(msg.value.content.vote.link, function (err, linkedMsg) {
289 if (linkedMsg)
290 msg.value.content.vote.linkedText = linkedMsg.value.content.text
291 cb(null, msg)
292 })
293 else
294 cb(null, msg)
295 }
296
297 function addAuthorAbout(msg, cb) {
298 getAbout(msg.value.author, function (err, about) {
299 if (err) return cb(err)
300 msg.author = about
301 cb(null, msg)
302 })
303 }
304
305 function addGitLinks(msg, cb) {
306 if (msg.value.content.type == 'git-update')
307 getMsg(msg.value.content.repo, function (err, gitRepo) {
308 if (gitRepo)
309 msg.value.content.repoName = gitRepo.value.content.name
310 cb(null, msg)
311 })
312 else if (msg.value.content.type == 'issue')
313 getMsg(msg.value.content.project, function (err, gitRepo) {
314 if (gitRepo)
315 msg.value.content.repoName = gitRepo.value.content.name
316 cb(null, msg)
317 })
318 else
319 cb(null, msg)
320 }
321}
322
323exports.serveHttp = function (sbot, config) {
324 var conf = config.viewer || {}
325 var port = conf.port || 8807
326 var host = conf.host || config.host || '::'
327
328 var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
329
330 http.createServer(serve).listen(port, host, function () {
331 console.log('[viewer] Listening on http://' + host + ':' + port)
332 })
333
334 function serve(req, res) {
335 if (req.method !== 'GET' && req.method !== 'HEAD') {
336 return respond(res, 405, 'Method must be GET or HEAD')
337 }
338
339 var m = urlIdRegex.exec(req.url)
340
341 if (req.url.startsWith('/user-feed/'))
342 return exports.serveUserFeed(req, res, m[4].substring(m[4].lastIndexOf('user-feed/')+10, 100))
343 else if (req.url.startsWith('/channel/'))
344 return exports.serveChannel(req, res, m[4].substring(m[4].lastIndexOf('channel/')+8, 100))
345 else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
346
347 if (m[2] && m[2].length === 3) {
348 m[1] = decodeURIComponent(m[1])
349 m[2] = m[1][0]
350 }
351 switch (m[2]) {
352 case '%': return exports.serveId(req, res, m[1], m[3], m[5])
353 case '@': return exports.serveFeed(req, res, m[1], m[3], m[5])
354 case '&': return serveBlob(req, res, sbot, m[1])
355 default: return servePath(req, res, m[4])
356 }
357 }
358}
359
360function serveBlob(req, res, sbot, id) {
361 if (req.headers['if-none-match'] === id) return respond(res, 304)
362 sbot.blobs.has(id, function (err, has) {
363 if (err) {
364 if (/^invalid/.test(err.message)) return respond(res, 400, err.message)
365 else return respond(res, 500, err.message || err)
366 }
367 if (!has) return respond(res, 404, 'Not found')
368 res.writeHead(200, {
369 'Cache-Control': 'public, max-age=315360000',
370 'etag': id
371 })
372 pull(
373 sbot.blobs.get(id),
374 toPull(res, function (err) {
375 if (err) console.error('[viewer]', err)
376 })
377 )
378 })
379}
380
381function getMsgWithValue(sbot, id, cb) {
382 sbot.get(id, function (err, value) {
383 if (err) return cb(err)
384 cb(null, {key: id, value: value})
385 })
386}
387
388function respond(res, status, message) {
389 res.writeHead(status)
390 res.end(message)
391}
392
393function ctype(name) {
394 switch (name && /[^.\/]*$/.exec(name)[0] || 'html') {
395 case 'html': return 'text/html'
396 case 'js': return 'text/javascript'
397 case 'css': return 'text/css'
398 case 'json': return 'application/json'
399 case 'rss': return 'text/xml'
400 }
401}
402
403function servePath(req, res, url) {
404 switch (url) {
405 case '/robots.txt': return res.end('User-agent: *')
406 }
407 var m = /^(\/?[^\/]*)(\/.*)?$/.exec(url)
408 switch (m[1]) {
409 case '/static': return serveStatic(req, res, m[2])
410 case '/emoji': return serveEmoji(req, res, m[2])
411 }
412 return respond(res, 404, 'Not found')
413}
414
415function ifModified(req, lastMod) {
416 var ifModSince = req.headers['if-modified-since']
417 if (!ifModSince) return false
418 var d = new Date(ifModSince)
419 return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
420}
421
422function serveStatic(req, res, file) {
423 serveFile(req, res, path.join(__dirname, 'static', file))
424}
425
426function serveFile(req, res, file) {
427 fs.stat(file, function (err, stat) {
428 if (err && err.code === 'ENOENT') return respond(res, 404, 'Not found')
429 if (err) return respond(res, 500, err.stack || err)
430 if (!stat.isFile()) return respond(res, 403, 'May only load files')
431 if (ifModified(req, stat.mtime)) return respond(res, 304, 'Not modified')
432 res.writeHead(200, {
433 'Content-Type': ctype(file),
434 'Content-Length': stat.size,
435 'Last-Modified': stat.mtime.toGMTString()
436 })
437 fs.createReadStream(file).pipe(res)
438 })
439}
440
441function prepend(fn, arg) {
442 return function (read) {
443 return function (abort, cb) {
444 if (fn && !abort) {
445 var _fn = fn
446 fn = null
447 return _fn(arg, function (err, value) {
448 if (err) return read(err, function (err) {
449 cb(err || true)
450 })
451 cb(null, value)
452 })
453 }
454 read(abort, cb)
455 }
456 }
457}
458

Built with git-ssb-web