git ssb

9+

cel / ssb-viewer



Commit 829750e126a25926ef967ec2c57267ac57e53829

Initial commit

cel committed on 12/15/2016, 9:26:20 PM

Files changed

README.mdadded
bin.jsadded
example.htmladded
index.jsadded
lib/about.jsadded
package.jsonadded
static/base.cssadded
static/nicer.cssadded
README.mdView
@@ -1,0 +1,105 @@
1 +# ssb-viewer
2 +
3 +HTTP server for read-only views of SSB content. Serves content as web pages or as scripts for embedding in other web pages.
4 +
5 +## Install & Run
6 +
7 +As a sbot plugin:
8 +```sh
9 +mkdir -p ~/.ssb/node_modules
10 +cd ~/.ssb/node_modules
11 +git clone ssb://%MeCTQrz9uszf9EZoTnKCeFeIedhnKWuB3JHW2l1g9NA=.sha256 ssb-viewer && cd ssb-viewer
12 +npm install
13 +sbot plugins.enable ssb-viewer
14 +# restart sbot
15 +```
16 +
17 +Or standalone:
18 +```sh
19 +git clone ssb://%MeCTQrz9uszf9EZoTnKCeFeIedhnKWuB3JHW2l1g9NA=.sha256 ssb-viewer && cd ssb-viewer
20 +npm install
21 +./bin.js
22 +```
23 +
24 +## Usage
25 +
26 +To view a thread as a web page, navigate to a url like `http://localhost:8807/%MSGID`.
27 +
28 +To embed a thread into another web page, load it as follows:
29 +
30 +```html
31 +<script src="http://localhost:8807/%MSGID.js"></script>
32 +```
33 +
34 +To add more than the base styles, you can also load `http://localhost:8807/static/nicer.css`.
35 +
36 +## Routes
37 +
38 +- `/%msgid`: web page showing a message thread
39 +- `/%msgid.js`: script to embed a message thread
40 +- `/%msgid.json`: message thread as JSON
41 +
42 +### Query options
43 +
44 +- `noroot`: don't include the root message in the thread
45 +- `base=...`: base url for links that ssb-viewer can handle
46 +- `msg_base=...`: base url for links to messages
47 +- `feed_base=...`: base url for links to feeds
48 +- `blob_base=...`: base url for links to blobs
49 +- `img_base=...`: base url for embedded blobs (images)
50 +- `emoji_base=...`: base url for emoji images
51 +
52 +The `*_base` query options overwrite the defaults set in the config.
53 +The `base` option is a fallback instead of specifying the URLs separately.
54 +The base options are mostly useful for embedding, where the script is embedded
55 +on a different origin than where ssb-viewer is running. However, you may not
56 +need them, as the ssb-viewer embed script will detect the base where it is
57 +included from.
58 +
59 +## Config
60 +
61 +To change `ssb-viewer`'s default options, edit your `~/.ssb/config`, to have
62 +properties like the following:
63 +```json
64 +{
65 + "viewer": {
66 + "port": 8807,
67 + "host": "::"
68 + }
69 +}
70 +```
71 +You can also pass these as command-line options to `./bin.js` or `sbot` as,
72 +e.g. `--viewer.port 8807`.
73 +
74 +- `viewer.port`: port for the server to listen on. default: `8807`
75 +- `viewer.host`: host address for the server to listen on. default: `::`
76 +- `viewer.base`: default base url for links that ssb-viewer can handle
77 +- `viewer.msg_base`: base url for links to ssb messages
78 +- `viewer.feed_base`: base url for links to ssb feeds
79 +- `viewer.blob_base`: base url for links to ssb blobs
80 +- `viewer.img_base`: base url for embedded blobs (images)
81 +- `viewer.emoji_base`: base url for emoji images
82 +
83 +## References
84 +
85 +- Concept: [ssb-porthole][]
86 +- UI ideas: [sdash][], [patchbay][]
87 +- Server techniques: [ssb-web-server][], [ssb-ws][]
88 +
89 +
90 +[ssb-porthole]: %cgkDJXsh6pO5m458B3ngEro+U0qUMGTY1TRGTZOP6lQ=.sha256
91 +[patchbay]: %s9mSFATE4RGyJx9wgH22lBrvD4CgUQW4yeguSWWjtqc=.sha256
92 +[sdash]: %qrU04j9vfUJKfq1rGZrQ5ihtSfA4ilfY3wLy7xFv0xk=.sha256
93 +[git-ssb-web]: %q5d5Du+9WkaSdjc8aJPZm+jMrqgo0tmfR+RcX5ZZ6H4=.sha256
94 +[ssb-web-server]: %gYctTCrA06BhAGGvQ6PJ0H2eCCQLj1iEsmfn8SD5+nk=.sha256
95 +[ssb-ws]: %tFjo5SoD+Y0SaB5vqZYppmoPmv9LKB5wMPl96qtu4qk=.sha256
96 +
97 +## License
98 +
99 +Copyright (c) 2016 Charles Lehner
100 +
101 +Usage of the works is permitted provided that this instrument is
102 +retained with the works, so that any entity that uses the works is
103 +notified of this instrument.
104 +
105 +DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.
bin.jsView
@@ -1,0 +1,6 @@
1 +#!/usr/bin/env node
2 +
3 +require('ssb-client')(function (err, sbot, config) {
4 + if (err) throw err
5 + require('.').init(sbot, config)
6 +})
example.htmlView
@@ -1,0 +1,28 @@
1 +<!DOCTYPE html>
2 +<html>
3 +<head>
4 +<meta charset=utf-8>
5 +<title>ssb-viewer</title>
6 +<meta name=viewport content="width=device-width,initial-scale=1">
7 +<link rel="stylesheet" href="static/nicer.css">
8 +<style>
9 +body {
10 + background-color: #f3f3f3;
11 +}
12 +#demo .ssb-thread {
13 + background-color: white;
14 + padding: 1ex 2em;
15 +}
16 +</style>
17 +</head>
18 +<body id="demo">
19 + <center>
20 + <h1>Embed Example</h1>
21 + </center>
22 + <script src="http://localhost:8807/%fU0K0uC4orV6258+dGuAcA95TSfc9PrTtKIG9uL3rWI=.sha256.js"></script>
23 + <center>
24 + <p>the end</p>
25 + </center>
26 +</div>
27 +</body>
28 +</html>
index.jsView
@@ -1,0 +1,386 @@
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, '&amp;')
188 + .replace(/</g, '&lt;')
189 + .replace(/>/g, '&gt;')
190 + .replace(/"/g, '&quot;')
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 + // detect the origin of the script and rewrite the js/html to use it
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 + // return date.toISOString().replace('T', ' ')
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 +}
lib/about.jsView
@@ -1,0 +1,31 @@
1 +var pull = require('pull-stream')
2 +var sort = require('ssb-sort')
3 +
4 +function linkDest(val) {
5 + return typeof val === 'string' ? val : val && val.link
6 +}
7 +
8 +function reduceAbout(about, msg) {
9 + var c = msg.value.content
10 + if (!c) return about
11 + if (c.name) about.name = c.name.replace(/^@?/, '@')
12 + if (c.image) about.image = linkDest(c.image)
13 + return about
14 +}
15 +
16 +module.exports = function (sbot, id, cb) {
17 + var about = {}
18 + pull(
19 + sbot.links({
20 + rel: 'about',
21 + dest: id,
22 + values: true,
23 + }),
24 + pull.collect(function (err, msgs) {
25 + if (err) return cb(err)
26 + cb(null, sort(msgs).reduce(reduceAbout, {
27 + name: String(id).substr(0, 10) + '…',
28 + }))
29 + })
30 + )
31 +}
package.jsonView
@@ -1,0 +1,27 @@
1 +{
2 + "name": "ssb-viewer",
3 + "version": "1.0.0",
4 + "description": "serve ssb threads as (embeddable) web pages",
5 + "main": "index.js",
6 + "bin": "bin.js",
7 + "dependencies": {
8 + "asyncmemo": "^1.0.0",
9 + "emoji-named-characters": "^1.0.2",
10 + "emoji-server": "^1.0.0",
11 + "human-time": "^0.0.1",
12 + "lrucache": "^1.0.2",
13 + "pull-cat": "^1.1.11",
14 + "pull-paramap": "^1.2.1",
15 + "pull-stream": "^3.5.0",
16 + "ssb-client": "^4.4.0",
17 + "ssb-marked": "^0.6.0",
18 + "ssb-ref": "^2.6.2",
19 + "ssb-sort": "^1.0.0",
20 + "stream-to-pull-stream": "^1.7.2"
21 + },
22 + "devDependencies": {
23 + "tape": "^4.6.2"
24 + },
25 + "author": "cel",
26 + "license": "Fair"
27 +}
static/base.cssView
@@ -1,0 +1,15 @@
1 +.ssb-timestamp {
2 + float: right;
3 +}
4 +
5 +.ssb-avatar-image {
6 + vertical-align: top;
7 +}
8 +
9 +.ssb-avatar-name {
10 + margin-left: 1ex;
11 +}
12 +
13 +.ssb-post img {
14 + max-width: 100%;
15 +}
static/nicer.cssView
@@ -1,0 +1,32 @@
1 +.ssb-message {
2 + border-bottom: 1px solid #ddd;
3 + margin: 1em 0;
4 +}
5 +
6 +h1, h2, h3, h4 {
7 + line-height: 1.2;
8 +}
9 +
10 +.ssb-thread {
11 + width: 80ex;
12 + max-width: 100%;
13 + min-width: 57%;
14 + margin: 0 auto;
15 + line-height: 1.5;
16 + font-family: sans-serif;
17 +}
18 +
19 +.ssb-message:target {
20 + background-color: #fcfae8;
21 + padding: 1em 1em 0;
22 + margin: -1em -1em 0;
23 +}
24 +.ssb-message:target:first-child {
25 + margin-top: 0;
26 +}
27 +
28 +.ssb-emoji {
29 + height: 1em;
30 + width: 1em;
31 + vertical-align: top;
32 +}

Built with git-ssb-web