aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/app.js102
-rw-r--r--lib/markdown-inline.js49
-rw-r--r--lib/render-msg.js337
-rw-r--r--lib/render.js203
-rw-r--r--lib/serve.js774
-rw-r--r--lib/util.js80
6 files changed, 1545 insertions, 0 deletions
diff --git a/lib/app.js b/lib/app.js
new file mode 100644
index 0000000..de8585d
--- /dev/null
+++ b/lib/app.js
@@ -0,0 +1,102 @@
+var http = require('http')
+var memo = require('asyncmemo')
+var lru = require('lrucache')
+var pkg = require('../package')
+var u = require('./util')
+
+var Serve = require('./serve')
+var Render = require('./render')
+
+module.exports = App
+
+function App(sbot, config) {
+ this.sbot = sbot
+ this.config = config
+
+ var conf = config.patchfoo || {}
+ this.port = conf.port || 8027
+ this.host = conf.host || 'localhost'
+
+ var base = conf.base || '/'
+ this.opts = {
+ base: base,
+ blob_base: conf.blob_base || conf.img_base || base,
+ img_base: conf.img_base || base,
+ emoji_base: conf.emoji_base || (base + 'emoji/'),
+ }
+
+ sbot.get = memo({cache: lru(100)}, sbot.get)
+ this.getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot)
+ this.getAbout = memo({cache: lru(100)}, require('ssb-avatar'), sbot, sbot.id)
+ this.unboxContent = memo({cache: lru(100)}, sbot.private.unbox)
+
+ this.unboxMsg = this.unboxMsg.bind(this)
+
+ this.render = new Render(this, this.opts)
+}
+
+App.prototype.go = function () {
+ var self = this
+ http.createServer(function (req, res) {
+ new Serve(self, req, res).go()
+ }).listen(self.port, self.host, function () {
+ self.log('Listening on http://' + self.host + ':' + self.port)
+ })
+}
+
+App.prototype.logPrefix = ['[' + pkg.name + ']']
+
+App.prototype.log = function () {
+ console.log.apply(console, [].concat.apply(this.logPrefix, arguments))
+}
+
+App.prototype.error = function () {
+ console.error.apply(console, [].concat.apply(this.logPrefix, arguments))
+}
+
+App.prototype.unboxMsg = function (msg, cb) {
+ var self = this
+ var c = msg.value.content
+ if (typeof c !== 'string') cb(null, msg)
+ else self.unboxContent(c, function (err, content) {
+ if (err) {
+ self.error('unbox:', err)
+ return cb(null, msg)
+ }
+ var m = {}
+ for (var k in msg) m[k] = msg[k]
+ m.value = {}
+ for (var k in msg.value) m.value[k] = msg.value[k]
+ m.value.content = content
+ m.value.private = true
+ cb(null, m)
+ })
+}
+
+App.prototype.search = function (opts) {
+ return this.sbot.fulltext.search(opts)
+}
+
+App.prototype.getMsgDecrypted = function (key, cb) {
+ var self = this
+ this.getMsg(key, function (err, msg) {
+ if (err) return cb(err)
+ self.unboxMsg(msg, cb)
+ })
+}
+
+App.prototype.publish = function (content, cb) {
+ if (Array.isArray(content.recps)) {
+ recps = content.recps.map(u.linkDest)
+ this.sbot.private.publish(content, recps, cb)
+ } else {
+ this.sbot.publish(content, cb)
+ }
+}
+
+function getMsgWithValue(sbot, id, cb) {
+ sbot.get(id, function (err, value) {
+ if (err) return cb(err)
+ cb(null, {key: id, value: value})
+ })
+}
diff --git a/lib/markdown-inline.js b/lib/markdown-inline.js
new file mode 100644
index 0000000..2f1e696
--- /dev/null
+++ b/lib/markdown-inline.js
@@ -0,0 +1,49 @@
+var marked = require('ssb-marked')
+var u = require('./util')
+
+// based on ssb-markdown, which is Copyright (c) 2016 Dominic Tarr, MIT License
+
+var inlineRenderer = new marked.Renderer()
+
+// inline renderer just spits out the text of links and images
+inlineRenderer.urltransform = function (url) { return false }
+inlineRenderer.link = function (href, title, text) { return unquote(shortenIfLink(text)) }
+inlineRenderer.image = function (href, title, text) { return unquote(shortenIfLink(text)) }
+inlineRenderer.code = function(code, lang, escaped) { return escaped ? code : escape(code) }
+inlineRenderer.blockquote = function(quote) { return unquote(quote) }
+inlineRenderer.html = function(html) { return false }
+inlineRenderer.heading = function(text, level, raw) { return unquote(text)+' ' }
+inlineRenderer.hr = function() { return ' --- ' }
+inlineRenderer.br = function() { return ' ' }
+inlineRenderer.list = function(body, ordered) { return unquote(body) }
+inlineRenderer.listitem = function(text) { return '- '+unquote(text) }
+inlineRenderer.paragraph = function(text) { return unquote(text)+' ' }
+inlineRenderer.table = function(header, body) { return unquote(header + ' ' + body) }
+inlineRenderer.tablerow = function(content) { return unquote(content) }
+inlineRenderer.tablecell = function(content, flags) { return unquote(content) }
+inlineRenderer.strong = function(text) { return unquote(text) }
+inlineRenderer.em = function(text) { return unquote(text) }
+inlineRenderer.codespan = function(text) { return unquote(text) }
+inlineRenderer.del = function(text) { return unquote(text) }
+inlineRenderer.mention = function(preceding, id) { return shortenIfLink(unquote((preceding||'') + id)) }
+
+function unquote (text) {
+ return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '\'')
+}
+
+function escape (text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/\n+/g, ' ')
+}
+
+function shortenIfLink (text) {
+ return (u.ssbRefRegex.test(text.trim())) ? text.slice(0, 8) : text
+}
+
+module.exports = function(text) {
+ return marked(''+(text||''), {renderer: inlineRenderer, emoji: false})
+}
diff --git a/lib/render-msg.js b/lib/render-msg.js
new file mode 100644
index 0000000..bc0d383
--- /dev/null
+++ b/lib/render-msg.js
@@ -0,0 +1,337 @@
+var h = require('hyperscript')
+var htime = require('human-time')
+var multicb = require('multicb')
+var u = require('./util')
+var mdInline = require('./markdown-inline')
+
+module.exports = RenderMsg
+
+function RenderMsg(render, app, msg, opts) {
+ this.render = render
+ this.app = app
+ this.msg = msg
+ var opts = opts || {}
+ this.shouldWrap = opts.wrap !== false
+
+ this.c = msg.value.content || {}
+}
+
+RenderMsg.prototype.toUrl = function (href) {
+ return this.render.toUrl(href)
+}
+
+RenderMsg.prototype.linkify = function (text) {
+ var arr = text.split(u.ssbRefRegex)
+ for (var i = 1; i < arr.length; i += 2) {
+ arr[i] = h('a', {href: this.toUrl(arr[i])}, arr[i])
+ }
+ return arr
+}
+
+RenderMsg.prototype.raw = function (cb) {
+ this.wrap(h('pre',
+ this.linkify(JSON.stringify(this.msg, 0, 2))
+ ), cb)
+}
+
+RenderMsg.prototype.wrap = function (content, cb) {
+ if (!this.shouldWrap) return cb(null, content)
+ var date = new Date(this.msg.value.timestamp)
+ var self = this
+ var channel = this.c.channel ? '#' + this.c.channel : ''
+ var done = multicb({pluck: 1, spread: true})
+ done()(null, h('tr.msg-row',
+ h('td.msg-left',
+ h('div', this.render.avatarImage(this.msg.value.author, done())),
+ h('div', this.render.idLink(this.msg.value.author, done())),
+ this.recpsLine(done())
+ ),
+ h('td.msg-main',
+ h('div.msg-header',
+ h('a.ssb-timestamp', {
+ title: date.toLocaleString(),
+ href: this.msg.key ? this.toUrl(this.msg.key) : undefined
+ }, htime(date)), ' ',
+ h('code.ssb-id',
+ {href: this.toUrl(this.msg.key)}, this.msg.key),
+ channel ? [' ', h('a', {href: this.toUrl(channel)}, channel)] : ''),
+ content),
+ h('td.msg-right',
+ this.msg.rel ? [this.msg.rel, ' '] : '',
+ this.msg.key ? h('form', {method: 'post', action: '/vote'},
+ h('div', h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw')),
+ h('input', {type: 'hidden', name: 'recps',
+ value: this.recpsIds().join(',')}),
+ h('input', {type: 'hidden', name: 'link', value: this.msg.key}),
+ h('input', {type: 'hidden', name: 'value', value: 1}),
+ h('input', {type: 'submit', name: 'expression', value: 'dig'})
+ ) : ''
+ )
+ ))
+ done(cb)
+}
+
+RenderMsg.prototype.wrapMini = function (content, cb) {
+ if (!this.shouldWrap) return cb(null, content)
+ var date = new Date(this.msg.value.timestamp)
+ var self = this
+ var channel = this.c.channel ? '#' + this.c.channel : ''
+ var done = multicb({pluck: 1, spread: true})
+ done()(null, h('tr.msg-row',
+ h('td.msg-left',
+ this.render.idLink(this.msg.value.author, done()), ' ',
+ this.recpsLine(done()),
+ channel ? [h('a', {href: this.toUrl(channel)}, channel), ' '] : ''),
+ h('td.msg-main',
+ h('a.ssb-timestamp', {
+ title: date.toLocaleString(),
+ href: this.msg.key ? this.toUrl(this.msg.key) : undefined
+ }, htime(date)), ' ',
+ content),
+ h('td.msg-right',
+ h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw'))
+ ))
+ done(cb)
+}
+
+RenderMsg.prototype.recpsLine = function (cb) {
+ return this.msg.value.private
+ ? this.render.privateLine(this.c.recps, cb)
+ : (cb(), '')
+}
+
+RenderMsg.prototype.recpsIds = function () {
+ return this.msg.value.private
+ ? u.toArray(this.c.recps).map(u.linkDest)
+ : []
+}
+
+RenderMsg.prototype.message = function (raw, cb) {
+ if (raw) return this.raw(cb)
+ if (typeof this.c === 'string') return this.encrypted(cb)
+ switch (this.c.type) {
+ case 'post': return this.post(cb)
+ case 'vote': return this.vote(cb)
+ case 'about': return this.about(cb)
+ case 'contact': return this.contact(cb)
+ case 'pub': return this.pub(cb)
+ case 'channel': return this.channel(cb)
+ case 'git-repo': return this.gitRepo(cb)
+ case 'git-update': return this.gitUpdate(cb)
+ case 'pull-request': return this.gitPullRequest(cb)
+ case 'issue': return this.issue(cb)
+ default: return this.object(cb)
+ }
+}
+
+RenderMsg.prototype.encrypted = function (cb) {
+ this.wrapMini(this.render.lockIcon(), cb)
+}
+
+RenderMsg.prototype.markdown = function (cb) {
+ return this.render.markdown(this.c.text, this.c.mentions)
+}
+
+RenderMsg.prototype.post = function (cb) {
+ var self = this
+ self.link(self.c.root, function (err, a) {
+ if (err) return self.wrap(u.renderError(err), cb)
+ self.wrap(h('div.ssb-post',
+ a ? h('div', h('small', 're: ', a)) : '',
+ h('div.ssb-post-text', {innerHTML: self.markdown()})
+ ), cb)
+ })
+}
+
+RenderMsg.prototype.vote = function (cb) {
+ var self = this
+ var v = self.c.vote || {}
+ self.link(v, function (err, a) {
+ if (err) return cb(err)
+ self.wrapMini([
+ v.value > 0 ? 'dug' : v.value < 0 ? 'downvoted' : 'undug', ' ', a], cb)
+ })
+}
+
+RenderMsg.prototype.getName = function (id, cb) {
+ switch (id && id[0]) {
+ case '%': return this.getMsgName(id, cb)
+ case '@': // fallthrough
+ case '&': return this.getAboutName(id, cb)
+ default: return cb(null, String(id))
+ }
+}
+
+RenderMsg.prototype.getMsgName = function (id, cb) {
+ var self = this
+ self.app.getMsg(id, function (err, msg) {
+ if (err && err.name == 'NotFoundError')
+ cb(null, id.substring(0, 10)+'...(missing)')
+ else if (err) cb(err)
+ // preserve security: only decrypt the linked message if we decrypted
+ // this message
+ else if (self.msg.value.private) self.app.unboxMsg(msg, gotMsg)
+ else gotMsg(null, msg)
+ })
+ function gotMsg(err, msg) {
+ if (err) return cb(err)
+ new RenderMsg(self.render, self.app, msg, {wrap: false}).title(cb)
+ }
+}
+
+function truncate(str, len) {
+ return str.length > len ? str.substr(0, len) + '...' : str
+}
+
+function title(str) {
+ return truncate(mdInline(str), 40)
+}
+
+RenderMsg.prototype.title = function (cb) {
+ var self = this
+ if (typeof self.c.text === 'string') {
+ if (self.c.type === 'post')
+ cb(null, title(self.c.text))
+ else
+ cb(null, self.c.type + ':' + (self.c.title || title(self.c.text)))
+ } else if (self.c.type === 'git-repo') {
+ self.getAboutName(self.msg.key, cb)
+ } else {
+ self.message(false, function (err, el) {
+ if (err) return cb(err)
+ cb(null, title(h('div', el).textContent))
+ })
+ }
+}
+
+RenderMsg.prototype.getAboutName = function (id, cb) {
+ this.app.getAbout(id, function (err, about) {
+ cb(err, about && about.name)
+ })
+}
+
+RenderMsg.prototype.link = function (link, cb) {
+ var self = this
+ var ref = u.linkDest(link)
+ if (!ref) return cb(null, '')
+ self.getName(ref, function (err, name) {
+ if (err) return cb(err)
+ cb(null, h('a', {href: self.toUrl(ref)}, name))
+ })
+}
+
+RenderMsg.prototype.about = function (cb) {
+ var img = u.linkDest(this.c.image)
+ this.wrapMini([
+ this.c.about === this.msg.value.author ? 'self-identifies' :
+ ['identifies ', h('a', {href: this.toUrl(this.c.about)}, truncate(this.c.about, 10))],
+ ' as ',
+ this.c.name ? [h('ins', this.c.name), ' '] : '',
+ img ? [
+ h('br'),
+ h('a', {href: this.toUrl(img)},
+ h('img', {
+ src: this.render.imageUrl(img),
+ alt: img,
+ width: 64,
+ height: 64,
+ })
+ )
+ ] : ''
+ ], cb)
+}
+
+RenderMsg.prototype.contact = function (cb) {
+ var self = this
+ self.link(self.c.contact, function (err, a) {
+ if (err) return cb(err)
+ self.wrapMini([
+ self.c.following ? 'follows' :
+ self.c.blocking ? 'blocks' :
+ self.c.following === false ? 'unfollows' :
+ self.c.blocking === false ? 'unblocks' : '',
+ ' ', a], cb)
+ })
+}
+
+RenderMsg.prototype.pub = function (cb) {
+ var self = this
+ var addr = self.c.address || {}
+ self.link(addr.key, function (err, pubLink) {
+ if (err) return cb(err)
+ self.wrapMini([
+ 'pub ', pubLink, ': ',
+ h('code', addr.host + ':' + addr.port)], cb)
+ })
+}
+
+RenderMsg.prototype.channel = function (cb) {
+ var chan = '#' + this.c.channel
+ this.wrapMini([
+ this.c.subscribed ? 'subscribes to ' :
+ this.c.subscribed === false ? 'unsubscribes from ' : '',
+ h('a', {href: this.toUrl(chan)}, chan)], cb)
+}
+
+RenderMsg.prototype.gitRepo = function (cb) {
+ this.wrapMini([
+ 'git repo ',
+ h('code', h('small', 'ssb://' + this.msg.key)),
+ this.c.name ? [' ', h('a', {href: this.toUrl(this.msg.key)},
+ '%' + this.c.name)] : ''
+ ], cb)
+}
+
+RenderMsg.prototype.gitUpdate = function (cb) {
+ var self = this
+ // h('a', {href: self.toUrl(self.c.repo)}, 'ssb://' + self.c.repo),
+ self.link(self.c.repo, function (err, a) {
+ if (err) return cb(err)
+ self.wrap(h('div.ssb-git-update',
+ 'git push ', a, ' ',
+ self.c.refs ? h('ul', Object.keys(self.c.refs).map(function (ref) {
+ var id = self.c.refs[ref]
+ return h('li',
+ ref.replace(/^refs\/(heads|tags)\//, ''), ': ',
+ id ? h('code', id) : h('em', 'deleted'))
+ })) : '',
+ Array.isArray(self.c.commits) ?
+ h('ul', self.c.commits.map(function (commit) {
+ return h('li',
+ h('code', String(commit.sha1).substr(0, 8)), ' ',
+ commit.title)
+ })) : ''
+ ), cb)
+ })
+}
+
+RenderMsg.prototype.gitPullRequest = function (cb) {
+ var self = this
+ var done = multicb({pluck: 1, spread: true})
+ self.link(self.c.repo, done())
+ self.link(self.c.head_repo, done())
+ done(function (err, baseRepoLink, headRepoLink) {
+ if (err) return cb(err)
+ self.wrap(h('div.ssb-pull-request',
+ 'pull request ',
+ 'to ', baseRepoLink, ':', self.c.branch, ' ',
+ 'from ', headRepoLink, ':', self.c.head_branch,
+ self.c.title ? h('h4', self.c.title) : '',
+ h('div', {innerHTML: self.markdown()})), cb)
+ })
+}
+
+RenderMsg.prototype.issue = function (cb) {
+ var self = this
+ self.link(self.c.project, function (err, projectLink) {
+ if (err) return cb(err)
+ self.wrap(h('div.ssb-issue',
+ 'issue on ', projectLink,
+ self.c.title ? h('h4', self.c.title) : '',
+ h('div', {innerHTML: self.markdown()})), cb)
+ })
+}
+
+RenderMsg.prototype.object = function (cb) {
+ this.wrapMini(h('pre', this.c.type), cb)
+}
diff --git a/lib/render.js b/lib/render.js
new file mode 100644
index 0000000..135f3b3
--- /dev/null
+++ b/lib/render.js
@@ -0,0 +1,203 @@
+var fs = require('fs')
+var path = require('path')
+var pull = require('pull-stream')
+var cat = require('pull-cat')
+var paramap = require('pull-paramap')
+var h = require('hyperscript')
+var marked = require('ssb-marked')
+var emojis = require('emoji-named-characters')
+var qs = require('querystring')
+var u = require('./util')
+var multicb = require('multicb')
+var RenderMsg = require('./render-msg')
+
+module.exports = Render
+
+function MdRenderer(render) {
+ marked.Renderer.call(this, {})
+ this.render = render
+}
+MdRenderer.prototype = new marked.Renderer()
+
+MdRenderer.prototype.urltransform = function (href) {
+ return this.render.toUrl(href)
+}
+
+MdRenderer.prototype.image = function (href, title, text) {
+ return h('img', {
+ src: this.render.imageUrl(href),
+ alt: text,
+ title: title || undefined
+ }).outerHTML
+}
+
+function lexerRenderEmoji(emoji) {
+ var el = this.renderer.render.emoji(emoji)
+ return el && el.outerHTML || el
+}
+
+function Render(app, opts) {
+ this.app = app
+ this.opts = opts
+
+ this.markedOpts = {
+ gfm: true,
+ mentions: true,
+ tables: true,
+ breaks: true,
+ pedantic: false,
+ sanitize: true,
+ smartLists: true,
+ smartypants: false,
+ emoji: lexerRenderEmoji,
+ renderer: new MdRenderer(this),
+ }
+}
+
+Render.prototype.emoji = function (emoji) {
+ var name = ':' + emoji + ':'
+ return emoji in emojis ?
+ h('img.ssb-emoji', {
+ src: this.opts.emoji_base + emoji + '.png',
+ alt: name,
+ title: name,
+ height: 16,
+ width: 16
+ }) : name
+}
+
+Render.prototype.markdown = function (text, mentions) {
+ var mentionsObj = this._mentions = {}
+ if (Array.isArray(mentions)) mentions.forEach(function (link) {
+ if (link && link.name) mentionsObj['@' + link.name] = link.link
+ })
+ var out = marked((text || '').toString(), this.markedOpts)
+ delete this._mentions
+ return out
+}
+
+Render.prototype.imageUrl = function (ref) {
+ return this.opts.img_base + ref
+}
+
+Render.prototype.toUrl = function (href) {
+ if (!href) return href
+ var mentions = this._mentions
+ if (mentions && href in this._mentions) href = this._mentions[href]
+ switch (href[0]) {
+ case '%': return this.opts.base + encodeURIComponent(href)
+ case '@':
+ if (!u.isRef(href)) return false
+ return this.opts.base + href
+ case '&': return this.opts.blob_base + href
+ case '#': return this.opts.base + encodeURIComponent(href)
+ case '/': return this.opts.base + href.substr(1)
+ }
+ if (/^javascript:/.test(href)) return false
+ return href
+}
+
+Render.prototype.lockIcon = function () {
+ return this.emoji('lock')
+}
+
+Render.prototype.avatarImage = function (link, cb) {
+ var self = this
+ if (!link) return cb(), ''
+ if (typeof link === 'string') link = {link: link}
+ var img = h('img.ssb-avatar-image', {
+ alt: link.link
+ })
+ if (link.image) gotAbout(null, link)
+ else self.app.getAbout(link.link, gotAbout)
+ function gotAbout(err, about) {
+ if (err) return cb(err)
+ if (!about.image) img.src = self.toUrl('/static/fallback.png')
+ else img.src = self.imageUrl(about.image)
+ cb()
+ }
+ return img
+}
+
+Render.prototype.prepareLink = function (link, cb) {
+ if (typeof link === 'string') link = {link: link}
+ if (link.name || !link.link) cb(null, link)
+ else this.app.getAbout(link.link, function (err, about) {
+ if (err) return cb(err)
+ link.name = about.name
+ if (link.name && link.name[0] === link.link[0]) {
+ link.name = link.name.substr(1)
+ }
+ cb(null, link)
+ })
+}
+
+Render.prototype.prepareLinks = function (links, cb) {
+ var self = this
+ if (!links) return cb()
+ var done = multicb({pluck: 1})
+ if (Array.isArray(links)) links.forEach(function (link) {
+ self.prepareLink(link, done())
+ })
+ done(cb)
+}
+
+Render.prototype.idLink = function (link, cb) {
+ var self = this
+ if (!link) return cb(), ''
+ var a = h('a', ' ')
+ self.prepareLink(link, function (err, link) {
+ if (err) return cb(err)
+ a.href = self.toUrl(link.link)
+ a.childNodes[0].textContent = '@' + link.name
+ cb()
+ })
+ return a
+}
+
+Render.prototype.privateLine = function (recps, cb) {
+ var done = multicb({pluck: 1, spread: true})
+ var self = this
+ var el = h('div.recps',
+ self.lockIcon(),
+ Array.isArray(recps)
+ ? recps.map(function (recp) {
+ return [' ', self.idLink(recp, done())]
+ }) : '')
+ done(cb)
+ return el
+}
+
+Render.prototype.publish = function (content, cb) {
+ var self = this
+
+ var el = h('div')
+ self.app.publish(content, function (err, msg) {
+ if (err) return el.appendChild(u.renderError(err)), cb()
+ self.app.unboxMsg(msg, function (err, msg) {
+ if (err) return el.appendChild(u.renderError(err)), cb()
+ self.renderMsg(msg, false, function (err, msgEl) {
+ if (err) msgEl = [
+ h('a', {href: self.toUrl(msg.key)}, msg.key),
+ u.renderError(err)]
+ el.appendChild(h('div',
+ 'published:',
+ h('table.ssb-msgs', msgEl)
+ ))
+ cb()
+ })
+ })
+ })
+ return el
+}
+
+Render.prototype.renderMsg = function (msg, raw, cb) {
+ new RenderMsg(this, this.app, msg).message(raw, cb)
+}
+
+Render.prototype.renderFeeds = function (raw) {
+ var self = this
+ return paramap(function (msg, cb) {
+ self.renderMsg(msg, raw, cb)
+ }, 4)
+}
diff --git a/lib/serve.js b/lib/serve.js
new file mode 100644
index 0000000..e85ca31
--- /dev/null
+++ b/lib/serve.js
@@ -0,0 +1,774 @@
+var fs = require('fs')
+var qs = require('querystring')
+var pull = require('pull-stream')
+var path = require('path')
+var paramap = require('pull-paramap')
+var sort = require('ssb-sort')
+var crypto = require('crypto')
+var toPull = require('stream-to-pull-stream')
+var serveEmoji = require('emoji-server')()
+var u = require('./util')
+var cat = require('pull-cat')
+var h = require('hyperscript')
+var paginate = require('pull-paginate')
+var ssbMentions = require('ssb-mentions')
+var multicb = require('multicb')
+var pkg = require('../package')
+
+module.exports = Serve
+
+var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs')
+var appHash = hash([fs.readFileSync(__filename)])
+
+var urlIdRegex = /^(?:\/(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
+
+function hash(arr) {
+ return arr.reduce(function (hash, item) {
+ return hash.update(String(item))
+ }, crypto.createHash('sha256')).digest('base64')
+}
+
+function isMsgReadable(msg) {
+ var c = msg && msg.value.content
+ return typeof c === 'object' && c !== null
+}
+
+function isMsgEncrypted(msg) {
+ var c = msg && msg.value.content
+ return typeof c === 'string'
+}
+
+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 'png': return 'image/png'
+ case 'json': return 'application/json'
+ }
+}
+
+function Serve(app, req, res) {
+ this.app = app
+ this.req = req
+ this.res = res
+ this.startDate = new Date()
+}
+
+Serve.prototype.go = function () {
+ console.log(this.req.method, this.req.url)
+ var self = this
+
+ if (this.req.method === 'POST' || this.req.method === 'PUT') {
+ pull(
+ toPull(this.req),
+ pull.collect(function (err, bufs) {
+ var data
+ if (!err) try {
+ data = qs.parse(Buffer.concat(bufs).toString('ascii'))
+ } catch(e) {
+ err = e
+ }
+ gotData(err, data)
+ })
+ )
+ } else {
+ gotData(null, {})
+ }
+ function gotData(err, data) {
+ if (err) {
+ self.req.writeHead(400, {'Content-Type': 'text/plain'})
+ self.req.end(err.stack)
+ } else {
+ self.data = data
+ self.handle()
+ }
+ }
+}
+
+Serve.prototype.handle = function () {
+ var m = urlIdRegex.exec(this.req.url)
+ this.query = m[5] ? qs.parse(m[5]) : {}
+ switch (m[2]) {
+ case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1])
+ case '%': return this.id(m[1], m[3])
+ case '@': return this.userFeed(m[1], m[3])
+ case '&': return this.blob(m[1])
+ default: return this.path(m[4])
+ }
+}
+
+Serve.prototype.respond = function (status, message) {
+ this.res.writeHead(status)
+ this.res.end(message)
+}
+
+Serve.prototype.respondSink = function (status, headers, cb) {
+ var self = this
+ self.res.writeHead(status, headers)
+ return toPull(self.res, cb || function (err) {
+ if (err) self.error(err)
+ })
+}
+
+Serve.prototype.path = function (url) {
+ var m
+ switch (url) {
+ case '/': return this.home()
+ case '/robots.txt': return this.res.end('User-agent: *')
+ }
+ if (m = /^\/%23(.*)/.exec(url)) {
+ return this.channel(decodeURIComponent(m[1]))
+ }
+ m = /^([^.]*)(?:\.(.*))?$/.exec(url)
+ switch (m[1]) {
+ case '/public': return this.public(m[2])
+ case '/private': return this.private(m[2])
+ case '/search': return this.search(m[2])
+ case '/vote': return this.vote(m[2])
+ }
+ m = /^(\/?[^\/]*)(\/.*)?$/.exec(url)
+ switch (m[1]) {
+ case '/static': return this.static(m[2])
+ case '/emoji': return this.emoji(m[2])
+ }
+ return this.respond(404, 'Not found')
+}
+
+Serve.prototype.home = function () {
+ pull(
+ pull.empty(),
+ this.wrapPage('/'),
+ this.respondSink(200, {
+ 'Content-Type': 'text/html'
+ })
+ )
+}
+
+Serve.prototype.public = function (ext) {
+ var q = this.query
+ var opts = {
+ reverse: !q.forwards,
+ lt: Number(q.lt) || Date.now(),
+ gt: Number(q.gt) || -Infinity,
+ limit: Number(q.limit) || 12
+ }
+
+ pull(
+ this.app.sbot.createLogStream(opts),
+ this.renderThreadPaginated(opts, null, q),
+ this.wrapMessages(),
+ this.wrapPublic(),
+ this.wrapPage('public'),
+ this.respondSink(200, {
+ 'Content-Type': ctype(ext)
+ })
+ )
+}
+
+Serve.prototype.private = function (ext) {
+ var q = this.query
+ var opts = {
+ reverse: !q.forwards,
+ lt: Number(q.lt) || Date.now(),
+ gt: Number(q.gt) || -Infinity,
+ }
+ var limit = Number(q.limit) || 12
+
+ pull(
+ this.app.sbot.createLogStream(opts),
+ pull.filter(isMsgEncrypted),
+ paramap(this.app.unboxMsg, 4),
+ pull.filter(isMsgReadable),
+ pull.take(limit),
+ this.renderThreadPaginated(opts, null, q),
+ this.wrapMessages(),
+ this.wrapPrivate(opts),
+ this.wrapPage('private'),
+ this.respondSink(200, {
+ 'Content-Type': ctype(ext)
+ })
+ )
+}
+
+Serve.prototype.search = function (ext) {
+ var searchQ = (this.query.q || '').trim()
+ var self = this
+
+ if (/^ssb:\/\//.test(searchQ)) {
+ var maybeId = searchQ.substr(6)
+ if (u.isRef(maybeId)) searchQ = maybeId
+ }
+
+ if (u.isRef(searchQ)) {
+ self.res.writeHead(302, {
+ Location: self.app.render.toUrl(searchQ)
+ })
+ return self.res.end()
+ }
+
+ pull(
+ self.app.search(searchQ),
+ self.renderThread(),
+ self.wrapMessages(),
+ self.wrapPage('search · ' + searchQ, searchQ),
+ self.respondSink(200, {
+ 'Content-Type': ctype(ext),
+ })
+ )
+}
+
+Serve.prototype.vote = function (ext) {
+ var self = this
+
+ var content = {
+ type: 'vote',
+ vote: {
+ link: self.data.link,
+ value: self.data.value,
+ expression: self.data.expression,
+ }
+ }
+ if (self.data.recps) content.recps = self.data.recps.split(',')
+ self.app.publish(content, function (err, msg) {
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.wrapPage(content.vote.expression),
+ self.respondSink(500, {
+ 'Content-Type': ctype(ext)
+ })
+ )
+
+ pull(
+ pull.once(msg),
+ pull.asyncMap(self.app.unboxMsg),
+ self.app.render.renderFeeds(false),
+ pull.map(u.toHTML),
+ self.wrapMessages(),
+ u.hyperwrap(function (content, cb) {
+ cb(null, h('div',
+ 'published:',
+ content
+ ))
+ }),
+ self.wrapPage('published'),
+ self.respondSink(302, {
+ 'Content-Type': ctype(ext),
+ 'Location': self.app.render.toUrl(msg.key)
+ })
+ )
+ })
+}
+
+Serve.prototype.rawId = function (id) {
+ var self = this
+ var etag = hash([id, appHash, 'raw'])
+ if (self.req.headers['if-none-match'] === etag) return self.respond(304)
+
+ self.app.getMsgDecrypted(id, function (err, msg) {
+ if (err) return pull(
+ pull.once(u.renderError(err).outerHTML),
+ self.respondSink(400, {'Content-Type': ctype('html')})
+ )
+ return pull(
+ pull.once(msg),
+ self.renderRawMsgPage(id),
+ self.respondSink(200, {
+ 'Content-Type': ctype('html'),
+ 'etag': etag
+ })
+ )
+ })
+}
+
+Serve.prototype.channel = function (channel) {
+ var q = this.query
+ var gt = Number(q.gt) || -Infinity
+ var lt = Number(q.lt) || Date.now()
+ var opts = {
+ reverse: !q.forwards,
+ lt: lt,
+ gt: gt,
+ limit: Number(q.limit) || 12,
+ query: [{$filter: {
+ value: {content: {channel: channel}},
+ timestamp: {
+ $gt: gt,
+ $lt: lt,
+ }
+ }}]
+ }
+
+ pull(
+ this.app.sbot.query.read(opts),
+ this.renderThreadPaginated(opts, null, q),
+ this.wrapMessages(),
+ this.wrapChannel(channel),
+ this.wrapPage('#' + channel),
+ this.respondSink(200, {
+ 'Content-Type': ctype('html')
+ })
+ )
+}
+
+function threadHeads(msgs, rootId) {
+ return sort.heads(msgs.filter(function (msg) {
+ return msg.value.content.root === rootId
+ || msg.key === rootId
+ }))
+}
+
+
+Serve.prototype.id = function (id, ext) {
+ var self = this
+ if (self.query.raw != null) return self.rawId(id)
+
+ this.app.getMsgDecrypted(id, function (err, rootMsg) {
+ var getRoot = err ? pull.error(err) : pull.once(rootMsg)
+ var recps = rootMsg && rootMsg.value.content.recps
+ var threadRootId = rootMsg && rootMsg.value.content.root || id
+ var channel = rootMsg && rootMsg.value.content.channel
+
+ pull(
+ cat([getRoot, self.app.sbot.links({dest: id, values: true})]),
+ pull.unique('key'),
+ paramap(self.app.unboxMsg, 4),
+ pull.collect(function (err, links) {
+ if (err) return self.respond(500, err.stack || err)
+ var etag = hash(sort.heads(links).concat(appHash, ext, qs))
+ if (self.req.headers['if-none-match'] === etag) return self.respond(304)
+ pull(
+ pull.values(sort(links)),
+ self.renderThread(),
+ self.wrapMessages(),
+ self.wrapThread({
+ recps: recps,
+ root: threadRootId,
+ branches: id === threadRootId ? threadHeads(links, id) : id,
+ channel: channel,
+ }),
+ self.wrapPage(id),
+ self.respondSink(200, {
+ 'Content-Type': ctype(ext),
+ 'etag': etag
+ })
+ )
+ })
+ )
+ })
+}
+
+Serve.prototype.userFeed = function (id, ext) {
+ var self = this
+ var q = self.query
+ var opts = {
+ id: id,
+ reverse: !q.forwards,
+ lt: Number(q.lt) || Date.now(),
+ gt: Number(q.gt) || -Infinity,
+ limit: Number(q.limit) || 20
+ }
+
+ self.app.getAbout(id, function (err, about) {
+ if (err) self.app.error(err)
+ pull(
+ self.app.sbot.createUserStream(opts),
+ self.renderThreadPaginated(opts, id, q),
+ self.wrapMessages(),
+ self.wrapUserFeed(id),
+ self.wrapPage(about.name),
+ self.respondSink(200, {
+ 'Content-Type': ctype(ext)
+ })
+ )
+ })
+}
+
+Serve.prototype.file = function (file) {
+ var self = this
+ fs.stat(file, function (err, stat) {
+ if (err && err.code === 'ENOENT') return self.respond(404, 'Not found')
+ if (err) return self.respond(500, err.stack || err)
+ if (!stat.isFile()) return self.respond(403, 'May only load files')
+ if (self.ifModified(stat.mtime)) return self.respond(304, 'Not modified')
+ self.res.writeHead(200, {
+ 'Content-Type': ctype(file),
+ 'Content-Length': stat.size,
+ 'Last-Modified': stat.mtime.toGMTString()
+ })
+ fs.createReadStream(file).pipe(self.res)
+ })
+}
+
+Serve.prototype.static = function (file) {
+ this.file(path.join(__dirname, '../static', file))
+}
+
+Serve.prototype.emoji = function (emoji) {
+ serveEmoji(this.req, this.res, emoji)
+}
+
+Serve.prototype.blob = function (id) {
+ var self = this
+ var blobs = self.app.sbot.blobs
+ if (self.req.headers['if-none-match'] === id) return self.respond(304)
+ blobs.has(id, function (err, has) {
+ if (err) {
+ if (/^invalid/.test(err.message)) return self.respond(400, err.message)
+ else return self.respond(500, err.message || err)
+ }
+ if (!has) return self.respond(404, 'Not found')
+ pull(
+ blobs.get(id),
+ pull.map(Buffer),
+ self.respondSink(200, {
+ 'Cache-Control': 'public, max-age=315360000',
+ 'etag': id
+ })
+ )
+ })
+}
+
+Serve.prototype.ifModified = function (lastMod) {
+ var ifModSince = this.req.headers['if-modified-since']
+ if (!ifModSince) return false
+ var d = new Date(ifModSince)
+ return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
+}
+
+Serve.prototype.wrapMessages = function () {
+ return u.hyperwrap(function (content, cb) {
+ cb(null, h('table.ssb-msgs', content))
+ })
+}
+
+Serve.prototype.renderThread = function () {
+ return pull(
+ this.app.render.renderFeeds(false),
+ pull.map(u.toHTML)
+ )
+}
+
+function mergeOpts(a, b) {
+ var obj = {}, k
+ for (k in a) {
+ obj[k] = a[k]
+ }
+ for (k in b) {
+ if (b[k] != null) obj[k] = b[k]
+ else delete obj[k]
+ }
+ return obj
+}
+
+Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
+ function link(opts, name, cb) {
+ cb(null, h('tr', h('td.paginate', {colspan: 2},
+ h('a', {href: '?' + qs.stringify(mergeOpts(q, opts))}, name))))
+ }
+ return pull(
+ paginate(
+ function onFirst(msg, cb) {
+ var num = feedId ? msg.value.sequence : msg.timestamp
+ if (q.forwards) {
+ link({
+ lt: num,
+ gt: null,
+ forwards: null,
+ }, '↓ older', cb)
+ } else {
+ link({
+ lt: null,
+ gt: num,
+ forwards: 1,
+ }, '↑ newer', cb)
+ }
+ },
+ this.app.render.renderFeeds(),
+ function onLast(msg, cb) {
+ var num = feedId ? msg.value.sequence : msg.timestamp
+ if (q.forwards) {
+ link({
+ lt: null,
+ gt: num,
+ forwards: 1,
+ }, '↑ newer', cb)
+ } else {
+ link({
+ lt: num,
+ gt: null,
+ forwards: null,
+ }, '↓ older', cb)
+ }
+ },
+ function onEmpty(cb) {
+ if (q.forwards) {
+ link({
+ gt: null,
+ lt: opts.gt + 1,
+ forwards: null,
+ }, '↓ older', cb)
+ } else {
+ link({
+ gt: opts.lt - 1,
+ lt: null,
+ forwards: 1,
+ }, '↑ newer', cb)
+ }
+ }
+ ),
+ pull.map(u.toHTML)
+ )
+}
+
+Serve.prototype.renderRawMsgPage = function (id) {
+ return pull(
+ this.app.render.renderFeeds(true),
+ pull.map(u.toHTML),
+ this.wrapMessages(),
+ this.wrapPage(id)
+ )
+}
+
+function catchHTMLError() {
+ return function (read) {
+ var ended
+ return function (abort, cb) {
+ if (ended) return cb(ended)
+ read(abort, function (end, data) {
+ if (!end || end === true) return cb(end, data)
+ ended = true
+ cb(null, u.renderError(end).outerHTML)
+ })
+ }
+ }
+}
+
+function styles() {
+ return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8')
+}
+
+Serve.prototype.appendFooter = function () {
+ var self = this
+ return function (read) {
+ return cat([read, u.readNext(function (cb) {
+ var ms = new Date() - self.startDate
+ cb(null, pull.once(h('footer',
+ h('a', {href: pkg.homepage}, pkg.name), ' · ',
+ ms/1000 + 's'
+ ).outerHTML))
+ })])
+ }
+}
+
+Serve.prototype.wrapPage = function (title, searchQ) {
+ var self = this
+ return pull(
+ catchHTMLError(),
+ self.appendFooter(),
+ u.hyperwrap(function (content, cb) {
+ var done = multicb({pluck: 1, spread: true})
+ done()(null, h('html', h('head',
+ h('meta', {charset: 'utf-8'}),
+ h('title', title),
+ h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}),
+ h('style', styles())
+ ),
+ h('body',
+ h('nav.nav-bar', h('form', {action: '/search', method: 'get'},
+ h('a', {href: '/public'}, 'public'), ' ',
+ h('a', {href: '/private'}, 'private') , ' ',
+ self.app.render.idLink(self.app.sbot.id, done()), ' ',
+ h('input.search-input', {name: 'q', value: searchQ,
+ placeholder: 'search', size: 16})
+ // h('a', {href: '/convos'}, 'convos'), ' ',
+ // h('a', {href: '/friends'}, 'friends'), ' ',
+ // h('a', {href: '/git'}, 'git')
+ )),
+ content
+ )))
+ done(cb)
+ })
+ )
+}
+
+Serve.prototype.wrapUserFeed = function (id) {
+ var self = this
+ return u.hyperwrap(function (thread, cb) {
+ var done = multicb({pluck: 1, spread: true})
+ done()(null, [
+ h('section.ssb-feed',
+ h('h3.feed-name',
+ self.app.render.avatarImage(id, done()), ' ',
+ h('strong', self.app.render.idLink(id, done()))
+ ),
+ h('code', h('small', id))
+ ),
+ thread
+ ])
+ done(cb)
+ })
+}
+
+Serve.prototype.wrapPublic = function (opts) {
+ var self = this
+ return u.hyperwrap(function (thread, cb) {
+ self.composer(null, function (err, composer) {
+ if (err) return cb(err)
+ cb(null, [
+ composer,
+ thread
+ ])
+ })
+ })
+}
+
+Serve.prototype.wrapPrivate = function (opts) {
+ var self = this
+ return u.hyperwrap(function (thread, cb) {
+ self.composer({
+ placeholder: 'private message',
+ useRecpsFromMentions: true,
+ }, function (err, composer) {
+ if (err) return cb(err)
+ cb(null, [
+ composer,
+ thread
+ ])
+ })
+ })
+}
+
+Serve.prototype.wrapThread = function (opts) {
+ var self = this
+ return u.hyperwrap(function (thread, cb) {
+ self.app.render.prepareLinks(opts.recps, function (err, recps) {
+ if (err) return cb(er)
+ self.composer({
+ placeholder: recps ? 'private reply' : 'reply',
+ id: 'reply',
+ root: opts.root,
+ channel: opts.channel,
+ branches: opts.branches,
+ recps: recps,
+ }, function (err, composer) {
+ if (err) return cb(err)
+ cb(null, [
+ thread,
+ composer
+ ])
+ })
+ })
+ })
+}
+
+Serve.prototype.wrapChannel = function (channel) {
+ var self = this
+ return u.hyperwrap(function (thread, cb) {
+ self.composer({
+ placeholder: 'public message in #' + channel,
+ channel: channel,
+ }, function (err, composer) {
+ if (err) return cb(err)
+ cb(null, [
+ h('section',
+ h('h3.feed-name',
+ h('a', {href: self.app.render.toUrl('#' + channel)}, '#' + channel)
+ )
+ ),
+ composer,
+ thread
+ ])
+ })
+ })
+}
+
+Serve.prototype.composer = function (opts, cb) {
+ var self = this
+ opts = opts || {}
+ var data = self.data
+
+ var done = multicb({pluck: 1, spread: true})
+ done()(null, h('section.composer',
+ h('form', {method: 'post', action: opts.id ? '#' + opts.id : ''},
+ opts.recps ? self.app.render.privateLine(opts.recps, done()) : '',
+ h('textarea', {
+ id: opts.id,
+ name: 'text',
+ rows: 4,
+ cols: 70,
+ placeholder: opts.placeholder || 'public message',
+ }, data.text || ''),
+ h('div.composer-actions',
+ h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ',
+ h('input', {type: 'submit', name: 'action', value: 'preview'})),
+ data.action === 'preview' ? preview(false, done()) :
+ data.action === 'raw' ? preview(true, done()) :
+ data.action === 'publish' ? publish(done()) : ''
+ )
+ ))
+ done(cb)
+
+ function preview(raw, cb) {
+ var myId = self.app.sbot.id
+ var content
+ try {
+ content = JSON.parse(data.text)
+ } catch (err) {
+ content = {
+ type: 'post',
+ text: data.text,
+ }
+ var mentions = ssbMentions(data.text)
+ if (mentions.length) content.mentions = mentions
+ if (opts.useRecpsFromMentions) {
+ content.recps = [myId].concat(mentions.filter(function (e) {
+ return e.link[0] === '@'
+ }))
+ if (opts.recps) return cb(new Error('got recps in opts and mentions'))
+ } else {
+ if (opts.recps) content.recps = opts.recps
+ }
+ if (opts.root) content.root = opts.root
+ if (opts.branches) content.branch = u.fromArray(opts.branches)
+ if (opts.channel) content.channel = opts.channel
+ }
+ var msg = {
+ value: {
+ author: myId,
+ timestamp: Date.now(),
+ content: content
+ }
+ }
+ if (content.recps) msg.value.private = true
+ var msgContainer = h('table.ssb-msgs')
+ pull(
+ pull.once(msg),
+ pull.asyncMap(self.app.unboxMsg),
+ self.app.render.renderFeeds(raw),
+ pull.drain(function (el) {
+ msgContainer.appendChild(el)
+ }, cb)
+ )
+ return h('form', {method: 'post', action: '#reply'},
+ h('input', {type: 'hidden', name: 'content',
+ value: JSON.stringify(content)}),
+ h('div', h('em', 'draft:')),
+ msgContainer,
+ h('div.composer-actions',
+ h('input', {type: 'submit', name: 'action', value: 'publish'})
+ )
+ )
+ }
+
+ function publish(cb) {
+ var content
+ try {
+ content = JSON.parse(self.data.content)
+ } catch(e) {
+ return cb(), u.renderError(e)
+ }
+ return self.app.render.publish(content, cb)
+ }
+
+}
diff --git a/lib/util.js b/lib/util.js
new file mode 100644
index 0000000..d6c3133
--- /dev/null
+++ b/lib/util.js
@@ -0,0 +1,80 @@
+var pull = require('pull-stream')
+var cat = require('pull-cat')
+var h = require('hyperscript')
+var u = exports
+
+u.ssbRefRegex = /((?:@|%|&)[A-Za-z0-9\/+]{43}=\.[\w\d]+)/g
+
+u.isRef = function (str) {
+ u.ssbRefRegex.lastIndex = 0
+ return u.ssbRefRegex.test(str)
+}
+
+u.readNext = function (fn) {
+ var next
+ return function (end, cb) {
+ if (next) return next(end, cb)
+ fn(function (err, _next) {
+ if (err) return cb(err)
+ next = _next
+ next(null, cb)
+ })
+ }
+}
+
+u.pullReverse = function () {
+ return function (read) {
+ return u.readNext(function (cb) {
+ pull(read, pull.collect(function (err, items) {
+ cb(err, items && pull.values(items.reverse()))
+ }))
+ })
+ }
+}
+
+u.toHTML = function (el) {
+ if (!el) return ''
+ if (typeof el === 'string' || Array.isArray(el)) {
+ return h('div', el).innerHTML
+ }
+ var html = el.outerHTML || String(el)
+ if (el.nodeName === 'html') html = '<!doctype html>' + html + '\n'
+ return html
+}
+
+u.hyperwrap = function (fn) {
+ var token = '__HYPERWRAP_' + Math.random() + '__'
+ return function (read) {
+ return u.readNext(function (cb) {
+ fn(token, function (err, el) {
+ if (err) return cb(err)
+ var parts = u.toHTML(el).split(token)
+ switch (parts.length) {
+ case 0: return cb(null, pull.empty())
+ case 1: return cb(null, pull.once(parts[0]))
+ case 2: return cb(null,
+ cat([pull.once(parts[0]), read, pull.once(parts[1])]))
+ default: return cb(new Error('duplicate wrap'))
+ }
+ })
+ })
+ }
+}
+
+u.linkDest = function (link) {
+ return typeof link === 'string' ? link : link && link.link || link
+}
+
+u.toArray = function (x) {
+ return !x ? [] : Array.isArray(x) ? x : [x]
+}
+
+u.fromArray = function (arr) {
+ return Array.isArray(arr) && arr.length === 1 ? arr[0] : arr
+}
+
+u.renderError = function(err) {
+ return h('div.error',
+ h('h3', err.name),
+ h('pre', err.stack))
+}