index.jsView |
---|
| 1 … | +var fs = require('fs') |
| 2 … | +var http = require('http') |
| 3 … | +var qs = require('querystring') |
| 4 … | +var path = require('path') |
| 5 … | +var crypto = require('crypto') |
| 6 … | +var cat = require('pull-cat') |
| 7 … | +var pull = require('pull-stream') |
| 8 … | +var paramap = require('pull-paramap') |
| 9 … | +var marked = require('ssb-marked') |
| 10 … | +var sort = require('ssb-sort') |
| 11 … | +var toPull = require('stream-to-pull-stream') |
| 12 … | +var memo = require('asyncmemo') |
| 13 … | +var lru = require('lrucache') |
| 14 … | +var htime = require('human-time') |
| 15 … | +var emojis = require('emoji-named-characters') |
| 16 … | +var serveEmoji = require('emoji-server')() |
| 17 … | + |
| 18 … | +var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') |
| 19 … | +var appHash = hash([fs.readFileSync(__filename)]) |
| 20 … | + |
| 21 … | +var urlIdRegex = /^(?:\/(([%&])[A-Za-z0-9\/+]{43}=\.sha256)(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/ |
| 22 … | + |
| 23 … | +function MdRenderer(opts) { |
| 24 … | + marked.Renderer.call(this, {}) |
| 25 … | + this.opts = opts |
| 26 … | +} |
| 27 … | +MdRenderer.prototype = new marked.Renderer() |
| 28 … | + |
| 29 … | +MdRenderer.prototype.urltransform = function (href) { |
| 30 … | + switch (href && href[0]) { |
| 31 … | + case '%': return this.opts.msg_base + href |
| 32 … | + case '@': return this.opts.feed_base + href |
| 33 … | + case '&': return this.opts.blob_base + href |
| 34 … | + } |
| 35 … | + return marked.Renderer.prototype.urltransform.call(this, href) |
| 36 … | +} |
| 37 … | + |
| 38 … | +MdRenderer.prototype.image = function (href, title, text) { |
| 39 … | + return '<img src="' + this.opts.img_base + escape(href) + '"' |
| 40 … | + + ' alt="' + text + '"' |
| 41 … | + + (title ? ' title="' + title + '"' : '') |
| 42 … | + + (this.options.xhtml ? '/>' : '>') |
| 43 … | +}; |
| 44 … | + |
| 45 … | +function renderEmoji(emoji) { |
| 46 … | + var opts = this.renderer.opts |
| 47 … | + return emoji in emojis ? |
| 48 … | + '<img src="' + opts.emoji_base + escape(emoji) + '.png"' |
| 49 … | + + ' alt=":' + escape(emoji) + ':"' |
| 50 … | + + ' title=":' + escape(emoji) + ':"' |
| 51 … | + + ' class="ssb-emoji" height="16" width="16">' |
| 52 … | + : ':' + emoji + ':' |
| 53 … | +} |
| 54 … | + |
| 55 … | +exports.name = 'viewer' |
| 56 … | +exports.manifest = {} |
| 57 … | +exports.version = require('./package').version |
| 58 … | + |
| 59 … | +exports.init = function (sbot, config) { |
| 60 … | + var conf = config.viewer || {} |
| 61 … | + var port = conf.port || 8807 |
| 62 … | + var host = conf.host || config.host || '::' |
| 63 … | + |
| 64 … | + var base = conf.base || '/' |
| 65 … | + var defaultOpts = { |
| 66 … | + msg_base: conf.msg_base || base, |
| 67 … | + feed_base: conf.feed_base || '#', |
| 68 … | + blob_base: conf.blob_base || base, |
| 69 … | + img_base: conf.img_base || base, |
| 70 … | + emoji_base: conf.emoji_base || (base + 'emoji/'), |
| 71 … | + } |
| 72 … | + |
| 73 … | + var getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot) |
| 74 … | + var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot) |
| 75 … | + |
| 76 … | + http.createServer(serve).listen(port, host, function () { |
| 77 … | + console.log('[viewer] Listening on http://' + host + ':' + port) |
| 78 … | + }) |
| 79 … | + |
| 80 … | + function serve(req, res) { |
| 81 … | + if (req.method !== 'GET' && req.method !== 'HEAD') { |
| 82 … | + return respond(res, 405, 'Method must be GET or HEAD') |
| 83 … | + } |
| 84 … | + var m = urlIdRegex.exec(req.url) |
| 85 … | + switch (m[2]) { |
| 86 … | + case '&': return serveBlob(req, res, sbot, m[1]) |
| 87 … | + case '%': return serveId(req, res, m[1], m[3], m[5]) |
| 88 … | + default: return servePath(req, res, m[4]) |
| 89 … | + } |
| 90 … | + } |
| 91 … | + |
| 92 … | + function serveId(req, res, id, ext, query) { |
| 93 … | + var q = query ? qs.parse(query) : {} |
| 94 … | + var includeRoot = !('noroot' in q) |
| 95 … | + var base = q.base || conf.base |
| 96 … | + var baseToken |
| 97 … | + if (!base) { |
| 98 … | + if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_' |
| 99 … | + else base = '/' |
| 100 … | + } |
| 101 … | + var opts = { |
| 102 … | + base: base, |
| 103 … | + base_token: baseToken, |
| 104 … | + msg_base: q.msg_base || conf.msg_base || base, |
| 105 … | + feed_base: q.feed_base || conf.feed_base || '#', |
| 106 … | + blob_base: q.blob_base || conf.blob_base || base, |
| 107 … | + img_base: q.img_base || conf.img_base || base, |
| 108 … | + emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'), |
| 109 … | + } |
| 110 … | + opts.marked = { |
| 111 … | + gfm: true, |
| 112 … | + mentions: true, |
| 113 … | + tables: true, |
| 114 … | + breaks: true, |
| 115 … | + pedantic: false, |
| 116 … | + sanitize: true, |
| 117 … | + smartLists: true, |
| 118 … | + smartypants: false, |
| 119 … | + emoji: renderEmoji, |
| 120 … | + renderer: new MdRenderer(opts) |
| 121 … | + } |
| 122 … | + |
| 123 … | + var format = formatMsgs(id, ext, opts) |
| 124 … | + if (format === null) return respond(res, 415, 'Invalid format') |
| 125 … | + |
| 126 … | + pull( |
| 127 … | + sbot.links({dest: id, values: true, rel: 'root'}), |
| 128 … | + includeRoot && prepend(getMsg, id), |
| 129 … | + pull.unique('key'), |
| 130 … | + pull.collect(function (err, links) { |
| 131 … | + if (err) return respond(res, 500, err.stack || err) |
| 132 … | + var etag = hash(sort.heads(links).concat(appHash, ext, qs)) |
| 133 … | + if (req.headers['if-none-match'] === etag) return respond(res, 304) |
| 134 … | + res.writeHead(200, { |
| 135 … | + 'Content-Type': ctype(ext), |
| 136 … | + 'etag': etag |
| 137 … | + }) |
| 138 … | + pull( |
| 139 … | + pull.values(sort(links)), |
| 140 … | + paramap(addAuthorAbout, 8), |
| 141 … | + format, |
| 142 … | + toPull(res, function (err) { |
| 143 … | + if (err) console.error('[viewer]', err) |
| 144 … | + }) |
| 145 … | + ) |
| 146 … | + }) |
| 147 … | + ) |
| 148 … | + } |
| 149 … | + |
| 150 … | + function addAuthorAbout(msg, cb) { |
| 151 … | + getAbout(msg.value.author, function (err, about) { |
| 152 … | + if (err) return cb(err) |
| 153 … | + msg.author = about |
| 154 … | + cb(null, msg) |
| 155 … | + }) |
| 156 … | + } |
| 157 … | +} |
| 158 … | + |
| 159 … | +function serveBlob(req, res, sbot, id) { |
| 160 … | + if (req.headers['if-none-match'] === id) return respond(res, 304) |
| 161 … | + sbot.blobs.has(id, function (err, has) { |
| 162 … | + if (err && /^invalid/.test(err.message)) return respond(400, err.message) |
| 163 … | + if (err) return respond(500, err.message || err) |
| 164 … | + if (!has) return respond(404, 'Not found') |
| 165 … | + res.writeHead(200, { |
| 166 … | + 'Cache-Control': 'public, max-age=315360000', |
| 167 … | + 'etag': id |
| 168 … | + }) |
| 169 … | + pull( |
| 170 … | + sbot.blobs.get(id), |
| 171 … | + toPull(res, function (err) { |
| 172 … | + if (err) console.error('[viewer]', err) |
| 173 … | + }) |
| 174 … | + ) |
| 175 … | + }) |
| 176 … | +} |
| 177 … | + |
| 178 … | +function getMsgWithValue(sbot, id, cb) { |
| 179 … | + sbot.get(id, function (err, value) { |
| 180 … | + if (err) return cb(err) |
| 181 … | + cb(null, {key: id, value: value}) |
| 182 … | + }) |
| 183 … | +} |
| 184 … | + |
| 185 … | +function escape(str) { |
| 186 … | + return String(str) |
| 187 … | + .replace(/&/g, '&') |
| 188 … | + .replace(/</g, '<') |
| 189 … | + .replace(/>/g, '>') |
| 190 … | + .replace(/"/g, '"') |
| 191 … | +} |
| 192 … | + |
| 193 … | +function respond(res, status, message) { |
| 194 … | + res.writeHead(status) |
| 195 … | + res.end(message) |
| 196 … | +} |
| 197 … | + |
| 198 … | +function ctype(name) { |
| 199 … | + switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { |
| 200 … | + case 'html': return 'text/html' |
| 201 … | + case 'js': return 'text/javascript' |
| 202 … | + case 'css': return 'text/css' |
| 203 … | + case 'json': return 'application/json' |
| 204 … | + } |
| 205 … | +} |
| 206 … | + |
| 207 … | +function servePath(req, res, url) { |
| 208 … | + switch (url) { |
| 209 … | + case '/robots.txt': return res.end('User-agent: *') |
| 210 … | + } |
| 211 … | + var m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) |
| 212 … | + switch (m[1]) { |
| 213 … | + case '/static': return serveStatic(req, res, m[2]) |
| 214 … | + case '/emoji': return serveEmoji(req, res, m[2]) |
| 215 … | + } |
| 216 … | + return respond(res, 404, 'Not found') |
| 217 … | +} |
| 218 … | + |
| 219 … | +function ifModified(req, lastMod) { |
| 220 … | + var ifModSince = req.headers['if-modified-since'] |
| 221 … | + if (!ifModSince) return false |
| 222 … | + var d = new Date(ifModSince) |
| 223 … | + return d && Math.floor(d/1000) >= Math.floor(lastMod/1000) |
| 224 … | +} |
| 225 … | + |
| 226 … | +function serveStatic(req, res, file) { |
| 227 … | + serveFile(req, res, path.join(__dirname, 'static', file)) |
| 228 … | +} |
| 229 … | + |
| 230 … | +function serveFile(req, res, file) { |
| 231 … | + fs.stat(file, function (err, stat) { |
| 232 … | + if (err && err.code === 'ENOENT') return respond(res, 404, 'Not found') |
| 233 … | + if (err) return respond(res, 500, err.stack || err) |
| 234 … | + if (!stat.isFile()) return respond(res, 403, 'May only load files') |
| 235 … | + if (ifModified(req, stat.mtime)) return respond(res, 304, 'Not modified') |
| 236 … | + res.writeHead(200, { |
| 237 … | + 'Content-Type': ctype(file), |
| 238 … | + 'Content-Length': stat.size, |
| 239 … | + 'Last-Modified': stat.mtime.toGMTString() |
| 240 … | + }) |
| 241 … | + fs.createReadStream(file).pipe(res) |
| 242 … | + }) |
| 243 … | +} |
| 244 … | + |
| 245 … | +function prepend(fn, arg) { |
| 246 … | + return function (read) { |
| 247 … | + return function (abort, cb) { |
| 248 … | + if (fn && !abort) { |
| 249 … | + var _fn = fn |
| 250 … | + fn = null |
| 251 … | + return _fn(arg, function (err, value) { |
| 252 … | + if (err) return read(err, function (err) { |
| 253 … | + cb(err || true) |
| 254 … | + }) |
| 255 … | + cb(null, value) |
| 256 … | + }) |
| 257 … | + } |
| 258 … | + read(abort, cb) |
| 259 … | + } |
| 260 … | + } |
| 261 … | +} |
| 262 … | + |
| 263 … | +function formatMsgs(id, ext, opts) { |
| 264 … | + switch (ext || 'html') { |
| 265 … | + case 'html': return pull(renderThread(opts), wrapPage(id)) |
| 266 … | + case 'js': return pull(renderThread(opts), wrapJSEmbed(opts)) |
| 267 … | + case 'json': return wrapJSON() |
| 268 … | + default: return null |
| 269 … | + } |
| 270 … | +} |
| 271 … | + |
| 272 … | +function wrap(before, after) { |
| 273 … | + return function (read) { |
| 274 … | + return cat([pull.once(before), read, pull.once(after)]) |
| 275 … | + } |
| 276 … | +} |
| 277 … | + |
| 278 … | +function renderThread(opts) { |
| 279 … | + return pull( |
| 280 … | + pull.map(renderMsg.bind(this, opts)), |
| 281 … | + wrap('<div class="ssb-thread">', '</div>') |
| 282 … | + ) |
| 283 … | +} |
| 284 … | + |
| 285 … | +function wrapPage(id) { |
| 286 … | + return wrap('<!doctype html><html><head>' |
| 287 … | + + '<meta charset=utf-8>' |
| 288 … | + + '<title>' + id + '</title>' |
| 289 … | + + '<meta name=viewport content="width=device-width,initial-scale=1">' |
| 290 … | + + '<link rel=stylesheet href="/static/base.css">' |
| 291 … | + + '<link rel=stylesheet href="/static/nicer.css">' |
| 292 … | + + '</head><body>', |
| 293 … | + '</body></html>' |
| 294 … | + ) |
| 295 … | +} |
| 296 … | + |
| 297 … | +function wrapJSON() { |
| 298 … | + var first = true |
| 299 … | + return pull( |
| 300 … | + pull.map(JSON.stringify), |
| 301 … | + join(','), |
| 302 … | + wrap('[', ']') |
| 303 … | + ) |
| 304 … | +} |
| 305 … | + |
| 306 … | +function wrapJSEmbed(opts) { |
| 307 … | + return pull( |
| 308 … | + wrap('<link rel=stylesheet href="' + opts.base + 'static/base.css">', ''), |
| 309 … | + pull.map(docWrite), |
| 310 … | + opts.base_token && rewriteBase(new RegExp(opts.base_token, 'g')) |
| 311 … | + ) |
| 312 … | +} |
| 313 … | + |
| 314 … | + |
| 315 … | +function rewriteBase(token) { |
| 316 … | + |
| 317 … | + return pull( |
| 318 … | + replace(token, '" + SSB_VIEWER_ORIGIN + "/'), |
| 319 … | + wrap('var SSB_VIEWER_ORIGIN = (function () {' |
| 320 … | + + 'var scripts = document.getElementsByTagName("script")\n' |
| 321 … | + + 'var script = scripts[scripts.length-1]\n' |
| 322 … | + + 'if (!script) return location.origin\n' |
| 323 … | + + 'return script.src.replace(/\\/%.*$/, "")\n' |
| 324 … | + + '}())\n', '') |
| 325 … | + ) |
| 326 … | +} |
| 327 … | + |
| 328 … | +function join(delim) { |
| 329 … | + var first = true |
| 330 … | + return pull.map(function (val) { |
| 331 … | + if (!first) return delim + String(val) |
| 332 … | + first = false |
| 333 … | + return val |
| 334 … | + }) |
| 335 … | +} |
| 336 … | + |
| 337 … | +function replace(re, rep) { |
| 338 … | + return pull.map(function (val) { |
| 339 … | + return String(val).replace(re, rep) |
| 340 … | + }) |
| 341 … | +} |
| 342 … | + |
| 343 … | +function docWrite(str) { |
| 344 … | + return 'document.write(' + JSON.stringify(str) + ')\n' |
| 345 … | +} |
| 346 … | + |
| 347 … | +function hash(arr) { |
| 348 … | + return arr.reduce(function (hash, item) { |
| 349 … | + return hash.update(String(item)) |
| 350 … | + }, crypto.createHash('sha256')).digest('base64') |
| 351 … | +} |
| 352 … | + |
| 353 … | +function renderMsg(opts, msg) { |
| 354 … | + var c = msg.value.content || {} |
| 355 … | + var name = encodeURIComponent(msg.key) |
| 356 … | + return '<div class="ssb-message" id="' + name + '">' |
| 357 … | + + '<img class="ssb-avatar-image" alt=""' |
| 358 … | + + ' src="' + opts.img_base + escape(msg.author.image) + '"' |
| 359 … | + + ' height="32" width="32">' |
| 360 … | + + '<a class="ssb-avatar-name"' |
| 361 … | + + ' href="' + opts.feed_base + escape(msg.value.author) + '"' |
| 362 … | + + '>' + msg.author.name + '</a>' |
| 363 … | + + msgTimestamp(msg, name) |
| 364 … | + + (c.type === 'post' ? renderPost(opts, c) : renderDefault(c)) |
| 365 … | + + '</div>' |
| 366 … | +} |
| 367 … | + |
| 368 … | +function msgTimestamp(msg, name) { |
| 369 … | + var date = new Date(msg.value.timestamp) |
| 370 … | + return '<time class="ssb-timestamp" datetime="' + date.toISOString() + '">' |
| 371 … | + + '<a href="#' + name + '">' |
| 372 … | + + formatDate(date) + '</a></time>' |
| 373 … | +} |
| 374 … | + |
| 375 … | +function formatDate(date) { |
| 376 … | + |
| 377 … | + return htime(date) |
| 378 … | +} |
| 379 … | + |
| 380 … | +function renderPost(opts, c) { |
| 381 … | + return '<div class="ssb-post">' + marked(c.text, opts.marked) + '</div>' |
| 382 … | +} |
| 383 … | + |
| 384 … | +function renderDefault(c) { |
| 385 … | + return '<pre>' + JSON.stringify(c, 0, 2) + '</pre>' |
| 386 … | +} |