aboutsummaryrefslogtreecommitdiff
path: root/lib/serve.js
diff options
context:
space:
mode:
authorcel <cel@f/6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU=.ed25519>2017-01-30 18:24:49 -0800
committercel <cel@f/6sQ6d2CMxRUhLpspgGIulDxDCwYD7DzFzPNr7u5AU=.ed25519>2017-01-30 18:24:49 -0800
commit7a3b67c49b20c9063a696b8fb7dc00e541855693 (patch)
tree67cbb86265898fa7cc14ebb564c9a9f1ad872221 /lib/serve.js
downloadpatchfoo-7a3b67c49b20c9063a696b8fb7dc00e541855693.tar.gz
patchfoo-7a3b67c49b20c9063a696b8fb7dc00e541855693.zip
Init
Diffstat (limited to 'lib/serve.js')
-rw-r--r--lib/serve.js774
1 files changed, 774 insertions, 0 deletions
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)
+ }
+
+}