From 829750e126a25926ef967ec2c57267ac57e53829 Mon Sep 17 00:00:00 2001 From: cel Date: Thu, 15 Dec 2016 13:26:20 -0800 Subject: [PATCH] Initial commit --- README.md | 105 +++++++++++++ bin.js | 6 + example.html | 28 ++++ index.js | 386 +++++++++++++++++++++++++++++++++++++++++++++++ lib/about.js | 31 ++++ package.json | 27 ++++ static/base.css | 15 ++ static/nicer.css | 32 ++++ 8 files changed, 630 insertions(+) create mode 100644 README.md create mode 100755 bin.js create mode 100644 example.html create mode 100644 index.js create mode 100644 lib/about.js create mode 100644 package.json create mode 100644 static/base.css create mode 100644 static/nicer.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..8041e54 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# ssb-viewer + +HTTP server for read-only views of SSB content. Serves content as web pages or as scripts for embedding in other web pages. + +## Install & Run + +As a sbot plugin: +```sh +mkdir -p ~/.ssb/node_modules +cd ~/.ssb/node_modules +git clone ssb://%MeCTQrz9uszf9EZoTnKCeFeIedhnKWuB3JHW2l1g9NA=.sha256 ssb-viewer && cd ssb-viewer +npm install +sbot plugins.enable ssb-viewer +# restart sbot +``` + +Or standalone: +```sh +git clone ssb://%MeCTQrz9uszf9EZoTnKCeFeIedhnKWuB3JHW2l1g9NA=.sha256 ssb-viewer && cd ssb-viewer +npm install +./bin.js +``` + +## Usage + +To view a thread as a web page, navigate to a url like `http://localhost:8807/%MSGID`. + +To embed a thread into another web page, load it as follows: + +```html + +``` + +To add more than the base styles, you can also load `http://localhost:8807/static/nicer.css`. + +## Routes + +- `/%msgid`: web page showing a message thread +- `/%msgid.js`: script to embed a message thread +- `/%msgid.json`: message thread as JSON + +### Query options + +- `noroot`: don't include the root message in the thread +- `base=...`: base url for links that ssb-viewer can handle +- `msg_base=...`: base url for links to messages +- `feed_base=...`: base url for links to feeds +- `blob_base=...`: base url for links to blobs +- `img_base=...`: base url for embedded blobs (images) +- `emoji_base=...`: base url for emoji images + +The `*_base` query options overwrite the defaults set in the config. +The `base` option is a fallback instead of specifying the URLs separately. +The base options are mostly useful for embedding, where the script is embedded +on a different origin than where ssb-viewer is running. However, you may not +need them, as the ssb-viewer embed script will detect the base where it is +included from. + +## Config + +To change `ssb-viewer`'s default options, edit your `~/.ssb/config`, to have +properties like the following: +```json +{ + "viewer": { + "port": 8807, + "host": "::" + } +} +``` +You can also pass these as command-line options to `./bin.js` or `sbot` as, +e.g. `--viewer.port 8807`. + +- `viewer.port`: port for the server to listen on. default: `8807` +- `viewer.host`: host address for the server to listen on. default: `::` +- `viewer.base`: default base url for links that ssb-viewer can handle +- `viewer.msg_base`: base url for links to ssb messages +- `viewer.feed_base`: base url for links to ssb feeds +- `viewer.blob_base`: base url for links to ssb blobs +- `viewer.img_base`: base url for embedded blobs (images) +- `viewer.emoji_base`: base url for emoji images + +## References + +- Concept: [ssb-porthole][] +- UI ideas: [sdash][], [patchbay][] +- Server techniques: [ssb-web-server][], [ssb-ws][] + + +[ssb-porthole]: %cgkDJXsh6pO5m458B3ngEro+U0qUMGTY1TRGTZOP6lQ=.sha256 +[patchbay]: %s9mSFATE4RGyJx9wgH22lBrvD4CgUQW4yeguSWWjtqc=.sha256 +[sdash]: %qrU04j9vfUJKfq1rGZrQ5ihtSfA4ilfY3wLy7xFv0xk=.sha256 +[git-ssb-web]: %q5d5Du+9WkaSdjc8aJPZm+jMrqgo0tmfR+RcX5ZZ6H4=.sha256 +[ssb-web-server]: %gYctTCrA06BhAGGvQ6PJ0H2eCCQLj1iEsmfn8SD5+nk=.sha256 +[ssb-ws]: %tFjo5SoD+Y0SaB5vqZYppmoPmv9LKB5wMPl96qtu4qk=.sha256 + +## License + +Copyright (c) 2016 Charles Lehner + +Usage of the works is permitted provided that this instrument is +retained with the works, so that any entity that uses the works is +notified of this instrument. + +DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY. diff --git a/bin.js b/bin.js new file mode 100755 index 0000000..b31c243 --- /dev/null +++ b/bin.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +require('ssb-client')(function (err, sbot, config) { + if (err) throw err + require('.').init(sbot, config) +}) diff --git a/example.html b/example.html new file mode 100644 index 0000000..213e77a --- /dev/null +++ b/example.html @@ -0,0 +1,28 @@ + + + + +ssb-viewer + + + + + +
+

