git ssb

9+

cel / ssb-viewer



Tree: 07dd6934bb87b5461859bed91c1e7761f254db75

Files: 07dd6934bb87b5461859bed91c1e7761f254db75 / index.js

13006 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) {
116 var feedId = req.url.substring(req.url.lastIndexOf('user-feed/')+10, 100)
117 console.log("serving user feed: " + feedId)
118
119 var following = []
120 var channelSubscriptions = []
121
122 getAbout(feedId, function (err, about) {
123 pull(
124 sbot.createUserStream({ id: feedId }),
125 pull.filter((msg) => {
126 return !msg.value ||
127 msg.value.content.type == 'contact' ||
128 (msg.value.content.type == 'channel' &&
129 typeof msg.value.content.subscribed != 'undefined')
130 }),
131 pull.collect(function (err, msgs) {
132 msgs.forEach((msg) => {
133 if (msg.value.content.type == 'contact')
134 {
135 if (msg.value.content.following)
136 following[msg.value.content.contact] = 1
137 else
138 delete following[msg.value.content.contact]
139 }
140 else // channel subscription
141 {
142 if (msg.value.content.subscribed)
143 channelSubscriptions[msg.value.content.channel] = 1
144 else
145 delete channelSubscriptions[msg.value.content.channel]
146 }
147 })
148
149 serveFeeds(req, res, following, channelSubscriptions, feedId,
150 'user feed ' + (about ? about.name : ""))
151 })
152 )
153 })
154 }
155
156 function serveFeeds(req, res, following, channelSubscriptions, feedId, name) {
157 var feedOpts = Object.assign({}, defaultOpts, {
158 renderPrivate: false,
159 renderSubscribe: false,
160 renderAbout: false
161 })
162
163 pull(
164 sbot.createLogStream({ reverse: true, limit: 5000 }),
165 pull.filter((msg) => {
166 return !msg.value ||
167 (msg.value.author in following ||
168 msg.value.content.channel in channelSubscriptions)
169 }),
170 pull.take(150),
171 pull.collect(function (err, logs) {
172 if (err) return respond(res, 500, err.stack || err)
173 res.writeHead(200, {
174 'Content-Type': ctype("html")
175 })
176 pull(
177 pull.values(logs),
178 paramap(addAuthorAbout, 8),
179 paramap(addFollowAbout, 8),
180 paramap(addVoteMessage, 8),
181 paramap(addGitLinks, 8),
182 pull(renderThread(feedOpts), wrapPage(name)),
183 toPull(res, function (err) {
184 if (err) console.error('[viewer]', err)
185 })
186 )
187 })
188 )
189 }
190
191 exports.serveChannel = function(req, res) {
192 var channelId = req.url.substring(req.url.lastIndexOf('channel/')+8, 100)
193 console.log("serving channel: " + channelId)
194
195 var showAll = req.url.endsWith("?showAll")
196
197 pull(
198 sbot.query.read({ limit: showAll ? 300 : 10, reverse: true,
199 query: [{$filter: { value: { content: { channel: channelId }}}}]}),
200 pull.collect(function (err, logs) {
201 if (err) return respond(res, 500, err.stack || err)
202 res.writeHead(200, {
203 'Content-Type': ctype("html")
204 })
205 pull(
206 pull.values(logs),
207 paramap(addAuthorAbout, 8),
208 paramap(addVoteMessage, 8),
209 pull(renderThread(defaultOpts, '', renderShowAll(req.url)),
210 wrapPage('#' + channelId)),
211 toPull(res, function (err) {
212 if (err) console.error('[viewer]', err)
213 })
214 )
215 })
216 )
217 }
218
219 exports.serveId = function(req, res, id, ext, query) {
220 var q = query ? qs.parse(query) : {}
221 var includeRoot = !('noroot' in q)
222 var base = q.base || conf.base
223 var baseToken
224 if (!base) {
225 if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_'
226 else base = '/'
227 }
228 var opts = {
229 base: base,
230 base_token: baseToken,
231 msg_base: q.msg_base || conf.msg_base || base,
232 feed_base: q.feed_base || conf.feed_base || base,
233 blob_base: q.blob_base || conf.blob_base || base,
234 img_base: q.img_base || conf.img_base || base,
235 emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'),
236 }
237 opts.marked = {
238 gfm: true,
239 mentions: true,
240 tables: true,
241 breaks: true,
242 pedantic: false,
243 sanitize: true,
244 smartLists: true,
245 smartypants: false,
246 emoji: renderEmoji,
247 renderer: new MdRenderer(opts)
248 }
249
250 var format = formatMsgs(id, ext, opts)
251 if (format === null) return respond(res, 415, 'Invalid format')
252
253 pull(
254 sbot.links({dest: id, values: true }),
255 includeRoot && prepend(getMsg, id),
256 pull.unique('key'),
257 pull.collect(function (err, links) {
258 if (err) return respond(res, 500, err.stack || err)
259 var etag = hash(sort.heads(links).concat(appHash, ext, qs))
260 if (req.headers['if-none-match'] === etag) return respond(res, 304)
261 res.writeHead(200, {
262 'Content-Type': ctype(ext),
263 'etag': etag
264 })
265 pull(
266 pull.values(sort(links)),
267 paramap(addAuthorAbout, 8),
268 format,
269 toPull(res, function (err) {
270 if (err) console.error('[viewer]', err)
271 })
272 )
273 })
274 )
275 }
276
277 function addFollowAbout(msg, cb) {
278 if (msg.value.content.contact)
279 getAbout(msg.value.content.contact, function (err, about) {
280 if (err) return cb(err)
281 msg.value.content.contactAbout = about
282 cb(null, msg)
283 })
284 else
285 cb(null, msg)
286 }
287
288 function addVoteMessage(msg, cb) {
289 if (msg.value.content.type == 'vote' && msg.value.content.vote.link[0] == '%')
290 getMsg(msg.value.content.vote.link, function (err, linkedMsg) {
291 if (linkedMsg)
292 msg.value.content.vote.linkedText = linkedMsg.value.content.text
293 cb(null, msg)
294 })
295 else
296 cb(null, msg)
297 }
298
299 function addAuthorAbout(msg, cb) {
300 getAbout(msg.value.author, function (err, about) {
301 if (err) return cb(err)
302 msg.author = about
303 cb(null, msg)
304 })
305 }
306
307 function addGitLinks(msg, cb) {
308 if (msg.value.content.type == 'git-update')
309 getMsg(msg.value.content.repo, function (err, gitRepo) {
310 if (gitRepo)
311 msg.value.content.repoName = gitRepo.value.content.name
312 cb(null, msg)
313 })
314 else if (msg.value.content.type == 'issue')
315 getMsg(msg.value.content.project, function (err, gitRepo) {
316 if (gitRepo)
317 msg.value.content.repoName = gitRepo.value.content.name
318 cb(null, msg)
319 })
320 else
321 cb(null, msg)
322 }
323}
324
325exports.serveHttp = function (sbot, config) {
326 var conf = config.viewer || {}
327 var port = conf.port || 8807
328 var host = conf.host || config.host || '::'
329
330 var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
331
332 http.createServer(serve).listen(port, host, function () {
333 console.log('[viewer] Listening on http://' + host + ':' + port)
334 })
335
336 function serve(req, res) {
337 if (req.method !== 'GET' && req.method !== 'HEAD') {
338 return respond(res, 405, 'Method must be GET or HEAD')
339 }
340
341 var m = urlIdRegex.exec(req.url)
342
343 if (req.url.startsWith('/user-feed/')) return exports.serveUserFeed(req, res)
344 else if (req.url.startsWith('/channel/')) return exports.serveChannel(req, res)
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