Initial commit

This commit is contained in:
cel
2016-12-15 13:26:20 -08:00
commit 829750e126
8 changed files with 630 additions and 0 deletions
+105
View File
@@ -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
<script src="http://localhost:8807/%MSGID.js"></script>
```
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.
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env node
require('ssb-client')(function (err, sbot, config) {
if (err) throw err
require('.').init(sbot, config)
})
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<title>ssb-viewer</title>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="static/nicer.css">
<style>
body {
background-color: #f3f3f3;
}
#demo .ssb-thread {
background-color: white;
padding: 1ex 2em;
}
</style>
</head>
<body id="demo">
<center>
<h1>Embed Example</h1>
</center>
<script src="http://localhost:8807/%fU0K0uC4orV6258+dGuAcA95TSfc9PrTtKIG9uL3rWI=.sha256.js"></script>
<center>
<p>the end</p>
</center>
</div>
</body>
</html>
+386
View File
@@ -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 '<img src="' + this.opts.img_base + escape(href) + '"'
+ ' alt="' + text + '"'
+ (title ? ' title="' + title + '"' : '')
+ (this.options.xhtml ? '/>' : '>')
};
function renderEmoji(emoji) {
var opts = this.renderer.opts
return emoji in emojis ?
'<img src="' + opts.emoji_base + escape(emoji) + '.png"'
+ ' alt=":' + escape(emoji) + ':"'
+ ' title=":' + escape(emoji) + ':"'
+ ' class="ssb-emoji" height="16" width="16">'
: ':' + 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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('<div class="ssb-thread">', '</div>')
)
}
function wrapPage(id) {
return wrap('<!doctype html><html><head>'
+ '<meta charset=utf-8>'
+ '<title>' + id + '</title>'
+ '<meta name=viewport content="width=device-width,initial-scale=1">'
+ '<link rel=stylesheet href="/static/base.css">'
+ '<link rel=stylesheet href="/static/nicer.css">'
+ '</head><body>',
'</body></html>'
)
}
function wrapJSON() {
var first = true
return pull(
pull.map(JSON.stringify),
join(','),
wrap('[', ']')
)
}
function wrapJSEmbed(opts) {
return pull(
wrap('<link rel=stylesheet href="' + opts.base + 'static/base.css">', ''),
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 '<div class="ssb-message" id="' + name + '">'
+ '<img class="ssb-avatar-image" alt=""'
+ ' src="' + opts.img_base + escape(msg.author.image) + '"'
+ ' height="32" width="32">'
+ '<a class="ssb-avatar-name"'
+ ' href="' + opts.feed_base + escape(msg.value.author) + '"'
+ '>' + msg.author.name + '</a>'
+ msgTimestamp(msg, name)
+ (c.type === 'post' ? renderPost(opts, c) : renderDefault(c))
+ '</div>'
}
function msgTimestamp(msg, name) {
var date = new Date(msg.value.timestamp)
return '<time class="ssb-timestamp" datetime="' + date.toISOString() + '">'
+ '<a href="#' + name + '">'
+ formatDate(date) + '</a></time>'
}
function formatDate(date) {
// return date.toISOString().replace('T', ' ')
return htime(date)
}
function renderPost(opts, c) {
return '<div class="ssb-post">' + marked(c.text, opts.marked) + '</div>'
}
function renderDefault(c) {
return '<pre>' + JSON.stringify(c, 0, 2) + '</pre>'
}
+31
View File
@@ -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) + '…',
}))
})
)
}
+27
View File
@@ -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"
}
+15
View File
@@ -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%;
}
+32
View File
@@ -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;
}