Embed Example

+
+ +
+

the end

+
+ + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..3a0a4ec --- /dev/null +++ b/index.js @@ -0,0 +1,386 @@ +var fs = require('fs') +var http = require('http') +var qs = require('querystring') +var path = require('path') +var crypto = require('crypto') +var cat = require('pull-cat') +var pull = require('pull-stream') +var paramap = require('pull-paramap') +var marked = require('ssb-marked') +var sort = require('ssb-sort') +var toPull = require('stream-to-pull-stream') +var memo = require('asyncmemo') +var lru = require('lrucache') +var htime = require('human-time') +var emojis = require('emoji-named-characters') +var serveEmoji = require('emoji-server')() + +var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') +var appHash = hash([fs.readFileSync(__filename)]) + +var urlIdRegex = /^(?:\/(([%&])[A-Za-z0-9\/+]{43}=\.sha256)(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/ + +function MdRenderer(opts) { + marked.Renderer.call(this, {}) + this.opts = opts +} +MdRenderer.prototype = new marked.Renderer() + +MdRenderer.prototype.urltransform = function (href) { + switch (href && href[0]) { + case '%': return this.opts.msg_base + href + case '@': return this.opts.feed_base + href + case '&': return this.opts.blob_base + href + } + return marked.Renderer.prototype.urltransform.call(this, href) +} + +MdRenderer.prototype.image = function (href, title, text) { + return '' + text + '' : '>') +}; + +function renderEmoji(emoji) { + var opts = this.renderer.opts + return emoji in emojis ? + ':' + escape(emoji) + ':' + : ':' + emoji + ':' +} + +exports.name = 'viewer' +exports.manifest = {} +exports.version = require('./package').version + +exports.init = function (sbot, config) { + var conf = config.viewer || {} + var port = conf.port || 8807 + var host = conf.host || config.host || '::' + + var base = conf.base || '/' + var defaultOpts = { + msg_base: conf.msg_base || base, + feed_base: conf.feed_base || '#', + blob_base: conf.blob_base || base, + img_base: conf.img_base || base, + emoji_base: conf.emoji_base || (base + 'emoji/'), + } + + var getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot) + var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot) + + http.createServer(serve).listen(port, host, function () { + console.log('[viewer] Listening on http://' + host + ':' + port) + }) + + function serve(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + return respond(res, 405, 'Method must be GET or HEAD') + } + var m = urlIdRegex.exec(req.url) + switch (m[2]) { + case '&': return serveBlob(req, res, sbot, m[1]) + case '%': return serveId(req, res, m[1], m[3], m[5]) + default: return servePath(req, res, m[4]) + } + } + + function serveId(req, res, id, ext, query) { + var q = query ? qs.parse(query) : {} + var includeRoot = !('noroot' in q) + var base = q.base || conf.base + var baseToken + if (!base) { + if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_' + else base = '/' + } + var opts = { + base: base, + base_token: baseToken, + msg_base: q.msg_base || conf.msg_base || base, + feed_base: q.feed_base || conf.feed_base || '#', + blob_base: q.blob_base || conf.blob_base || base, + img_base: q.img_base || conf.img_base || base, + emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'), + } + opts.marked = { + gfm: true, + mentions: true, + tables: true, + breaks: true, + pedantic: false, + sanitize: true, + smartLists: true, + smartypants: false, + emoji: renderEmoji, + renderer: new MdRenderer(opts) + } + + var format = formatMsgs(id, ext, opts) + if (format === null) return respond(res, 415, 'Invalid format') + + pull( + sbot.links({dest: id, values: true, rel: 'root'}), + includeRoot && prepend(getMsg, id), + pull.unique('key'), + pull.collect(function (err, links) { + if (err) return respond(res, 500, err.stack || err) + var etag = hash(sort.heads(links).concat(appHash, ext, qs)) + if (req.headers['if-none-match'] === etag) return respond(res, 304) + res.writeHead(200, { + 'Content-Type': ctype(ext), + 'etag': etag + }) + pull( + pull.values(sort(links)), + paramap(addAuthorAbout, 8), + format, + toPull(res, function (err) { + if (err) console.error('[viewer]', err) + }) + ) + }) + ) + } + + function addAuthorAbout(msg, cb) { + getAbout(msg.value.author, function (err, about) { + if (err) return cb(err) + msg.author = about + cb(null, msg) + }) + } +} + +function serveBlob(req, res, sbot, id) { + if (req.headers['if-none-match'] === id) return respond(res, 304) + sbot.blobs.has(id, function (err, has) { + if (err && /^invalid/.test(err.message)) return respond(400, err.message) + if (err) return respond(500, err.message || err) + if (!has) return respond(404, 'Not found') + res.writeHead(200, { + 'Cache-Control': 'public, max-age=315360000', + 'etag': id + }) + pull( + sbot.blobs.get(id), + toPull(res, function (err) { + if (err) console.error('[viewer]', err) + }) + ) + }) +} + +function getMsgWithValue(sbot, id, cb) { + sbot.get(id, function (err, value) { + if (err) return cb(err) + cb(null, {key: id, value: value}) + }) +} + +function escape(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function respond(res, status, message) { + res.writeHead(status) + res.end(message) +} + +function ctype(name) { + switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { + case 'html': return 'text/html' + case 'js': return 'text/javascript' + case 'css': return 'text/css' + case 'json': return 'application/json' + } +} + +function servePath(req, res, url) { + switch (url) { + case '/robots.txt': return res.end('User-agent: *') + } + var m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) + switch (m[1]) { + case '/static': return serveStatic(req, res, m[2]) + case '/emoji': return serveEmoji(req, res, m[2]) + } + return respond(res, 404, 'Not found') +} + +function ifModified(req, lastMod) { + var ifModSince = req.headers['if-modified-since'] + if (!ifModSince) return false + var d = new Date(ifModSince) + return d && Math.floor(d/1000) >= Math.floor(lastMod/1000) +} + +function serveStatic(req, res, file) { + serveFile(req, res, path.join(__dirname, 'static', file)) +} + +function serveFile(req, res, file) { + fs.stat(file, function (err, stat) { + if (err && err.code === 'ENOENT') return respond(res, 404, 'Not found') + if (err) return respond(res, 500, err.stack || err) + if (!stat.isFile()) return respond(res, 403, 'May only load files') + if (ifModified(req, stat.mtime)) return respond(res, 304, 'Not modified') + res.writeHead(200, { + 'Content-Type': ctype(file), + 'Content-Length': stat.size, + 'Last-Modified': stat.mtime.toGMTString() + }) + fs.createReadStream(file).pipe(res) + }) +} + +function prepend(fn, arg) { + return function (read) { + return function (abort, cb) { + if (fn && !abort) { + var _fn = fn + fn = null + return _fn(arg, function (err, value) { + if (err) return read(err, function (err) { + cb(err || true) + }) + cb(null, value) + }) + } + read(abort, cb) + } + } +} + +function formatMsgs(id, ext, opts) { + switch (ext || 'html') { + case 'html': return pull(renderThread(opts), wrapPage(id)) + case 'js': return pull(renderThread(opts), wrapJSEmbed(opts)) + case 'json': return wrapJSON() + default: return null + } +} + +function wrap(before, after) { + return function (read) { + return cat([pull.once(before), read, pull.once(after)]) + } +} + +function renderThread(opts) { + return pull( + pull.map(renderMsg.bind(this, opts)), + wrap('
', '
') + ) +} + +function wrapPage(id) { + return wrap('' + + '' + + '' + id + '' + + '' + + '' + + '' + + '', + '' + ) +} + +function wrapJSON() { + var first = true + return pull( + pull.map(JSON.stringify), + join(','), + wrap('[', ']') + ) +} + +function wrapJSEmbed(opts) { + return pull( + wrap('', ''), + pull.map(docWrite), + opts.base_token && rewriteBase(new RegExp(opts.base_token, 'g')) + ) +} + + +function rewriteBase(token) { + // detect the origin of the script and rewrite the js/html to use it + return pull( + replace(token, '" + SSB_VIEWER_ORIGIN + "/'), + wrap('var SSB_VIEWER_ORIGIN = (function () {' + + 'var scripts = document.getElementsByTagName("script")\n' + + 'var script = scripts[scripts.length-1]\n' + + 'if (!script) return location.origin\n' + + 'return script.src.replace(/\\/%.*$/, "")\n' + + '}())\n', '') + ) +} + +function join(delim) { + var first = true + return pull.map(function (val) { + if (!first) return delim + String(val) + first = false + return val + }) +} + +function replace(re, rep) { + return pull.map(function (val) { + return String(val).replace(re, rep) + }) +} + +function docWrite(str) { + return 'document.write(' + JSON.stringify(str) + ')\n' +} + +function hash(arr) { + return arr.reduce(function (hash, item) { + return hash.update(String(item)) + }, crypto.createHash('sha256')).digest('base64') +} + +function renderMsg(opts, msg) { + var c = msg.value.content || {} + var name = encodeURIComponent(msg.key) + return '
' + + '' + + '' + msg.author.name + '' + + msgTimestamp(msg, name) + + (c.type === 'post' ? renderPost(opts, c) : renderDefault(c)) + + '
' +} + +function msgTimestamp(msg, name) { + var date = new Date(msg.value.timestamp) + return '' +} + +function formatDate(date) { + // return date.toISOString().replace('T', ' ') + return htime(date) +} + +function renderPost(opts, c) { + return '
' + marked(c.text, opts.marked) + '
' +} + +function renderDefault(c) { + return '
' + JSON.stringify(c, 0, 2) + '
' +} diff --git a/lib/about.js b/lib/about.js new file mode 100644 index 0000000..553135c --- /dev/null +++ b/lib/about.js @@ -0,0 +1,31 @@ +var pull = require('pull-stream') +var sort = require('ssb-sort') + +function linkDest(val) { + return typeof val === 'string' ? val : val && val.link +} + +function reduceAbout(about, msg) { + var c = msg.value.content + if (!c) return about + if (c.name) about.name = c.name.replace(/^@?/, '@') + if (c.image) about.image = linkDest(c.image) + return about +} + +module.exports = function (sbot, id, cb) { + var about = {} + pull( + sbot.links({ + rel: 'about', + dest: id, + values: true, + }), + pull.collect(function (err, msgs) { + if (err) return cb(err) + cb(null, sort(msgs).reduce(reduceAbout, { + name: String(id).substr(0, 10) + '…', + })) + }) + ) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..923048c --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ssb-viewer", + "version": "1.0.0", + "description": "serve ssb threads as (embeddable) web pages", + "main": "index.js", + "bin": "bin.js", + "dependencies": { + "asyncmemo": "^1.0.0", + "emoji-named-characters": "^1.0.2", + "emoji-server": "^1.0.0", + "human-time": "^0.0.1", + "lrucache": "^1.0.2", + "pull-cat": "^1.1.11", + "pull-paramap": "^1.2.1", + "pull-stream": "^3.5.0", + "ssb-client": "^4.4.0", + "ssb-marked": "^0.6.0", + "ssb-ref": "^2.6.2", + "ssb-sort": "^1.0.0", + "stream-to-pull-stream": "^1.7.2" + }, + "devDependencies": { + "tape": "^4.6.2" + }, + "author": "cel", + "license": "Fair" +} diff --git a/static/base.css b/static/base.css new file mode 100644 index 0000000..738d839 --- /dev/null +++ b/static/base.css @@ -0,0 +1,15 @@ +.ssb-timestamp { + float: right; +} + +.ssb-avatar-image { + vertical-align: top; +} + +.ssb-avatar-name { + margin-left: 1ex; +} + +.ssb-post img { + max-width: 100%; +} diff --git a/static/nicer.css b/static/nicer.css new file mode 100644 index 0000000..1a715bd --- /dev/null +++ b/static/nicer.css @@ -0,0 +1,32 @@ +.ssb-message { + border-bottom: 1px solid #ddd; + margin: 1em 0; +} + +h1, h2, h3, h4 { + line-height: 1.2; +} + +.ssb-thread { + width: 80ex; + max-width: 100%; + min-width: 57%; + margin: 0 auto; + line-height: 1.5; + font-family: sans-serif; +} + +.ssb-message:target { + background-color: #fcfae8; + padding: 1em 1em 0; + margin: -1em -1em 0; +} +.ssb-message:target:first-child { + margin-top: 0; +} + +.ssb-emoji { + height: 1em; + width: 1em; + vertical-align: top; +}