diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/about.js | 6 | ||||
-rw-r--r-- | lib/app.js | 314 | ||||
-rw-r--r-- | lib/git.js | 667 | ||||
-rw-r--r-- | lib/render-msg.js | 443 | ||||
-rw-r--r-- | lib/render.js | 211 | ||||
-rw-r--r-- | lib/serve.js | 1254 | ||||
-rw-r--r-- | lib/util.js | 109 |
7 files changed, 2795 insertions, 209 deletions
diff --git a/lib/about.js b/lib/about.js index e94b7fc..6d04355 100644 --- a/lib/about.js +++ b/lib/about.js @@ -20,15 +20,14 @@ About.prototype.createAboutOpStream = function (id) { && key !== 'type' && key !== 'recps' }).map(function (key) { - var value = u.linkDest(c[key]) - // if (u.isRef(value)) value = {link: value} + var value = c[key] return { id: msg.key, author: msg.value.author, timestamp: msg.value.timestamp, prop: key, value: value, - remove: value && typeof value === 'object' && value.remove, + remove: value && value.remove, } }) }), @@ -96,6 +95,7 @@ About.prototype.get = function (dest, cb) { if (!c) return var about = aboutByFeed[author] || (aboutByFeed[author] = {}) if (c.name) about.name = c.name + if (c.title) about.title = c.title if (c.image) about.image = u.linkDest(c.image) if (c.description) about.description = c.description }, function (err) { @@ -4,13 +4,16 @@ var lru = require('hashlru') var pkg = require('../package') var u = require('./util') var pull = require('pull-stream') -var hasher = require('pull-hash/ext/ssb') var multicb = require('multicb') var paramap = require('pull-paramap') var Contacts = require('ssb-contact') var About = require('./about') var Serve = require('./serve') var Render = require('./render') +var Git = require('./git') +var cat = require('pull-cat') +var proc = require('child_process') +var toPull = require('stream-to-pull-stream') var BoxStream = require('pull-box-stream') var crypto = require('crypto') @@ -24,7 +27,7 @@ function App(sbot, config) { var conf = config.patchfoo || {} this.port = conf.port || 8027 - this.host = conf.host || '::1' + this.host = conf.host || 'localhost' var base = conf.base || '/' this.opts = { @@ -32,6 +35,7 @@ function App(sbot, config) { blob_base: conf.blob_base || conf.img_base || base, img_base: conf.img_base || base, emoji_base: conf.emoji_base || (base + 'emoji/'), + encode_msgids: conf.encode_msgids == null ? true : Boolean(conf.encode_msgids), } sbot.get = memo({cache: lru(100)}, sbot.get) @@ -41,20 +45,29 @@ function App(sbot, config) { this._getAbout.bind(this)) this.unboxContent = memo({cache: lru(100)}, sbot.private.unbox) this.reverseNameCache = lru(500) + this.reverseEmojiNameCache = lru(500) + this.getBlobSize = memo({cache: this.blobSizeCache = lru(100)}, + sbot.blobs.size.bind(sbot.blobs)) this.unboxMsg = this.unboxMsg.bind(this) this.render = new Render(this, this.opts) + this.git = new Git(this) + + this.monitorBlobWants() } App.prototype.go = function () { var self = this - http.createServer(function (req, res) { + var server = http.createServer(function (req, res) { new Serve(self, req, res).go() - }).listen(self.port, self.host, function () { + }) + if (self.host === 'localhost') server.listen(self.port, onListening) + else server.listen(self.port, self.host, onListening) + function onListening() { var host = /:/.test(self.host) ? '[' + self.host + ']' : self.host self.log('Listening on http://' + host + ':' + self.port) - }) + } // invalidate cached About info when new About messages come in pull( @@ -62,9 +75,12 @@ App.prototype.go = function () { pull.drain(function (link) { self.aboutCache.remove(link.dest) }, function (err) { - if (err) self.error('about:', err) + if (err) throw err }) ) + + // keep alive ssb client connection + setInterval(self.sbot.whoami, 10e3) } var logPrefix = '[' + pkg.name + ']' @@ -172,14 +188,27 @@ App.prototype.publish = function (content, cb) { tryPublish(2) } +App.prototype.wantSizeBlob = function (id, cb) { + // only want() the blob if we don't already have it + var self = this + var blobs = this.sbot.blobs + blobs.size(id, function (err, size) { + if (size != null) return cb(null, size) + self.blobWants[id] = true + blobs.want(id, function (err) { + if (err) return cb(err) + blobs.size(id, cb) + }) + }) +} + App.prototype.addBlobRaw = function (cb) { var done = multicb({pluck: 1, spread: true}) var sink = pull( - hasher(done()), u.pullLength(done()), this.sbot.blobs.add(done()) ) - done(function (err, hash, size, _) { + done(function (err, size, hash) { if (err) return cb(err) cb(null, {link: hash, size: size}) }) @@ -228,11 +257,55 @@ App.prototype.pushBlob = function (id, cb) { this.sbot.blobs.push(id, cb) } +App.prototype.readBlob = function (link) { + link = u.toLink(link) + return this.sbot.blobs.get({ + hash: link.link, + size: link.size, + }) +} + +App.prototype.readBlobSlice = function (link, opts) { + if (this.sbot.blobs.getSlice) return this.sbot.blobs.getSlice({ + hash: link.link, + size: link.size, + start: opts.start, + end: opts.end, + }) + return pull( + this.readBlob(link), + u.pullSlice(opts.start, opts.end) + ) +} + +App.prototype.ensureHasBlobs = function (links, cb) { + var self = this + var done = multicb({pluck: 1}) + links.forEach(function (link) { + var cb = done() + self.sbot.blobs.size(link.link, function (err, size) { + if (err) cb(err) + else if (size == null) cb(null, link) + else cb() + }) + }) + done(function (err, missingLinks) { + if (err) console.trace(err) + missingLinks = missingLinks.filter(Boolean) + if (missingLinks.length == 0) return cb() + return cb({name: 'BlobNotFoundError', links: missingLinks}) + }) +} + App.prototype.getReverseNameSync = function (name) { var id = this.reverseNameCache.get(name) return id } +App.prototype.getReverseEmojiNameSync = function (name) { + return this.reverseEmojiNameCache.get(name) +} + App.prototype.getNameSync = function (name) { var about = this.aboutCache.get(name) return about && about.name @@ -267,10 +340,22 @@ App.prototype.pullGetMsg = function (id) { App.prototype.createLogStream = function (opts) { opts = opts || {} return opts.sortByTimestamp - ? this.sbot.createFeedStream(opts) + ? this.createFeedStream(opts) : this.sbot.createLogStream(opts) } +App.prototype.createFeedStream = function (opts) { + // work around opts.gt being treated as opts.gte sometimes + if (opts.gt && opts.limit && !opts.reverse) return pull( + this.sbot.createFeedStream(u.mergeOpts(opts, {limit: opts.limit + 1})), + pull.filter(function (msg) { + return msg && msg.value.timestamp !== opts.gt + }), + opts.limit && pull.take(opts.limit) + ) + return this.sbot.createFeedStream(opts) +} + var stateVals = { connected: 3, connecting: 2, @@ -372,6 +457,217 @@ App.prototype.createContactStreams = function (id) { return new Contacts(this.sbot).createContactStreams(id) } +function compareVoted(a, b) { + return b.value - a.value +} + +App.prototype.getVoted = function (_opts, cb) { + if (isNaN(_opts.limit)) return pull.error(new Error('missing limit')) + var self = this + var opts = { + type: 'vote', + limit: _opts.limit * 100, + reverse: !!_opts.reverse, + gt: _opts.gt || undefined, + lt: _opts.lt || undefined, + } + + var votedObj = {} + var votedArray = [] + var numItems = 0 + var firstTimestamp, lastTimestamp + pull( + self.sbot.messagesByType(opts), + self.unboxMessages(), + pull.take(function () { + return numItems < _opts.limit + }), + pull.drain(function (msg) { + if (!firstTimestamp) firstTimestamp = msg.timestamp + lastTimestamp = msg.timestamp + var vote = msg.value.content.vote + if (!vote) return + var target = u.linkDest(vote) + var votes = votedObj[target] + if (!votes) { + numItems++ + votes = {id: target, value: 0, feedsObj: {}, feeds: []} + votedObj[target] = votes + votedArray.push(votes) + } + if (msg.value.author in votes.feedsObj) { + if (!opts.reverse) return // leave latest vote value as-is + // remove old vote value + votes.value -= votes.feedsObj[msg.value.author] + } else { + votes.feeds.push(msg.value.author) + } + var value = vote.value > 0 ? 1 : vote.value < 0 ? -1 : 0 + votes.feedsObj[msg.value.author] = value + votes.value += value + }, function (err) { + if (err && err !== true) return cb(err) + var items = votedArray + if (opts.reverse) items.reverse() + items.sort(compareVoted) + cb(null, {items: items, + firstTimestamp: firstTimestamp, + lastTimestamp: lastTimestamp}) + }) + ) +} + App.prototype.createAboutStreams = function (id) { return this.about.createAboutStreams(id) } + +App.prototype.streamEmojis = function () { + return pull( + cat([ + this.sbot.links({ + rel: 'mentions', + source: this.sbot.id, + dest: '&', + values: true + }), + this.sbot.links({rel: 'mentions', dest: '&', values: true}) + ]), + this.unboxMessages(), + pull.map(function (msg) { return msg.value.content.mentions }), + pull.flatten(), + pull.filter('emoji'), + pull.unique('link') + ) +} + +App.prototype.filter = function (plugin, opts, filter) { + // work around flumeview-query not picking the best index. + // %b+QdyLFQ21UGYwvV3AiD8FEr7mKlB8w9xx3h8WzSUb0=.sha256 + var index + if (plugin === this.sbot.backlinks) { + var c = filter && filter.value && filter.value.content + var filteringByType = c && c.type + if (!filteringByType) index = 'DTS' + } + // work around flumeview-query not supporting $lt/$gt. + // %FCIv0D7JQyERznC18p8Dc1KtN6SLeJAl1sR5DAIr/Ek=.sha256 + return pull( + plugin.read({ + index: index, + reverse: opts.reverse, + limit: opts.limit && (opts.limit + 1), + query: [{$filter: u.mergeOpts(filter, { + timestamp: { + $gte: opts.gt, + $lte: opts.lt, + } + })}] + }), + pull.filter(function (msg) { + return msg && msg.timestamp !== opts.lt && msg.timestamp !== opts.gt + }), + opts.limit && pull.take(opts.limit) + ) +} + +App.prototype.streamChannel = function (opts) { + // prefer ssb-backlinks to ssb-query because it also handles hashtag mentions + if (this.sbot.backlinks) return this.filter(this.sbot.backlinks, opts, { + dest: '#' + opts.channel, + }) + + if (this.sbot.query) return this.filter(this.sbot.query, opts, { + value: {content: {channel: opts.channel}}, + }) + + return pull.error(new Error( + 'Viewing channels/tags requires the ssb-backlinks or ssb-query plugin')) +} + +App.prototype.streamMentions = function (opts) { + if (!this.sbot.backlinks) return pull.error(new Error( + 'Viewing mentions requires the ssb-backlinks plugin')) + + if (this.sbot.backlinks) return this.filter(this.sbot.backlinks, opts, { + dest: this.sbot.id, + }) +} + +App.prototype.streamPrivate = function (opts) { + if (this.sbot.private.read) return this.filter(this.sbot.private, opts, {}) + + return pull( + this.createLogStream(u.mergeOpts(opts, {limit: null})), + pull.filter(u.isMsgEncrypted), + this.unboxMessages(), + pull.filter(u.isMsgReadable), + pull.take(opts.limit) + ) +} + +App.prototype.blobMentions = function (opts) { + if (!this.sbot.links2) return pull.error(new Error( + 'missing ssb-links plugin')) + var filter = {rel: ['mentions', opts.name]} + if (opts.author) filter.source = opts.author + return this.sbot.links2.read({ + query: [ + {$filter: filter}, + {$filter: {dest: {$prefix: '&'}}}, + {$map: { + name: ['rel', 1], + size: ['rel', 2], + link: 'dest', + author: 'source', + time: 'ts' + }} + ] + }) +} + +App.prototype.monitorBlobWants = function () { + var self = this + self.blobWants = {} + pull( + this.sbot.blobs.createWants(), + pull.drain(function (wants) { + for (var id in wants) { + if (wants[id] < 0) self.blobWants[id] = true + else delete self.blobWants[id] + self.blobSizeCache.remove(id) + } + }, function (err) { + if (err) console.trace(err) + }) + ) +} + +App.prototype.getBlobState = function (id, cb) { + var self = this + if (self.blobWants[id]) return cb(null, 'wanted') + self.getBlobSize(id, function (err, size) { + if (err) return cb(err) + cb(null, size != null) + }) +} + +App.prototype.getNpmReadme = function (tarballId, cb) { + var self = this + // TODO: make this portable, and handle plaintext readmes + var tar = proc.spawn('tar', ['--ignore-case', '-Oxz', + 'package/README.md', 'package/readme.markdown', 'package/readme.mkd']) + var done = multicb({pluck: 1, spread: true}) + pull( + self.sbot.blobs.get(tarballId), + toPull.sink(tar.stdin, done()) + ) + pull( + toPull.source(tar.stdout), + pull.collect(done()) + ) + done(function (err, _, bufs) { + if (err) return cb(err) + var text = Buffer.concat(bufs).toString('utf8') + cb(null, text, true) + }) +} diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 0000000..cfcea9e --- /dev/null +++ b/lib/git.js @@ -0,0 +1,667 @@ +var pull = require('pull-stream') +var paramap = require('pull-paramap') +var lru = require('hashlru') +var memo = require('asyncmemo') +var u = require('./util') +var packidx = require('pull-git-packidx-parser') +var Reader = require('pull-reader') +var toPull = require('stream-to-pull-stream') +var zlib = require('zlib') +var looper = require('looper') +var multicb = require('multicb') +var kvdiff = require('pull-kvdiff') + +var ObjectNotFoundError = u.customError('ObjectNotFoundError') + +var types = { + blob: true, + commit: true, + tree: true, +} +var emptyBlobHash = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' + +module.exports = Git + +function Git(app) { + this.app = app + + this.findObject = memo({ + cache: lru(5), + asString: function (opts) { + return opts.obj + opts.headMsgId + } + }, this._findObject.bind(this)) + + this.findObjectInMsg = memo({ + cache: lru(5), + asString: function (opts) { + return opts.obj + opts.msg + } + }, this._findObjectInMsg.bind(this)) + + this.getPackIndex = memo({ + cache: lru(4), + asString: JSON.stringify + }, this._getPackIndex.bind(this)) +} + +// open, read, buffer and callback an object +Git.prototype.getObject = function (opts, cb) { + var self = this + self.openObject(opts, function (err, obj) { + if (err) return cb(err) + pull( + self.readObject(obj), + u.pullConcat(cb) + ) + }) +} + +// get a message that pushed an object +Git.prototype.getObjectMsg = function (opts, cb) { + this.findObject(opts, function (err, loc) { + if (err) return cb(err) + cb(null, loc.msg) + }) +} + +Git.prototype.openObject = function (opts, cb) { + var self = this + self.findObjectInMsg(opts, function (err, loc) { + if (err) return cb(err) + self.app.ensureHasBlobs([loc.packLink], function (err) { + if (err) return cb(err) + cb(null, { + type: opts.type, + length: opts.length, + offset: loc.offset, + next: loc.next, + packLink: loc.packLink, + idx: loc.idx, + msg: loc.msg, + }) + }) + }) +} + +Git.prototype.readObject = function (obj) { + if (obj.offset === obj.next) return pull.empty() + return pull( + this.app.readBlobSlice(obj.packLink, {start: obj.offset, end: obj.next}), + this.decodeObject({ + type: obj.type, + length: obj.length, + packLink: obj.packLink, + idx: obj.idx, + }) + ) +} + +// find which packfile contains a git object, and where in the packfile it is +// located +Git.prototype._findObject = function (opts, cb) { + if (!opts.headMsgId) return cb(new TypeError('missing head message id')) + if (!opts.obj) return cb(new TypeError('missing object id')) + var self = this + var objId = opts.obj + if (objId === emptyBlobHash) { + // special case: the empty blob may be found anywhere + self.app.getMsgDecrypted(opts.headMsgId, function (err, msg) { + if (err) return cb(err) + return cb(null, { + offset: 0, + next: 0, + packLink: null, + idx: null, + msg: msg, + }) + }) + } + self.findObjectMsgs(opts, function (err, msgs) { + if (err) return cb(err) + if (msgs.length === 0) + return cb(new ObjectNotFoundError('unable to find git object ' + objId)) + self.findObjectInMsgs(objId, msgs, cb) + }) +} + +Git.prototype._findObjectInMsg = function (opts, cb) { + if (!opts.msg) return cb(new TypeError('missing message id')) + if (!opts.obj) return cb(new TypeError('missing object id')) + var self = this + self.app.getMsgDecrypted(opts.msg, function (err, msg) { + if (err) return cb(err) + self.findObjectInMsgs(opts.obj, [msg], cb) + }) +} + +Git.prototype.findObjectInMsgs = function (objId, msgs, cb) { + var self = this + var objIdBuf = new Buffer(objId, 'hex') + // if blobs may need to be fetched, try to ask the user about as many of them + // at one time as possible + var packidxs = [].concat.apply([], msgs.map(function (msg) { + var c = msg.value.content + var idxs = u.toArray(c.indexes).map(u.toLink) + return u.toArray(c.packs).map(u.toLink).map(function (pack, i) { + var idx = idxs[i] + if (pack && idx) return { + msg: msg, + packLink: pack, + idxLink: idx, + } + }) + })).filter(Boolean) + var blobLinks = packidxs.length === 1 + ? [packidxs[0].idxLink, packidxs[0].packLink] + : packidxs.map(function (packidx) { + return packidx.idxLink + }) + self.app.ensureHasBlobs(blobLinks, function (err) { + if (err) return cb(err) + pull( + pull.values(packidxs), + paramap(function (pack, cb) { + self.getPackIndex(pack.idxLink, function (err, idx) { + if (err) return cb(err) + var offset = idx.find(objIdBuf) + if (!offset) return cb() + cb(null, { + offset: offset.offset, + next: offset.next, + packLink: pack.packLink, + idx: idx, + msg: pack.msg, + }) + }) + }, 4), + pull.filter(), + pull.take(1), + pull.collect(function (err, offsets) { + if (err) return cb(err) + if (offsets.length === 0) + return cb(new ObjectNotFoundError('unable to find git object ' + + objId + ' in ' + msgs.length + ' messages')) + cb(null, offsets[0]) + }) + ) + }) +} + +// given an object id and ssb msg id, get a set of messages of which at least one pushed the object. +Git.prototype.findObjectMsgs = function (opts, cb) { + var self = this + var id = opts.obj + var headMsgId = opts.headMsgId + var ended = false + var waiting = 0 + var maybeMsgs = [] + + function cbOnce(err, msgs) { + if (ended) return + ended = true + cb(err, msgs) + } + + function objectMatches(commit) { + return commit && (commit === id || commit.sha1 === id) + } + + if (!headMsgId) return cb(new TypeError('missing head message id')) + if (!u.isRef(headMsgId)) + return cb(new TypeError('bad head message id \'' + headMsgId + '\'')) + + ;(function getMsg(id) { + waiting++ + self.app.getMsgDecrypted(id, function (err, msg) { + waiting-- + if (ended) return + if (err && err.name == 'NotFoundError') + return cbOnce(new Error('missing message ' + headMsgId)) + if (err) return cbOnce(err) + var c = msg.value.content + if (typeof c === 'string') + return cbOnce(new Error('unable to decrypt message ' + msg.key)) + if ((u.toArray(c.object_ids).some(objectMatches)) + || (u.toArray(c.tags).some(objectMatches)) + || (u.toArray(c.commits).some(objectMatches))) { + // found the object + return cbOnce(null, [msg]) + } else if (!c.object_ids) { + // the object might be here + maybeMsgs.push(msg) + } + // traverse the DAG to keep looking for the object + u.toArray(c.repoBranch).filter(u.isRef).forEach(getMsg) + if (waiting === 0) { + cbOnce(null, maybeMsgs) + } + }) + })(headMsgId) +} + +Git.prototype._getPackIndex = function (idxBlobLink, cb) { + pull(this.app.readBlob(idxBlobLink), packidx(cb)) +} + +var objectTypes = [ + 'none', 'commit', 'tree', 'blob', + 'tag', 'unused', 'ofs-delta', 'ref-delta' +] + +function readTypedVarInt(reader, cb) { + var type, value, shift + reader.read(1, function (end, buf) { + if (ended = end) return cb(end) + var firstByte = buf[0] + type = objectTypes[(firstByte >> 4) & 7] + value = firstByte & 15 + shift = 4 + checkByte(firstByte) + }) + + function checkByte(byte) { + if (byte & 0x80) + reader.read(1, gotByte) + else + cb(null, type, value) + } + + function gotByte(end, buf) { + if (ended = end) return cb(end) + var byte = buf[0] + value += (byte & 0x7f) << shift + shift += 7 + checkByte(byte) + } +} + +function readVarInt(reader, cb) { + var value = 0, shift = 0 + reader.read(1, function gotByte(end, buf) { + if (ended = end) return cb(end) + var byte = buf[0] + value += (byte & 0x7f) << shift + shift += 7 + if (byte & 0x80) + reader.read(1, gotByte) + else + cb(null, value) + }) +} + +function inflate(read) { + return toPull(zlib.createInflate())(read) +} + +Git.prototype.decodeObject = function (opts) { + var self = this + var packLink = opts.packLink + return function (read) { + var reader = Reader() + reader(read) + return u.readNext(function (cb) { + readTypedVarInt(reader, function (end, type, length) { + if (end === true) cb(new Error('Missing object type')) + else if (end) cb(end) + else if (type === 'ref-delta') getObjectFromRefDelta(length, cb) + else if (opts.type && type !== opts.type) + cb(new Error('expected type \'' + opts.type + '\' ' + + 'but found \'' + type + '\'')) + else if (opts.length && length !== opts.length) + cb(new Error('expected length ' + opts.length + ' ' + + 'but found ' + length)) + else cb(null, inflate(reader.read())) + }) + }) + + function getObjectFromRefDelta(length, cb) { + reader.read(20, function (end, sourceHash) { + if (end) return cb(end) + var inflatedReader = Reader() + pull(reader.read(), inflate, inflatedReader) + readVarInt(inflatedReader, function (err, expectedSourceLength) { + if (err) return cb(err) + readVarInt(inflatedReader, function (err, expectedTargetLength) { + if (err) return cb(err) + var offset = opts.idx.find(sourceHash) + if (!offset) return cb(null, 'missing source object ' + + sourcehash.toString('hex')) + var readSource = pull( + self.app.readBlobSlice(opts.packLink, { + start: offset.offset, + end: offset.next + }), + self.decodeObject({ + type: opts.type, + length: expectedSourceLength, + packLink: opts.packLink, + idx: opts.idx + }) + ) + cb(null, patchObject(inflatedReader, length, readSource, expectedTargetLength)) + }) + }) + }) + } + } +} + +function readOffsetSize(cmd, reader, readCb) { + var offset = 0, size = 0 + + function addByte(bit, outPos, cb) { + if (cmd & (1 << bit)) + reader.read(1, function (err, buf) { + if (err) readCb(err) + else cb(buf[0] << (outPos << 3)) + }) + else + cb(0) + } + + addByte(0, 0, function (val) { + offset = val + addByte(1, 1, function (val) { + offset |= val + addByte(2, 2, function (val) { + offset |= val + addByte(3, 3, function (val) { + offset |= val + addSize() + }) + }) + }) + }) + function addSize() { + addByte(4, 0, function (val) { + size = val + addByte(5, 1, function (val) { + size |= val + addByte(6, 2, function (val) { + size |= val + readCb(null, offset, size || 0x10000) + }) + }) + }) + } +} + +function patchObject(deltaReader, deltaLength, readSource, targetLength) { + var srcBuf + var ended + + return u.readNext(function (cb) { + pull(readSource, u.pullConcat(function (err, buf) { + if (err) return cb(err) + srcBuf = buf + cb(null, read) + })) + }) + + function read(abort, cb) { + if (ended) return cb(ended) + deltaReader.read(1, function (end, dBuf) { + if (ended = end) return cb(end) + var cmd = dBuf[0] + if (cmd & 0x80) + // skip a variable amount and then pass through a variable amount + readOffsetSize(cmd, deltaReader, function (err, offset, size) { + if (err) return earlyEnd(err) + var buf = srcBuf.slice(offset, offset + size) + cb(end, buf) + }) + else if (cmd) + // insert `cmd` bytes from delta + deltaReader.read(cmd, cb) + else + cb(new Error("unexpected delta opcode 0")) + }) + + function earlyEnd(err) { + cb(err === true ? new Error('stream ended early') : err) + } + } +} + +var gitNameRegex = /^(.*) <(([^>@]*)(@[^>]*)?)> (.*) (.*)$/ +function parseName(line) { + var m = gitNameRegex.exec(line) + if (!m) return null + return { + name: m[1], + email: m[2], + localpart: m[3], + feed: u.isRef(m[4]) && m[4] || undefined, + date: new Date(m[5] * 1000), + tz: m[6], + } +} + +Git.prototype.getCommit = function (obj, cb) { + pull(this.readObject(obj), u.pullConcat(function (err, buf) { + if (err) return cb(err) + var commit = { + msg: obj.msg, + parents: [], + } + var authorLine, committerLine + var lines = buf.toString('utf8').split('\n') + for (var line; (line = lines.shift()); ) { + var parts = line.split(' ') + var prop = parts.shift() + var value = parts.join(' ') + switch (prop) { + case 'tree': + commit.tree = value + break + case 'parent': + commit.parents.push(value) + break + case 'author': + authorLine = value + break + case 'committer': + committerLine = value + break + case 'gpgsig': + var sigLines = [value] + while (lines[0] && lines[0][0] == ' ') + sigLines.push(lines.shift().slice(1)) + commit.gpgsig = sigLines.join('\n') + break + default: + return cb(new TypeError('unknown git object property ' + prop)) + } + } + commit.committer = parseName(committerLine) + if (authorLine !== committerLine) commit.author = parseName(authorLine) + commit.body = lines.join('\n') + cb(null, commit) + })) +} + +Git.prototype.getTag = function (obj, cb) { + pull(this.readObject(obj), u.pullConcat(function (err, buf) { + if (err) return cb(err) + var tag = { + msg: obj.msg, + } + var authorLine, tagterLine + var lines = buf.toString('utf8').split('\n') + for (var line; (line = lines.shift()); ) { + var parts = line.split(' ') + var prop = parts.shift() + var value = parts.join(' ') + switch (prop) { + case 'object': + tag.object = value + break + case 'type': + if (!types[value]) + return cb(new TypeError('unknown git object type ' + type)) + tag.type = value + break + case 'tag': + tag.tag = value + break + case 'tagger': + tag.tagger = parseName(value) + break + default: + return cb(new TypeError('unknown git object property ' + prop)) + } + } + tag.body = lines.join('\n') + cb(null, tag) + })) +} + +function readCString(reader, cb) { + var chars = [] + var loop = looper(function () { + reader.read(1, next) + }) + function next(err, ch) { + if (err) return cb(err) + if (ch[0] === 0) return cb(null, Buffer.concat(chars).toString('utf8')) + chars.push(ch) + loop() + } + loop() +} + +Git.prototype.readTree = function (obj) { + var self = this + var reader = Reader() + reader(this.readObject(obj)) + return function (abort, cb) { + if (abort) return reader.abort(abort, cb) + readCString(reader, function (err, str) { + if (err) return cb(err) + var parts = str.split(' ') + var mode = parseInt(parts[0], 8) + var name = parts.slice(1).join(' ') + reader.read(20, function (err, hash) { + if (err) return cb(err) + cb(null, { + name: name, + mode: mode, + hash: hash.toString('hex'), + type: mode === 0040000 ? 'tree' : + mode === 0160000 ? 'commit' : 'blob', + }) + }) + }) + } +} + +Git.prototype.readCommitChanges = function (commit) { + var self = this + return u.readNext(function (cb) { + var done = multicb({pluck: 1}) + commit.parents.forEach(function (rev) { + var cb = done() + self.getObjectMsg({ + obj: rev, + headMsgId: commit.msg.key, + type: 'commit', + }, function (err, msg) { + if (err) return cb(err) + self.openObject({ + obj: rev, + msg: msg.key, + }, function (err, obj) { + if (err) return cb(err) + self.getCommit(obj, cb) + }) + }) + }) + done()(null, commit) + done(function (err, commits) { + if (err) return cb(err) + var done = multicb({pluck: 1}) + commits.forEach(function (commit) { + var cb = done() + if (!commit.tree) return cb(null, pull.empty()) + self.getObjectMsg({ + obj: commit.tree, + headMsgId: commit.msg.key, + type: 'tree', + }, function (err, msg) { + if (err) return cb(err) + self.openObject({ + obj: commit.tree, + msg: commit.msg.key, + }, cb) + }) + }) + done(function (err, trees) { + if (err) return cb(err) + cb(null, self.diffTreesRecursive(trees)) + }) + }) + }) +} + +Git.prototype.diffTrees = function (objs) { + var self = this + return pull( + kvdiff(objs.map(function (obj) { + return self.readTree(obj) + }), 'name'), + pull.map(function (item) { + var diff = item.diff || {} + var head = item.values[item.values.length-1] + var created = true + for (var k = 0; k < item.values.length-1; k++) + if (item.values[k]) created = false + return { + name: item.key, + hash: item.values.map(function (val) { return val.hash }), + mode: diff.mode, + type: item.values.map(function (val) { return val.type }), + deleted: !head, + created: created + } + }) + ) +} + +Git.prototype.diffTreesRecursive = function (objs) { + var self = this + return pull( + self.diffTrees(objs), + paramap(function (item, cb) { + if (!item.type.some(function (t) { return t === 'tree' })) + return cb(null, [item]) + var done = multicb({pluck: 1}) + item.type.forEach(function (type, i) { + var cb = done() + if (type !== 'tree') return cb(null, pull.once(item)) + var hash = item.hash[i] + self.getObjectMsg({ + obj: hash, + headMsgId: objs[i].msg.key, + }, function (err, msg) { + if (err) return cb(err) + self.openObject({ + obj: hash, + msg: msg.key, + }, cb) + }) + }) + done(function (err, objs) { + if (err) return cb(err) + cb(null, pull( + self.diffTreesRecursive(objs), + pull.map(function (f) { + f.name = item.name + '/' + f.name + return f + }) + )) + }) + }, 4), + pull.flatten() + ) +} diff --git a/lib/render-msg.js b/lib/render-msg.js index c0001fc..9b69603 100644 --- a/lib/render-msg.js +++ b/lib/render-msg.js @@ -25,11 +25,7 @@ RenderMsg.prototype.toUrl = function (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 + return this.render.linkify(text) } function token() { @@ -151,8 +147,14 @@ RenderMsg.prototype.actions = function () { h('a', {href: '?gt=' + this.msg.timestamp}, '↓'), ' '] : '', this.c.type === 'gathering' ? [ h('a', {href: this.render.toUrl('/about/' + encodeURIComponent(this.msg.key))}, 'about'), ' '] : '', - h('a', {href: this.toUrl(this.msg.key) + '?raw'}, 'raw'), ' ', - this.voteFormInner('dig') + typeof this.c.text === 'string' ? [ + h('a', {href: this.toUrl(this.msg.key) + '?raw=md', + title: 'view markdown source'}, 'md'), ' '] : '', + h('a', {href: this.toUrl(this.msg.key) + '?raw', + title: 'view raw message'}, 'raw'), ' ', + this.buttonsCommon(), + this.c.type === 'gathering' ? [this.attendButton(), ' '] : '', + this.voteButton('dig') ) : [ this.msg.rel ? [this.msg.rel, ' '] : '' ] @@ -179,16 +181,29 @@ RenderMsg.prototype.recpsIds = function () { : [] } -RenderMsg.prototype.voteFormInner = function (expression) { +RenderMsg.prototype.buttonsCommon = function () { var chan = this.msg.value.content.channel + var recps = this.recpsIds() return [ - h('input', {type: 'hidden', name: 'action', value: 'vote'}), - h('input', {type: 'hidden', name: 'recps', - value: this.recpsIds().join(',')}), chan ? h('input', {type: 'hidden', name: 'channel', value: chan}) : '', 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: expression})] + h('input', {type: 'hidden', name: 'recps', value: recps.join(',')}) + ] +} + +RenderMsg.prototype.voteButton = function (expression) { + var chan = this.msg.value.content.channel + return [ + h('input', {type: 'hidden', name: 'vote_value', value: 1}), + h('input', {type: 'hidden', name: 'vote_expression', value: expression}), + h('input', {type: 'submit', name: 'action_vote', value: expression})] +} + +RenderMsg.prototype.attendButton = function () { + var chan = this.msg.value.content.channel + return [ + h('input', {type: 'submit', name: 'action_attend', value: 'attend'}) + ] } RenderMsg.prototype.message = function (cb) { @@ -223,9 +238,23 @@ RenderMsg.prototype.message = function (cb) { case 'ferment/update': case 'robeson/update': return this.update(cb) + case 'chess_invite': + case 'ssb_chess_invite': + return this.chessInvite(cb) + case 'chess_invite_accept': + case 'ssb_chess_invite_accept': + return this.chessInviteAccept(cb) + case 'chess_move': + case 'ssb_chess_move': + return this.chessMove(cb) + case 'chess_game_end': + case 'ssb_chess_game_end': + return this.chessGameEnd(cb) case 'wifi-network': return this.wifiNetwork(cb) case 'mutual/credit': return this.mutualCredit(cb) case 'mutual/account': return this.mutualAccount(cb) + case 'npm-publish': return this.npmPublish(cb) + case 'npm-packages': return this.npmPackages(cb) default: return this.object(cb) } } @@ -235,25 +264,33 @@ RenderMsg.prototype.encrypted = function (cb) { } RenderMsg.prototype.markdown = function (cb) { + if (this.opts.markdownSource) + return this.markdownSource(this.c.text, this.c.mentions) return this.render.markdown(this.c.text, this.c.mentions) } +RenderMsg.prototype.markdownSource = function (text, mentions) { + return h('div', + h('pre', String(text)), + mentions ? [ + h('div', h('em', 'mentions:')), + this.valueTable(mentions, function () {}) + ] : '' + ).innerHTML +} + RenderMsg.prototype.post = function (cb) { var self = this var done = multicb({pluck: 1, spread: true}) - var branchDone = multicb({pluck: 1}) - u.toArray(self.c.branch).forEach(function (branch) { - self.link(branch, branchDone()) - }) if (self.c.root === self.c.branch) done()() else self.link(self.c.root, done()) - branchDone(done()) + self.links(self.c.branch, done()) done(function (err, rootLink, branchLinks) { if (err) return self.wrap(u.renderError(err), cb) self.wrap(h('div.ssb-post', - rootLink ? h('div', h('small', '>> ', rootLink)) : '', + rootLink ? h('div', h('small', h('span.symbol', '→'), ' ', rootLink)) : '', branchLinks.map(function (a, i) { - return h('div', h('small', '> ', a)) + return h('div', h('small', h('span.symbol', ' ↳'), ' ', a)) }), h('div.ssb-post-text', {innerHTML: self.markdown()}) ), cb) @@ -320,6 +357,8 @@ RenderMsg.prototype.title = function (cb) { } else { if (self.c.type === 'ssb-dns') cb(null, self.c.record && JSON.stringify(self.c.record.data) || self.msg.key) + else if (self.c.type === 'npm-publish') + self.npmPublishTitle(cb) else self.app.getAbout(self.msg.key, function (err, about) { if (err) return cb(err) @@ -362,17 +401,38 @@ RenderMsg.prototype.link1 = function (link, cb) { return a } +RenderMsg.prototype.links = function (links, cb) { + var self = this + var done = multicb({pluck: 1}) + u.toArray(links).forEach(function (link) { + self.link(link, done()) + }) + done(cb) +} + function dateTime(d) { var date = new Date(d.epoch) - return date.toUTCString() + return date.toString() // d.bias // d.epoch } RenderMsg.prototype.about = function (cb) { - var img = u.linkDest(this.c.image) var done = multicb({pluck: 1, spread: true}) var elCb = done() + + var isAttendingMsg = u.linkDest(this.c.attendee) === this.msg.value.author + && Object.keys(this.c).sort().join() === 'about,attendee,type' + if (isAttendingMsg) { + var attending = !this.c.attendee.remove + this.wrapMini([ + attending ? ' is attending' : ' is not attending', ' ', + this.link1(this.c.about, done()) + ], elCb) + return done(cb) + } + + var img = u.linkDest(this.c.image) // if there is a description, it is likely to be multi-line var hasDescription = this.c.description != null var wrap = hasDescription ? this.wrap : this.wrapMini @@ -430,6 +490,7 @@ RenderMsg.prototype.contact = function (cb) { ' from ', h('code', self.c.note) ] : '', + self.c.reason ? [' because ', h('q', self.c.reason)] : '' ], cb) }) } @@ -475,50 +536,58 @@ RenderMsg.prototype.gitUpdate = function (cb) { var size = [].concat(self.c.packs, self.c.indexes) .map(function (o) { return o && o.size }) .reduce(function (total, s) { return total + s }) - self.link(self.c.repo, function (err, a) { + + var done = multicb({pluck: 1, spread: true}) + self.link(self.c.repo, done()) + self.render.npmPackageMentions(self.c.mentions, done()) + done(function (err, a, pkgMentionsEl) { if (err) return cb(err) self.wrap(h('div.ssb-git-update', 'git push ', a, ' ', !isNaN(size) ? [self.render.formatSize(size), ' '] : '', 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')) + var type = /^refs\/tags/.test(ref) ? 'tag' : 'commit' + var path = id && ('/git/' + type + '/' + encodeURIComponent(id) + + '?msg=' + encodeURIComponent(self.msg.key)) + return h('li', + ref.replace(/^refs\/(heads|tags)\//, ''), ': ', + id ? h('a', {href: self.render.toUrl(path)}, 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)), ' ', + var path = '/git/commit/' + encodeURIComponent(commit.sha1) + + '?msg=' + encodeURIComponent(self.msg.key) + return h('li', h('a', {href: self.render.toUrl(path)}, + h('code', String(commit.sha1).substr(0, 8))), ' ', self.linkify(String(commit.title)), - self.gitCommitBody(commit.body) + self.render.gitCommitBody(commit.body) ) })) : '', Array.isArray(self.c.tags) ? h('ul', self.c.tags.map(function (tag) { + var path = '/git/tag/' + encodeURIComponent(tag.sha1) + + '?msg=' + encodeURIComponent(self.msg.key) return h('li', - h('code', String(tag.sha1).substr(0, 8)), ' ', + h('a', {href: self.render.toUrl(path)}, + h('code', String(tag.sha1).substr(0, 8))), ' ', 'tagged ', String(tag.type), ' ', h('code', String(tag.object).substr(0, 8)), ' ', - String(tag.tag) + String(tag.tag), + tag.title ? [': ', self.linkify(String(tag.title).trim()), ' '] : '', + tag.body ? self.render.gitCommitBody(tag.body) : '' ) })) : '', self.c.commits_more ? h('div', '+ ' + self.c.commits_more + ' more commits') : '', self.c.tags_more ? h('div', - '+ ' + self.c.tags_more + ' more tags') : '' + '+ ' + self.c.tags_more + ' more tags') : '', + pkgMentionsEl ), cb) }) } -RenderMsg.prototype.gitCommitBody = function (body) { - if (!body) return '' - var isMarkdown = !/^# Conflicts:$/m.test(body) - return isMarkdown - ? h('div', {innerHTML: this.render.markdown('\n' + body)}) - : h('pre', this.linkify('\n' + body)) -} - RenderMsg.prototype.gitPullRequest = function (cb) { var self = this var done = multicb({pluck: 1, spread: true}) @@ -800,3 +869,297 @@ RenderMsg.prototype.micro = function (cb) { var el = h('span', {innerHTML: unwrapP(this.markdown())}) this.wrapMini(el, cb) } + +function hJoin(els, seperator) { + return els.map(function (el, i) { + return [i === 0 ? '' : separator, el] + }) +} + +function asNpmReadme(readme) { + if (!readme || readme === 'ERROR: No README data found!') return + return u.ifString(readme) +} + +function singleValue(obj) { + if (!obj || typeof obj !== 'object') return obj + var keys = Object.keys(obj) + if (keys.length === 1) return obj[keys[0]] +} + +function ifDifferent(obj, value) { + if (singleValue(obj) !== value) return obj +} + +RenderMsg.prototype.npmPublish = function (cb) { + var self = this + var render = self.render + var pkg = self.c.meta || {} + var pkgReadme = asNpmReadme(pkg.readme) + var pkgDescription = u.ifString(pkg.description) + + var versions = Object.keys(pkg.versions || {}) + var singleVersion = versions.length === 1 ? versions[0] : null + var singleRelease = singleVersion && pkg.versions[singleVersion] + var singleReadme = singleRelease && asNpmReadme(singleRelease.readme) + + var distTags = pkg['dist-tags'] || {} + var distTagged = {} + for (var distTag in distTags) + if (distTag !== 'latest') + distTagged[distTags[distTag]] = distTag + + self.links(self.c.previousPublish, function (err, prevLinks) { + if (err) return cb(err) + self.wrap([ + h('div', + 'published ', + h('u', pkg.name), ' ', + hJoin(versions.map(function (version) { + var distTag = distTagged[version] + return [h('b', version), distTag ? [' (', h('i', distTag), ')'] : ''] + }), ', ') + ), + pkgDescription ? h('div', + // TODO: make mdInline use custom emojis + h('q', {innerHTML: unwrapP(render.markdown(pkgDescription))})) : '', + prevLinks.length ? h('div', 'previous: ', prevLinks) : '', + pkgReadme && pkgReadme !== singleReadme ? + h('blockquote', {innerHTML: render.markdown(pkgReadme)}) : '', + versions.map(function (version, i) { + var release = pkg.versions[version] || {} + var license = u.ifString(release.license) + var author = ifDifferent(release.author, self.msg.value.author) + var description = u.ifString(release.description) + var readme = asNpmReadme(release.readme) + var keywords = u.toArray(release.keywords).map(u.ifString) + var dist = release.dist || {} + var size = u.ifNumber(dist.size) + return [ + h > 0 ? h('br') : '', + version !== singleVersion ? h('div', 'version: ', version) : '', + author ? h('div', 'author: ', render.npmAuthorLink(author)) : '', + license ? h('div', 'license: ', h('code', license)) : '', + keywords.length ? h('div', 'keywords: ', keywords.join(', ')) : '', + size ? h('div', 'size: ', render.formatSize(size)) : '', + description && description !== pkgDescription ? + h('div', h('q', {innerHTML: render.markdown(description)})) : '', + readme ? h('blockquote', {innerHTML: render.markdown(readme)}) : '' + ] + }) + ], cb) + }) +} + +RenderMsg.prototype.npmPackages = function (cb) { + var self = this + self.render.npmPackageMentions(self.c.mentions, function (err, el) { + if (err) return cb(err) + self.wrap(el, cb) + }) +} + +RenderMsg.prototype.npmPublishTitle = function (cb) { + var pkg = this.c.meta || {} + var name = pkg.name || pkg._id || '?' + + var taggedVersions = {} + for (var version in pkg.versions || {}) + taggedVersions[version] = [] + + var distTags = pkg['dist-tags'] || {} + for (var distTag in distTags) { + if (distTag === 'latest') continue + var version = distTags[distTag] || '?' + var tags = taggedVersions[version] || (taggedVersions[version] = []) + tags.push(distTag) + } + + cb(null, name + '@' + Object.keys(taggedVersions).map(function (version) { + var tags = taggedVersions[version] + return (tags.length ? tags.join(',') + ':' : '') + version + }).join(',')) +} + +function expandDigitToSpaces(n) { + return ' '.substr(-n) +} + +function parseFenRank (line) { + return line.replace(/\d/g, expandDigitToSpaces).split('') +} + +function parseChess(fen) { + var fields = String(fen).split(/\s+/) + var ranks = fields[0].split('/') + var f2 = fields[2] || '' + return { + board: ranks.map(parseFenRank), + /* + nextMove: fields[1] === 'b' ? 'black' + : fields[1] === 'w' ? 'white' : 'unknown', + castling: f2 === '-' ? {} : { + w: { + k: 0 < f2.indexOf('K'), + q: 0 < f2.indexOf('Q'), + }, + b: { + k: 0 < f2.indexOf('k'), + q: 0 < f2.indexOf('q'), + } + }, + enpassantTarget: fields[3] === '-' ? null : fields[3], + halfmoves: Number(fields[4]), + fullmoves: Number(fields[5]), + */ + } +} + +var chessSymbols = { + ' ': [' ', ''], + P: ['♙', 'white pawn'], + N: ['♘', 'white knight'], + B: ['♗', 'white bishop'], + R: ['♖', 'white rook'], + Q: ['♕', 'white queen'], + K: ['♔', 'white king'], + p: ['♟', 'black pawn'], + n: ['♞', 'black knight'], + b: ['♝', 'black bishop'], + r: ['♜', 'black rook'], + q: ['♛', 'black queen'], + k: ['♚', 'black king'], +} + +function renderChessSymbol(c, loc) { + var info = chessSymbols[c] || ['?', 'unknown'] + return h('span.symbol', { + title: info[1] + (loc ? ' at ' + loc : '') + }, info[0]) +} + +function chessLocToIdxs(loc) { + var m = /^([a-h])([1-8])$/.exec(loc) + if (m) return [8 - m[2], m[1].charCodeAt(0) - 97] +} + +function lookupPiece(board, loc) { + var idxs = chessLocToIdxs(loc) + return idxs && board[idxs[0]] && board[idxs[0]][idxs[1]] +} + +function chessIdxsToLoc(i, j) { + return 'abcdefgh'[j] + (8-i) +} + +RenderMsg.prototype.chessBoard = function (board) { + if (!board) return '' + return h('table.chess-board', + board.map(function (rank, i) { + return h('tr', rank.map(function (piece, j) { + var dark = (i ^ j) & 1 + return h('td', { + class: 'chess-square chess-square-' + (dark ? 'dark' : 'light'), + }, renderChessSymbol(piece, chessIdxsToLoc(i, j))) + })) + }) + ) +} + +RenderMsg.prototype.chessMove = function (cb) { + var self = this + var c = self.c + var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen + var game = parseChess(fen) + var piece = game && lookupPiece(game.board, c.dest) + self.link(self.c.root, function (err, rootLink) { + if (err) return cb(err) + self.wrap([ + h('div', h('small', '> ', rootLink)), + h('p', + // 'player ', (c.ply || ''), ' ', + 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ', + 'from ', c.orig, ' ', + 'to ', c.dest + ), + self.chessBoard(game.board) + ], cb) + }) +} + +RenderMsg.prototype.chessInvite = function (cb) { + var self = this + var myColor = self.c.myColor + self.link(self.c.inviting, function (err, link) { + if (err) return cb(err) + self.wrap([ + 'invites ', link, ' to play chess', + // myColor ? h('p', 'my color is ' + myColor) : '' + ], cb) + }) +} + +RenderMsg.prototype.chessInviteAccept = function (cb) { + var self = this + self.link(self.c.root, function (err, rootLink) { + if (err) return cb(err) + self.wrap([ + h('div', h('small', '> ', rootLink)), + h('p', 'accepts invitation to play chess') + ], cb) + }) +} + +RenderMsg.prototype.chessGameEnd = function (cb) { + var self = this + var c = self.c + if (c.status === 'resigned') return self.link(self.c.root, function (err, rootLink) { + if (err) return cb(err) + self.wrap([ + h('div', h('small', '> ', rootLink)), + h('p', h('strong', 'resigned')) + ], cb) + }) + + var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen + var game = parseChess(fen) + var piece = game && lookupPiece(game.board, c.dest) + var done = multicb({pluck: 1, spread: true}) + self.link(self.c.root, done()) + self.link(self.c.winner, done()) + done(function (err, rootLink, winnerLink) { + if (err) return cb(err) + self.wrap([ + h('div', h('small', '> ', rootLink)), + h('p', + 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ', + 'from ', c.orig, ' ', + 'to ', c.dest + ), + h('p', + h('strong', self.c.status), '. winner: ', h('strong', winnerLink)), + self.chessBoard(game.board) + ], cb) + }) +} + +RenderMsg.prototype.chessMove = function (cb) { + var self = this + var c = self.c + var fen = c.fen && c.fen.length === 2 ? c.pgnMove : c.fen + var game = parseChess(fen) + var piece = game && lookupPiece(game.board, c.dest) + self.link(self.c.root, function (err, rootLink) { + if (err) return cb(err) + self.wrap([ + h('div', h('small', '> ', rootLink)), + h('p', + // 'player ', (c.ply || ''), ' ', + 'moved ', (piece ? renderChessSymbol(piece) : ''), ' ', + 'from ', c.orig, ' ', + 'to ', c.dest + ), + self.chessBoard(game.board) + ], cb) + }) +} diff --git a/lib/render.js b/lib/render.js index 3b4a142..cc08259 100644 --- a/lib/render.js +++ b/lib/render.js @@ -10,6 +10,7 @@ var qs = require('querystring') var u = require('./util') var multicb = require('multicb') var RenderMsg = require('./render-msg') +var Highlight = require('highlight.js') module.exports = Render @@ -83,18 +84,33 @@ function Render(app, opts) { smartypants: false, emoji: lexerRenderEmoji, renderer: new MdRenderer(this), + highlight: this.highlight.bind(this), } } Render.prototype.emoji = function (emoji) { var name = ':' + emoji + ':' - return emoji in emojis ? - h('img.ssb-emoji', { + var link = this._mentions && this._mentions[name] + if (link && link.link) { + this.app.reverseEmojiNameCache.set(emoji, link.link) + return h('img.ssb-emoji', { + src: this.opts.img_base + link.link, + alt: name + + (link.size != null ? ' (' + this.formatSize(link.size) + ')' : ''), + height: 17, + title: name, + }) + } + if (emoji in emojis) { + return h('img.ssb-emoji', { src: this.opts.emoji_base + emoji + '.png', alt: name, height: 17, + align: 'absmiddle', title: name, - }) : name + }) + } + return name } /* disabled until it can be done safely without breaking html @@ -113,12 +129,14 @@ Render.prototype.markdown = function (text, mentions) { var mentionsByLink = this._mentionsByLink = {} if (Array.isArray(mentions)) mentions.forEach(function (link) { if (!link) return + else if (link.emoji) + mentionsObj[':' + link.name + ':'] = link else if (link.name) mentionsObj['@' + link.name] = link.link else if (link.host === 'http://localhost:7777') mentionsObj[link.href] = link.link if (link.link) - mentionsByLink[link.link] = link + mentionsByLink[link.link + (link.key ? '#' + link.key : '')] = link }) var out = marked(String(text), this.markedOpts) delete this._mentions @@ -130,7 +148,7 @@ Render.prototype.imageUrl = function (ref) { var m = /^blobstore:(.*)/.exec(ref) if (m) ref = m[1] ref = ref.replace(/#/, '%23') - return this.opts.img_base + ref + return this.opts.img_base + 'image/' + ref } Render.prototype.getImageAlt = function (id, fallback) { @@ -149,6 +167,22 @@ Render.prototype.formatSize = function (size) { return size.toFixed(2) + ' MB' } +Render.prototype.linkify = function (text) { + var arr = text.split(u.ssbRefEncRegex) + for (var i = 1; i < arr.length; i += 2) { + arr[i] = h('a', {href: this.toUrlEnc(arr[i])}, arr[i]) + } + return arr +} + +Render.prototype.toUrlEnc = function (href) { + var url = this.toUrl(href) + if (url) return url + try { href = decodeURIComponent(href) } + catch (e) { return false } + return this.toUrl(href) +} + Render.prototype.toUrl = function (href) { if (!href) return href var mentions = this._mentions @@ -157,7 +191,8 @@ Render.prototype.toUrl = function (href) { switch (href[0]) { case '%': if (!u.isRef(href)) return false - return this.opts.base + encodeURIComponent(href) + return this.opts.base + + (this.opts.encode_msgids ? encodeURIComponent(href) : href) case '@': if (!u.isRef(href)) return false return this.opts.base + href @@ -280,3 +315,167 @@ Render.prototype.renderFeeds = function (opts) { self.renderMsg(msg, opts, cb) }, 4) } + +Render.prototype.gitCommitBody = function (body) { + if (!body) return '' + var isMarkdown = !/^# Conflicts:$/m.test(body) + return isMarkdown + ? h('div', {innerHTML: this.markdown('\n' + body)}) + : h('pre', this.linkify('\n' + body)) +} + +Render.prototype.getName = function (id, cb) { + // TODO: consolidate the get name/link functions + var self = this + switch (id && id[0]) { + case '%': + return self.app.getMsgDecrypted(id, function (err, msg) { + if (err && err.name == 'NotFoundError') + return cb(null, String(id).substring(0, 8) + '…(missing)') + if (err) return fallback() + new RenderMsg(self, self.app, msg, {wrap: false}).title(cb) + }) + case '@': // fallthrough + case '&': + return self.app.getAbout(id, function (err, about) { + if (err || !about || !about.name) return fallback() + cb(null, about.name) + }) + default: + return cb(null, String(id)) + } + function fallback() { + cb(null, String(id).substr(0, 8) + '…') + } +} + +Render.prototype.getNameLink = function (id, cb) { + var self = this + self.getName(id, function (err, name) { + if (err) return cb(err) + cb(null, h('a', {href: self.toUrl(id)}, name)) + }) +} + +Render.prototype.npmAuthorLink = function (author) { + if (!author) return + var url = u.ifString(author.url) + var email = u.ifString(author.email) + var name = u.ifString(author.name) + var title + if (!url && u.isRef(name)) url = name, name = null + if (!url && !email) return name || JSON.stringify(author) + if (!url && email) url = 'mailto:' + email, email = null + if (!name && email) name = email, email = null + var feed = u.isRef(url) && url[0] === '@' && url + if (feed && name) title = this.app.getNameSync(feed) + if (feed && name && name[0] != '@') name = '@' + name + if (feed && !name) name = this.app.getNameSync(feed) // TODO: async + if (url && !name) name = url + var secondaryLink = email && h('a', {href: this.toUrl('mailto:' + email)}, email) + return [ + h('a', {href: this.toUrl(url), title: title}, name), + secondaryLink ? [' (', secondaryLink, ')'] : '' + ] +} + +// auto-highlight is slow +var useAutoHighlight = false + +Render.prototype.highlight = function (code, lang) { + if (code.length > 100000) return u.escapeHTML(code) + if (!lang && /^#!\/bin\/[^\/]*sh$/m.test(code)) lang = 'sh' + try { + return lang + ? Highlight.highlight(lang, code).value + : useAutoHighlight + ? Highlight.highlightAuto(code).value + : u.escapeHTML(code) + } catch(e) { + if (!/^Unknown language/.test(e.message)) console.trace(e) + return u.escapeHTML(code) + } +} + +Render.prototype.npmPackageMentions = function (links, cb) { + var self = this + var pkgLinks = u.toArray(links).filter(function (link) { + return /^npm:/.test(link.name) + }) + if (pkgLinks.length === 0) return cb(null, '') + var done = multicb({pluck: 1}) + pkgLinks.forEach(function (link) { + self.npmPackageMention(link, {}, done()) + }) + done(function (err, mentionEls) { + cb(null, h('table', + h('thead', h('tr', + h('td', 'package'), + h('td', 'version'), + h('td', 'tag'), + h('td', 'size'), + h('td', 'tarball'), + h('td', 'readme') + )), + h('tbody', mentionEls) + )) + }) +} + +Render.prototype.npmPackageMention = function (link, opts, cb) { + var parts = String(link.name).replace(/\.tgz$/, '').split(':') + var name = parts[1] + var version = parts[2] + var distTag = parts[3] + var self = this + var done = multicb({pluck: 1, spread: true}) + var base = '/npm/' + (opts.author ? u.escapeId(link.author) + '/' : '') + var pathWithAuthor = opts.withAuthor ? '/npm/' + + u.escapeId(link.author) + + (opts.name ? '/' + opts.name + + (opts.version ? '/' + opts.version + + (opts.distTag ? '/' + opts.distTag + '/' : '') : '') : '') : '' + self.app.getAbout(link.author, done()) + self.app.getBlobState(link.link, done()) + done(function (err, about, blobState) { + if (err) return cb(err) + cb(null, h('tr', [ + opts.withAuthor ? h('td', h('a', { + href: self.toUrl(pathWithAuthor), + title: 'publisher' + }, about.name), ' ') : '', + h('td', h('a', { + href: self.toUrl(base + name), + title: 'package name' + }, name), ' '), + h('td', version ? [h('a', { + href: self.toUrl(base + name + '/' + version), + title: 'package version' + }, version), ' '] : ''), + h('td', distTag ? [h('a', { + href: self.toUrl(base + name + '//' + distTag), + title: 'dist-tag' + }, distTag), ' '] : ''), + h('td', {align: 'right'}, link.size != null ? [h('span', { + title: 'tarball size' + }, self.formatSize(link.size)), ' '] : ''), + h('td', typeof link.link === 'string' ? h('code', h('a', { + href: self.toUrl('/links/' + link.link), + title: 'package tarball' + }, link.link.substr(0, 8) + '…')) : ''), + h('td', + blobState === 'wanted' ? + 'fetching...' + : blobState ? h('a', { + href: self.toUrl('/npm-readme/' + encodeURIComponent(link.link)), + title: 'package contents' + }, 'readme') + : h('form', {action: '', method: 'post'}, + h('input', {type: 'hidden', name: 'action', value: 'want-blobs'}), + h('input', {type: 'hidden', name: 'async_want', value: '1'}), + h('input', {type: 'hidden', name: 'blob_ids', value: link.link}), + h('input', {type: 'submit', value: 'fetch'}) + )) + ])) + }) +} diff --git a/lib/serve.js b/lib/serve.js index 1ce18c6..839725a 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -19,10 +19,13 @@ var mime = require('mime-types') var ident = require('pull-identify-filetype') var htime = require('human-time') var ph = require('pull-hyperscript') +var emojis = require('emoji-named-characters') +var jpeg = require('jpeg-autorotate') module.exports = Serve var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') +var hlCssDir = path.join(require.resolve('highlight.js'), '../../styles') var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))([^?]*)?|(\/.*?))(?:\?(.*))?$/ @@ -74,6 +77,11 @@ Serve.prototype.go = function () { filesCb(function (err) { gotData(err, data) }) + function addField(name, value) { + if (!(name in data)) data[name] = value + else if (Array.isArray(data[name])) data[name].push(value) + else data[name] = [data[name], value] + } busboy.on('file', function (fieldname, file, filename, encoding, mimetype) { var cb = filesCb() pull( @@ -83,15 +91,13 @@ Serve.prototype.go = function () { if (link.size === 0 && !filename) return cb() link.name = filename link.type = mimetype - data[fieldname] = link + addField(fieldname, link) cb() }) ) }) busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) { - if (!(fieldname in data)) data[fieldname] = val - else if (Array.isArray(data[fieldname])) data[fieldname].push(val) - else data[fieldname] = [data[fieldname], val] + addField(fieldname, val) }) this.req.pipe(busboy) } else { @@ -116,8 +122,10 @@ Serve.prototype.go = function () { self.data = data if (err) next(err) else if (data.action === 'publish') self.publishJSON(next) - else if (data.action === 'vote') self.publishVote(next) else if (data.action === 'contact') self.publishContact(next) + else if (data.action === 'want-blobs') self.wantBlobs(next) + else if (data.action_vote) self.publishVote(next) + else if (data.action_attend) self.publishAttend(next) else next() } @@ -154,8 +162,8 @@ Serve.prototype.publishVote = function (cb) { channel: this.data.channel || undefined, vote: { link: this.data.link, - value: Number(this.data.value), - expression: this.data.expression, + value: Number(this.data.vote_value), + expression: this.data.vote_expression, } } if (this.data.recps) content.recps = this.data.recps.split(',') @@ -171,6 +179,36 @@ Serve.prototype.publishContact = function (cb) { this.publish(content, cb) } +Serve.prototype.publishAttend = function (cb) { + var content = { + type: 'about', + channel: this.data.channel || undefined, + about: this.data.link, + attendee: { + link: this.app.sbot.id + } + } + if (this.data.recps) content.recps = this.data.recps.split(',') + this.publish(content, cb) +} + +Serve.prototype.wantBlobs = function (cb) { + var self = this + if (!self.data.blob_ids) return cb() + var ids = self.data.blob_ids.split(',') + if (!ids.every(u.isRef)) return cb(new Error('bad blob ids ' + ids.join(','))) + var done = multicb({pluck: 1}) + ids.forEach(function (id) { + self.app.wantSizeBlob(id, done()) + }) + if (self.data.async_want) return cb() + done(function (err) { + if (err) return cb(err) + // self.note = h('div', 'wanted blobs: ' + ids.join(', ') + '.') + cb() + }) +} + Serve.prototype.publish = function (content, cb) { var self = this var done = multicb({pluck: 1, spread: true}) @@ -238,14 +276,18 @@ Serve.prototype.path = function (url) { case '/new': return this.new(m[2]) case '/public': return this.public(m[2]) case '/private': return this.private(m[2]) + case '/mentions': return this.mentions(m[2]) case '/search': return this.search(m[2]) case '/advsearch': return this.advsearch(m[2]) case '/vote': return this.vote(m[2]) case '/peers': return this.peers(m[2]) + case '/status': return this.status(m[2]) case '/channels': return this.channels(m[2]) case '/friends': return this.friends(m[2]) case '/live': return this.live(m[2]) case '/compose': return this.compose(m[2]) + case '/emojis': return this.emojis(m[2]) + case '/votes': return this.votes(m[2]) } m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) switch (m[1]) { @@ -254,8 +296,13 @@ Serve.prototype.path = function (url) { case '/links': return this.links(m[2]) case '/static': return this.static(m[2]) case '/emoji': return this.emoji(m[2]) + case '/highlight': return this.highlight(m[2]) case '/contacts': return this.contacts(m[2]) case '/about': return this.about(m[2]) + case '/git': return this.git(m[2]) + case '/image': return this.image(m[2]) + case '/npm': return this.npm(m[2]) + case '/npm-readme': return this.npmReadme(m[2]) } return this.respond(404, 'Not found') } @@ -347,18 +394,13 @@ Serve.prototype.private = function (ext) { var q = this.query var opts = { reverse: !q.forwards, - sortByTimestamp: q.sort === 'claimed', lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 12, } - var limit = Number(q.limit) || 12 pull( - this.app.createLogStream(opts), - pull.filter(u.isMsgEncrypted), - this.app.unboxMessages(), - pull.filter(u.isMsgReadable), - pull.take(limit), + this.app.streamPrivate(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapPrivate(opts), @@ -369,6 +411,32 @@ Serve.prototype.private = function (ext) { ) } +Serve.prototype.mentions = function (ext) { + var self = this + var q = self.query + var opts = { + reverse: !q.forwards, + sortByTimestamp: q.sort === 'claimed', + lt: Number(q.lt) || Date.now(), + gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 12, + } + + return pull( + ph('section', {}, [ + ph('h3', 'Mentions'), + pull( + self.app.streamMentions(opts), + self.app.unboxMessages(), + self.renderThreadPaginated(opts, null, q), + self.wrapMessages() + ) + ]), + self.wrapPage('mentions'), + self.respondSink(200) + ) +} + Serve.prototype.search = function (ext) { var searchQ = (this.query.q || '').trim() var self = this @@ -487,6 +555,90 @@ Serve.prototype.compose = function (ext) { }) } +Serve.prototype.votes = function (path) { + if (path) return pull( + pull.once(u.renderError(new Error('Not implemented')).outerHTML), + this.wrapPage('#' + channel), + this.respondSink(404, {'Content-Type': ctype('html')}) + ) + + var self = this + var q = self.query + var opts = { + reverse: !q.forwards, + limit: Number(q.limit) || 50, + } + var gt = Number(q.gt) + if (gt) opts.gt = gt + var lt = Number(q.lt) + if (lt) opts.lt = lt + + self.app.getVoted(opts, function (err, voted) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('#' + channel), + self.respondSink(500, {'Content-Type': ctype('html')}) + ) + + pull( + ph('table', [ + ph('thead', [ + ph('tr', [ + ph('td', {colspan: 2}, self.syncPager({ + first: voted.firstTimestamp, + last: voted.lastTimestamp, + })) + ]) + ]), + ph('tbody', pull( + pull.values(voted.items), + paramap(function (item, cb) { + cb(null, ph('tr', [ + ph('td', [String(item.value)]), + ph('td', [ + self.phIdLink(item.id), + pull.once(' dug by '), + self.renderIdsList()(pull.values(item.feeds)) + ]) + ])) + }, 8) + )), + ph('tfoot', {}, []), + ]), + self.wrapPage('votes'), + self.respondSink(200, { + 'Content-Type': ctype('html') + }) + ) + }) +} + +Serve.prototype.syncPager = function (opts) { + var q = this.query + var reverse = !q.forwards + var min = (reverse ? opts.last : opts.first) || Number(q.gt) + var max = (reverse ? opts.first : opts.last) || Number(q.lt) + var minDate = new Date(min) + var maxDate = new Date(max) + var qOlder = u.mergeOpts(q, {lt: min, gt: undefined, forwards: undefined}) + var qNewer = u.mergeOpts(q, {gt: max, lt: undefined, forwards: 1}) + var atNewest = reverse ? !q.lt : !max + var atOldest = reverse ? !min : !q.gt + if (atNewest && !reverse) qOlder.lt++ + if (atOldest && reverse) qNewer.gt-- + return h('div', + atOldest ? 'oldest' : [ + h('a', {href: '?' + qs.stringify(qOlder)}, '<<'), ' ', + h('span', {title: minDate.toString()}, htime(minDate)), ' ', + ], + ' - ', + atNewest ? 'now' : [ + h('span', {title: maxDate.toString()}, htime(maxDate)), ' ', + h('a', {href: '?' + qs.stringify(qNewer)}, '>>') + ] + ).outerHTML +} + Serve.prototype.peers = function (ext) { var self = this if (self.data.action === 'connect') { @@ -532,6 +684,34 @@ Serve.prototype.peers = function (ext) { ) } +Serve.prototype.status = function (ext) { + var self = this + + if (!self.app.sbot.status) return pull( + pull.once('missing sbot status method'), + this.wrapPage('status'), + self.respondSink(400) + ) + + pull( + ph('section', [ + ph('h3', 'Status'), + pull( + u.readNext(function (cb) { + self.app.sbot.status(function (err, status) { + cb(err, status && pull.once(status)) + }) + }), + pull.map(function (status) { + return h('pre', self.app.render.linkify(JSON.stringify(status, 0, 2))).outerHTML + }) + ) + ]), + this.wrapPage('status'), + this.respondSink(200) + ) +} + Serve.prototype.channels = function (ext) { var self = this var id = self.app.sbot.id @@ -581,14 +761,6 @@ Serve.prototype.channels = function (ext) { ) } -Serve.prototype.phIdLink = function (id) { - return pull( - pull.once(id), - pull.asyncMap(this.renderIdLink.bind(this)), - pull.map(u.toHTML) - ) -} - Serve.prototype.contacts = function (path) { var self = this var id = String(path).substr(1) @@ -655,7 +827,7 @@ Serve.prototype.about = function (path) { function renderAboutOpContent(op) { if (op.prop === 'image') - return renderAboutOpImage(op.value) + return renderAboutOpImage(u.toLink(op.value)) if (op.prop === 'description') return h('div', {innerHTML: render.markdown(op.value)}).outerHTML if (op.prop === 'title') @@ -772,23 +944,11 @@ Serve.prototype.channel = function (path) { lt: lt, gt: gt, limit: Number(q.limit) || 12, - query: [{$filter: { - value: {content: {channel: channel}}, - timestamp: { - $gt: gt, - $lt: lt, - } - }}] + channel: channel, } - if (!this.app.sbot.query) return pull( - pull.once(u.renderError(new Error('Missing ssb-query plugin')).outerHTML), - this.wrapPage('#' + channel), - this.respondSink(400, {'Content-Type': ctype('html')}) - ) - pull( - this.app.sbot.query.read(opts), + this.app.streamChannel(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapChannel(channel), @@ -900,9 +1060,12 @@ Serve.prototype.emoji = function (emoji) { serveEmoji(this.req, this.res, emoji) } +Serve.prototype.highlight = function (dirs) { + this.file(path.join(hlCssDir, dirs)) +} + Serve.prototype.blob = function (id, path) { var self = this - var blobs = self.app.sbot.blobs var etag = id + (path || '') if (self.req.headers['if-none-match'] === etag) return self.respond(304) var key @@ -921,22 +1084,18 @@ Serve.prototype.blob = function (id, path) { return self.respond(400, 'Bad blob request') } } - var done = multicb({pluck: 1, spread: true}) - blobs.want(id, function (err, has) { + self.app.wantSizeBlob(id, function (err, size) { 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') - blobs.size(id, done()) pull( self.app.getBlob(id, key), pull.map(Buffer), - ident(done().bind(self, null)), + ident(gotType), self.respondSink() ) - done(function (err, size, type) { - if (err) console.trace(err) + function gotType(type) { type = type && mime.lookup(type) if (type) self.res.setHeader('Content-Type', type) // don't serve size for encrypted blob, because it refers to the size of @@ -948,6 +1107,77 @@ Serve.prototype.blob = function (id, path) { self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('etag', etag) self.res.writeHead(200) + } + }) +} + +Serve.prototype.image = function (path) { + var self = this + var id, key + var m = urlIdRegex.exec(path) + if (m && m[2] === '&') id = m[1], path = m[3] + var etag = 'image-' + id + (path || '') + if (self.req.headers['if-none-match'] === etag) return self.respond(304) + if (path) { + path = decodeURIComponent(path) + if (path[0] === '#') { + try { + key = new Buffer(path.substr(1), 'base64') + } catch(err) { + return self.respond(400, err.message) + } + if (key.length !== 32) { + return self.respond(400, 'Bad blob key') + } + } else { + return self.respond(400, 'Bad blob request') + } + } + self.app.wantSizeBlob(id, function (err, size) { + if (err) { + if (/^invalid/.test(err.message)) return self.respond(400, err.message) + else return self.respond(500, err.message || err) + } + + var done = multicb({pluck: 1, spread: true}) + var heresTheData = done() + var heresTheType = done().bind(self, null) + + pull( + self.app.getBlob(id, key), + pull.map(Buffer), + ident(heresTheType), + pull.collect(onFullBuffer) + ) + + function onFullBuffer (err, buffer) { + if (err) return heresTheData(err) + buffer = Buffer.concat(buffer) + + jpeg.rotate(buffer, {}, function (err, rotatedBuffer, orientation) { + if (!err) buffer = rotatedBuffer + + heresTheData(null, buffer) + pull( + pull.once(buffer), + self.respondSink() + ) + }) + } + + done(function (err, data, type) { + if (err) { + console.trace(err) + self.respond(500, err.message || err) + } + type = type && mime.lookup(type) + if (type) self.res.setHeader('Content-Type', type) + self.res.setHeader('Content-Length', data.length) + if (self.query.name) self.res.setHeader('Content-Disposition', + 'inline; filename='+encodeDispositionFilename(self.query.name)) + self.res.setHeader('Cache-Control', 'public, max-age=315360000') + self.res.setHeader('etag', etag) + self.res.writeHead(200) }) }) } @@ -972,38 +1202,28 @@ Serve.prototype.renderThread = function () { ) } -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) { var self = this function linkA(opts, name) { - var q1 = mergeOpts(q, opts) + var q1 = u.mergeOpts(q, opts) return h('a', {href: '?' + qs.stringify(q1)}, name || q1.limit) } function links(opts) { var limit = opts.limit || q.limit || 10 return h('tr', h('td.paginate', {colspan: 3}, opts.forwards ? '↑ newer ' : '↓ older ', - linkA(mergeOpts(opts, {limit: 1})), ' ', - linkA(mergeOpts(opts, {limit: 10})), ' ', - linkA(mergeOpts(opts, {limit: 100})) + linkA(u.mergeOpts(opts, {limit: 1})), ' ', + linkA(u.mergeOpts(opts, {limit: 10})), ' ', + linkA(u.mergeOpts(opts, {limit: 100})) )) } return pull( paginate( function onFirst(msg, cb) { - var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts + var num = feedId ? msg.value.sequence : + opts.sortByTimestamp ? msg.value.timestamp : + msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: num, @@ -1020,7 +1240,9 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { }, this.app.render.renderFeeds(), function onLast(msg, cb) { - var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts + var num = feedId ? msg.value.sequence : + opts.sortByTimestamp ? msg.value.timestamp : + msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: null, @@ -1056,8 +1278,13 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { } Serve.prototype.renderRawMsgPage = function (id) { + var showMarkdownSource = (this.query.raw === 'md') + var raw = !showMarkdownSource return pull( - this.app.render.renderFeeds(true), + this.app.render.renderFeeds({ + raw: raw, + markdownSource: showMarkdownSource + }), pull.map(u.toHTML), this.wrapMessages(), this.wrapPage(id) @@ -1078,6 +1305,20 @@ function catchHTMLError() { } } +function catchTextError() { + 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, end.stack + '\n') + }) + } + } +} + function styles() { return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8') } @@ -1108,19 +1349,25 @@ Serve.prototype.wrapPage = function (title, searchQ) { h('title', title), h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}), h('link', {rel: 'icon', href: render.toUrl('/static/hermie.ico'), type: 'image/x-icon'}), - h('style', styles()) + h('style', styles()), + h('link', {rel: 'stylesheet', href: render.toUrl('/highlight/foundation.css')}) ), h('body', h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'}, h('a', {href: render.toUrl('/new')}, 'new') , ' ', h('a', {href: render.toUrl('/public')}, 'public'), ' ', h('a', {href: render.toUrl('/private')}, 'private') , ' ', + h('a', {href: render.toUrl('/mentions')}, 'mentions') , ' ', h('a', {href: render.toUrl('/peers')}, 'peers') , ' ', + self.app.sbot.status ? + [h('a', {href: render.toUrl('/status')}, 'status'), ' '] : '', h('a', {href: render.toUrl('/channels')}, 'channels') , ' ', h('a', {href: render.toUrl('/friends')}, 'friends'), ' ', h('a', {href: render.toUrl('/advsearch')}, 'search'), ' ', h('a', {href: render.toUrl('/live')}, 'live'), ' ', h('a', {href: render.toUrl('/compose')}, 'compose'), ' ', + h('a', {href: render.toUrl('/votes')}, 'votes'), ' ', + h('a', {href: render.toUrl('/emojis')}, 'emojis'), ' ', render.idLink(self.app.sbot.id, done()), ' ', h('input.search-input', {name: 'q', value: searchQ, placeholder: 'search'}) @@ -1132,6 +1379,7 @@ Serve.prototype.wrapPage = function (title, searchQ) { 'published ', self.app.render.msgLink(self.publishedMsg, done()) ) : '', + // self.note, content ))) done(cb) @@ -1139,25 +1387,18 @@ Serve.prototype.wrapPage = function (title, searchQ) { ) } -Serve.prototype.renderIdLink = function (id, cb) { - var render = this.app.render - var el = render.idLink(id, function (err) { - if (err || !el) { - el = h('a', {href: render.toUrl(id)}, id) - } - cb(null, el) - }) +Serve.prototype.phIdLink = function (id) { + return pull( + pull.once(id), + this.renderIdsList() + ) } Serve.prototype.friends = function (path) { var self = this pull( self.app.sbot.friends.createFriendStream({hops: 1}), - self.renderFriends(), - pull.map(function (el) { - return [el, ' '] - }), - pull.map(u.toHTML), + self.renderIdsList(), u.hyperwrap(function (items, cb) { cb(null, [ h('section', @@ -1173,14 +1414,17 @@ Serve.prototype.friends = function (path) { ) } -Serve.prototype.renderFriends = function () { +Serve.prototype.renderIdsList = function () { var self = this - return paramap(function (id, cb) { - self.renderIdLink(id, function (err, el) { - if (err) el = u.renderError(err, ext) - cb(null, el) - }) - }, 8) + return pull( + paramap(function (id, cb) { + self.app.render.getNameLink(id, cb) + }, 8), + pull.map(function (el) { + return [el, ' '] + }), + pull.map(u.toHTML) + ) } var relationships = [ @@ -1260,6 +1504,532 @@ Serve.prototype.wrapUserFeed = function (isScrolled, id) { }) } +Serve.prototype.git = function (url) { + var m = /^\/?([^\/]*)\/?(.*)?$/.exec(url) + switch (m[1]) { + case 'commit': return this.gitCommit(m[2]) + case 'tag': return this.gitTag(m[2]) + case 'tree': return this.gitTree(m[2]) + case 'blob': return this.gitBlob(m[2]) + case 'raw': return this.gitRaw(m[2]) + default: return this.respond(404, 'Not found') + } +} + +Serve.prototype.gitRaw = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + pull.once('\'' + rev + '\' is not a git object id'), + self.respondSink(400, {'Content-Type': 'text/plain'}) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(err.stack), + self.respondSink(400, {'Content-Type': 'text/plain'}) + ) + pull( + self.app.git.readObject(obj), + catchTextError(), + ident(function (type) { + type = type && mime.lookup(type) + if (type) self.res.setHeader('Content-Type', type) + self.res.setHeader('Cache-Control', 'public, max-age=315360000') + self.res.setHeader('etag', rev) + self.res.writeHead(200) + }), + self.respondSink() + ) + }) +} + +Serve.prototype.gitAuthorLink = function (author) { + if (author.feed) { + var myName = this.app.getNameSync(author.feed) + var sigil = author.name === author.localpart ? '@' : '' + return ph('a', { + href: this.app.render.toUrl(author.feed), + title: author.localpart + (myName ? ' (' + myName + ')' : '') + }, u.escapeHTML(sigil + author.name)) + } else { + return ph('a', {href: this.app.render.toUrl('mailto:' + author.email)}, + u.escapeHTML(author.name)) + } +} + +Serve.prototype.gitCommit = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + self.app.git.getCommit(obj, function (err, commit) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' pushed ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : [ + ph('div', [ + self.gitAuthorLink(commit.committer), + ' committed ', + ph('span', {title: commit.committer.date.toLocaleString()}, + htime(commit.committer.date)), + ' in ', commit.committer.tz + ]), + commit.author ? ph('div', [ + self.gitAuthorLink(commit.author), + ' authored ', + ph('span', {title: commit.author.date.toLocaleString()}, + htime(commit.author.date)), + ' in ', commit.author.tz + ]) : '', + commit.parents.length ? ph('div', ['parents: ', pull( + pull.values(commit.parents), + self.gitObjectLinks(obj.msg.key, 'commit') + )]) : '', + commit.tree ? ph('div', ['tree: ', pull( + pull.once(commit.tree), + self.gitObjectLinks(obj.msg.key, 'tree') + )]) : '', + h('blockquote', + self.app.render.gitCommitBody(commit.body)).outerHTML, + ph('h4', 'files'), + ph('table', pull( + self.app.git.readCommitChanges(commit), + pull.map(function (file) { + return ph('tr', [ + ph('td', ph('code', u.escapeHTML(file.name))), + // ph('td', ph('code', u.escapeHTML(JSON.stringify(file.msg)))), + ph('td', file.deleted ? 'deleted' + : file.created ? 'created' + : file.hash ? 'changed' + : file.mode ? 'mode changed' + : JSON.stringify(file)) + ]) + }) + )) + ] + ]), + self.wrapPage('git commit ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) + }) +} + +Serve.prototype.gitTag = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + self.app.git.getTag(obj, function (err, tag) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' pushed ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : [ + ph('div', [ + self.gitAuthorLink(tag.tagger), + ' tagged ', + ph('span', {title: tag.tagger.date.toLocaleString()}, + htime(tag.tagger.date)), + ' in ', tag.tagger.tz + ]), + tag.type, ' ', + pull( + pull.once(tag.object), + self.gitObjectLinks(obj.msg.key, tag.type) + ), ' ', + ph('code', u.escapeHTML(tag.tag)), + h('pre', self.app.render.linkify(tag.body)).outerHTML, + ] + ]), + self.wrapPage('git tag ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) + }) +} + +Serve.prototype.gitTree = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : ph('table', [ + pull( + self.app.git.readTree(obj), + paramap(function (file, cb) { + self.app.git.getObjectMsg({ + obj: file.hash, + headMsgId: obj.msg.key, + }, function (err, msg) { + if (err && err.name === 'ObjectNotFoundError') return cb(null, file) + if (err) return cb(err) + file.msg = msg + cb(null, file) + }) + }, 8), + pull.map(function (item) { + if (!item.msg) return ph('tr', [ + ph('td', + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : '')), + ph('td', u.escapeHTML(item.hash)), + ph('td', 'missing') + ]) + var ext = item.name.replace(/.*\./, '') + var path = '/git/' + item.type + '/' + item.hash + + '?msg=' + encodeURIComponent(item.msg.key) + + (ext ? '&ext=' + ext : '') + var fileDate = new Date(item.msg.value.timestamp) + return ph('tr', [ + ph('td', + ph('a', {href: self.app.render.toUrl(path)}, + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : ''))), + ph('td', + self.phIdLink(item.msg.value.author)), + ph('td', + ph('a', { + href: self.app.render.toUrl(item.msg.key), + title: fileDate.toLocaleString(), + }, htime(fileDate)) + ), + ]) + }) + ) + ]), + ]), + self.wrapPage('git tree ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) +} + +Serve.prototype.gitBlob = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + + self.app.getMsgDecrypted(self.query.msg, function (err, msg) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(msg.value.timestamp) + self.app.git.openObject({ + obj: rev, + msg: msg.key, + }, function (err, obj) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(msg.value.author), ' ', + ph('a', { + href: self.app.render.toUrl(msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : pull( + self.app.git.readObject(obj), + self.wrapBinary({ + rawUrl: self.app.render.toUrl('/git/raw/' + rev + + '?msg=' + encodeURIComponent(msg.key)), + ext: self.query.ext + }) + ), + ]), + self.wrapPage('git blob ' + rev), + self.respondSink(200) + ) + }) + }) +} + +Serve.prototype.gitObjectLinks = function (headMsgId, type) { + var self = this + return paramap(function (id, cb) { + self.app.git.getObjectMsg({ + obj: id, + headMsgId: headMsgId, + type: type, + }, function (err, msg) { + if (err && err.name === 'BlobNotFoundError') + return cb(null, self.askWantBlobsForm(err.links)) + if (err && err.name === 'ObjectNotFoundError') + return cb(null, [ + ph('code', u.escapeHTML(id.substr(0, 8))), '(missing)']) + if (err) return cb(err) + var path = '/git/' + type + '/' + id + + '?msg=' + encodeURIComponent(msg.key) + cb(null, [ph('code', ph('a', { + href: self.app.render.toUrl(path) + }, u.escapeHTML(id.substr(0, 8)))), ' ']) + }) + }, 8) +} + +Serve.prototype.npm = function (url) { + var self = this + var parts = url.split('/') + var author = parts[1] && parts[1][0] === '@' + ? u.unescapeId(parts.splice(1, 1)[0]) : null + var name = parts[1] + var version = parts[2] + var distTag = parts[3] + var prefix = 'npm:' + + (name ? name + ':' + + (version ? version + ':' + + (distTag ? distTag + ':' : '') : '') : '') + + var render = self.app.render + var base = '/npm/' + (author ? u.escapeId(author) + '/' : '') + var pathWithoutAuthor = '/npm' + + (name ? '/' + name + + (version ? '/' + version + + (distTag ? '/' + distTag : '') : '') : '') + return pull( + ph('section', {}, [ + ph('h3', [ph('a', {href: render.toUrl('/npm/')}, 'npm'), ' : ', + author ? [ + self.phIdLink(author), ' ', + ph('sub', ph('a', {href: render.toUrl(pathWithoutAuthor)}, '×')), + ' : ' + ] : '', + name ? [ph('a', {href: render.toUrl(base + name)}, name), ' : '] : '', + version ? [ph('a', {href: render.toUrl(base + name + '/' + version)}, version), ' : '] : '', + distTag ? [ph('a', {href: render.toUrl(base + name + '/' + version + '/' + distTag)}, distTag)] : '' + ]), + ph('table', [ + ph('thead', ph('tr', [ + ph('td', 'publisher'), + ph('td', 'package'), + ph('td', 'version'), + ph('td', 'tag'), + ph('td', 'size'), + ph('td', 'tarball'), + ph('td', 'readme') + ])), + ph('tbody', pull( + self.app.blobMentions({ + name: {$prefix: prefix}, + author: author, + }), + distTag && !version && pull.filter(function (link) { + return link.name.split(':')[3] === distTag + }), + paramap(function (link, cb) { + self.app.render.npmPackageMention(link, { + withAuthor: true, + author: author, + name: name, + version: version, + distTag: distTag, + }, cb) + }, 4), + pull.map(u.toHTML) + )) + ]) + ]), + self.wrapPage(prefix), + self.respondSink(200) + ) +} + +Serve.prototype.npmReadme = function (url) { + var self = this + var id = decodeURIComponent(url.substr(1)) + return pull( + ph('section', {}, [ + ph('h3', [ + 'npm readme for ', + ph('a', {href: '/links/' + id}, id.substr(0, 8) + '…') + ]), + ph('blockquote', u.readNext(function (cb) { + self.app.getNpmReadme(id, function (err, readme, isMarkdown) { + if (err) return cb(null, ph('div', u.renderError(err).outerHTML)) + cb(null, isMarkdown + ? ph('div', self.app.render.markdown(readme)) + : ph('pre', readme)) + }) + })) + ]), + self.wrapPage('npm readme'), + self.respondSink(200) + ) +} + +// wrap a binary source and render it or turn into an embed +Serve.prototype.wrapBinary = function (opts) { + var self = this + var ext = opts.ext + return function (read) { + var readRendered, type + read = ident(function (_ext) { + if (_ext) ext = _ext + type = ext && mime.lookup(ext) || 'text/plain' + })(read) + return function (abort, cb) { + if (readRendered) return readRendered(abort, cb) + if (abort) return read(abort, cb) + if (!type) read(null, function (end, buf) { + if (end) return cb(end) + if (!type) return cb(new Error('unable to get type')) + readRendered = pickSource(type, cat([pull.once(buf), read])) + readRendered(null, cb) + }) + } + } + function pickSource(type, read) { + if (/^image\//.test(type)) { + read(true, function (err) { + if (err && err !== true) console.trace(err) + }) + return ph('img', { + src: opts.rawUrl + }) + } + return ph('pre', pull.map(function (buf) { + return self.app.render.highlight(buf.toString('utf8'), ext) + })(read)) + } +} + Serve.prototype.wrapPublic = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { @@ -1275,6 +2045,36 @@ Serve.prototype.wrapPublic = function (opts) { }) } +Serve.prototype.askWantBlobsForm = function (links) { + var self = this + return ph('form', {action: '', method: 'post'}, [ + ph('section', [ + ph('h3', 'Missing blobs'), + ph('p', 'The application needs these blobs to continue:'), + ph('table', links.map(u.toLink).map(function (link) { + if (!u.isRef(link.link)) return + return ph('tr', [ + ph('td', ph('code', link.link)), + !isNaN(link.size) ? ph('td', self.app.render.formatSize(link.size)) : '', + ]) + })), + ph('input', {type: 'hidden', name: 'action', value: 'want-blobs'}), + ph('input', {type: 'hidden', name: 'blob_ids', + value: links.map(u.linkDest).join(',')}), + ph('p', ph('input', {type: 'submit', value: 'Want Blobs'})) + ]) + ]) +} + +Serve.prototype.askWantBlobs = function (links) { + var self = this + pull( + self.askWantBlobsForm(links), + self.wrapPage('missing blobs'), + self.respondSink(409) + ) +} + Serve.prototype.wrapPrivate = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { @@ -1438,6 +2238,7 @@ Serve.prototype.composer = function (opts, cb) { var self = this opts = opts || {} var data = self.data + var myId = self.app.sbot.id var blobs = u.tryDecodeJSON(data.blobs) || {} if (data.upload && typeof data.upload === 'object') { @@ -1459,6 +2260,19 @@ Serve.prototype.composer = function (opts, cb) { formNames[mentionNames[i]] = u.extractFeedIds(mentionIds[i])[0] } + var formEmojiNames = {} + var emojiIds = u.toArray(data.emoji_id) + var emojiNames = u.toArray(data.emoji_name) + for (var i = 0; i < emojiIds.length && i < emojiNames.length; i++) { + var upload = data['emoji_upload_' + i] + formEmojiNames[emojiNames[i]] = + (upload && upload.link) || u.extractBlobIds(emojiIds[i])[0] + if (upload) blobs[upload.link] = { + type: upload.type, + size: upload.size, + } + } + if (data.upload) { var href = data.upload.link + (data.upload.key ? '#' + data.upload.key : '') @@ -1471,7 +2285,8 @@ Serve.prototype.composer = function (opts, cb) { // get bare feed names var unknownMentionNames = {} - var unknownMentions = ssbMentions(data.text, {bareFeedNames: true}) + var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) + var unknownMentions = mentions .filter(function (mention) { return mention.link === '@' }) @@ -1484,6 +2299,26 @@ Serve.prototype.composer = function (opts, cb) { return {name: name, id: id} }) + var emoji = mentions + .filter(function (mention) { return mention.emoji }) + .map(function (mention) { return mention.name }) + .filter(uniques()) + .map(function (name) { + // 1. check emoji-image mapping for this message + var id = formEmojiNames[name] + if (id) return {name: name, id: id} + // 2. TODO: check user's preferred emoji-image mapping + // 3. check builtin emoji + var link = self.getBuiltinEmojiLink(name) + if (link) { + return {name: name, id: link.link} + blobs[id] = {type: link.type, size: link.size} + } + // 4. check recently seen emoji + id = self.app.getReverseEmojiNameSync(name) + return {name: name, id: id} + }) + // strip content other than feed ids from the recps field if (data.recps) { data.recps = u.extractFeedIds(data.recps).filter(uniques()).join(', ') @@ -1522,7 +2357,19 @@ Serve.prototype.composer = function (opts, cb) { h('input', {name: 'mention_name', type: 'hidden', value: mention.name}), h('input.id-input', {name: 'mention_id', size: 60, - value: mention.id, placeholder: 'id'})) + value: mention.id, placeholder: '@id'})) + })) + ] : '', + emoji.length > 0 ? [ + h('div', h('em', 'emoji:')), + h('ul.mentions', emoji.map(function (link, i) { + return h('li', + h('code', link.name), ': ', + h('input', {name: 'emoji_name', type: 'hidden', + value: link.name}), + h('input.id-input', {name: 'emoji_id', size: 60, + value: link.id, placeholder: '&id'}), ' ', + h('input', {type: 'file', name: 'emoji_upload_' + i})) })) ] : '', h('table.ssb-msgs', @@ -1544,75 +2391,132 @@ Serve.prototype.composer = function (opts, cb) { )) done(cb) + function prepareContent(cb) { + var done = multicb({pluck: 1}) + content = { + type: 'post', + text: String(data.text).replace(/\r\n/g, '\n'), + } + var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) + .filter(function (mention) { + if (mention.emoji) { + mention.link = formEmojiNames[mention.name] + if (!mention.link) { + var link = self.getBuiltinEmojiLink(mention.name) + if (link) { + mention.link = link.link + mention.size = link.size + mention.type = link.type + } else { + mention.link = self.app.getReverseEmojiNameSync(mention.name) + if (!mention.link) return false + } + } + } + var blob = blobs[mention.link] + if (blob) { + if (!isNaN(blob.size)) + mention.size = blob.size + if (blob.type && blob.type !== 'application/octet-stream') + mention.type = blob.type + } else if (mention.link === '@') { + // bare feed name + var name = mention.name + var id = formNames[name] || self.app.getReverseNameSync('@' + name) + if (id) mention.link = id + else return false + } + if (mention.link && mention.link[0] === '&' && mention.size == null) { + var linkCb = done() + self.app.sbot.blobs.size(mention.link, function (err, size) { + if (!err && size != null) mention.size = size + linkCb() + }) + } + return true + }) + if (mentions.length) content.mentions = mentions + if (data.recps != null) { + if (opts.recps) return cb(new Error('got recps in opts and data')) + content.recps = [myId] + u.extractFeedIds(data.recps).forEach(function (recp) { + if (content.recps.indexOf(recp) === -1) content.recps.push(recp) + }) + } else { + if (opts.recps) content.recps = opts.recps + } + if (data.fork_thread) { + content.root = opts.post || undefined + content.branch = u.fromArray(opts.postBranches) || undefined + } else { + content.root = opts.root || undefined + content.branch = u.fromArray(opts.branches) || undefined + } + if (channel) content.channel = data.channel + + done(function (err) { + cb(err, content) + }) + } + function preview(raw, cb) { - var myId = self.app.sbot.id + var msgContainer = h('table.ssb-msgs') + var contentInput = h('input', {type: 'hidden', name: 'content'}) + var warningsContainer = h('div') + var content - try { - content = JSON.parse(data.text) - } catch (err) { - data.text = String(data.text).replace(/\r\n/g, '\n') - content = { - type: 'post', - text: data.text, - } - var mentions = ssbMentions(data.text, {bareFeedNames: true}) - .filter(function (mention) { - var blob = blobs[mention.link] - if (blob) { - if (!isNaN(blob.size)) - mention.size = blob.size - if (blob.type && blob.type !== 'application/octet-stream') - mention.type = blob.type - } else if (mention.link === '@') { - // bare feed name - var name = mention.name - var id = formNames[name] || self.app.getReverseNameSync('@' + name) - if (id) mention.link = id - else return false - } - return true - }) - if (mentions.length) content.mentions = mentions - if (data.recps != null) { - if (opts.recps) return cb(new Error('got recps in opts and data')) - content.recps = [myId] - u.extractFeedIds(data.recps).forEach(function (recp) { - if (content.recps.indexOf(recp) === -1) content.recps.push(recp) - }) - } else { - if (opts.recps) content.recps = opts.recps - } - if (data.fork_thread) { - content.root = opts.post || undefined - content.branch = u.fromArray(opts.postBranches) || undefined - } else { - content.root = opts.root || undefined - content.branch = u.fromArray(opts.branches) || undefined + try { content = JSON.parse(data.text) } + catch (err) {} + if (content) gotContent(null, content) + else prepareContent(gotContent) + + function gotContent(err, content) { + if (err) return cb(err) + contentInput.value = JSON.stringify(content) + var msg = { + value: { + author: myId, + timestamp: Date.now(), + content: content + } } - if (channel) content.channel = data.channel - } - var msg = { - value: { - author: myId, - timestamp: Date.now(), - content: content + if (content.recps) msg.value.private = true + + var warnings = [] + u.toLinkArray(content.mentions).forEach(function (link) { + if (link.emoji && link.size >= 10e3) { + warnings.push(h('li', + 'emoji ', h('q', link.name), + ' (', h('code', String(link.link).substr(0, 8) + '…'), ')' + + ' is >10KB')) + } else if (link.link[0] === '&' && link.size >= 10e6 && link.type) { + // if link.type is set, we probably just uploaded this blob + warnings.push(h('li', + 'attachment ', + h('code', String(link.link).substr(0, 8) + '…'), + ' is >10MB')) + } + }) + if (warnings.length) { + warningsContainer.appendChild(h('div', h('em', 'warning:'))) + warningsContainer.appendChild(h('ul.mentions', warnings)) } + + pull( + pull.once(msg), + self.app.unboxMessages(), + self.app.render.renderFeeds(raw), + pull.drain(function (el) { + msgContainer.appendChild(h('tbody', el)) + }, cb) + ) } - if (content.recps) msg.value.private = true - var msgContainer = h('table.ssb-msgs') - pull( - pull.once(msg), - self.app.unboxMessages(), - self.app.render.renderFeeds(raw), - pull.drain(function (el) { - msgContainer.appendChild(h('tbody', el)) - }, cb) - ) + return [ - h('input', {type: 'hidden', name: 'content', - value: JSON.stringify(content)}), + contentInput, opts.redirectToPublishedMsg ? h('input', {type: 'hidden', name: 'redirect_to_published_msg', value: '1'}) : '', + warningsContainer, h('div', h('em', 'draft:')), msgContainer, h('div.composer-actions', @@ -1622,3 +2526,57 @@ Serve.prototype.composer = function (opts, cb) { } } + +function hashBuf(buf) { + var hash = crypto.createHash('sha256') + hash.update(buf) + return '&' + hash.digest('base64') + '.sha256' +} + +Serve.prototype.getBuiltinEmojiLink = function (name) { + if (!(name in emojis)) return + var file = path.join(emojiDir, name + '.png') + var fileBuf = fs.readFileSync(file) + var id = hashBuf(fileBuf) + // seed the builtin emoji + pull(pull.once(fileBuf), this.app.sbot.blobs.add(id, function (err) { + if (err) console.error('error adding builtin emoji as blob', err) + })) + return { + link: id, + type: 'image/png', + size: fileBuf.length, + } +} + +Serve.prototype.emojis = function (path) { + var self = this + var seen = {} + pull( + ph('section', [ + ph('h3', 'Emojis'), + ph('ul', {class: 'mentions'}, pull( + self.app.streamEmojis(), + pull.map(function (emoji) { + if (!seen[emoji.name]) { + // cache the first use, so that our uses take precedence over other feeds' + self.app.reverseEmojiNameCache.set(emoji.name, emoji.link) + seen[emoji.name] = true + } + return ph('li', [ + ph('a', {href: self.app.render.toUrl('/links/' + emoji.link)}, + ph('img', { + class: 'ssb-emoji', + src: self.app.render.imageUrl(emoji.link), + size: 32, + }) + ), ' ', + u.escapeHTML(emoji.name) + ]) + }) + )) + ]), + this.wrapPage('emojis'), + this.respondSink(200) + ) +} diff --git a/lib/util.js b/lib/util.js index 5546716..fb0b13f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,9 +1,11 @@ var pull = require('pull-stream') var cat = require('pull-cat') var h = require('hyperscript') +var b64url = require('base64-url') var u = exports u.ssbRefRegex = /((?:@|%|&|ssb:\/\/%)[A-Za-z0-9\/+]{43}=\.[\w\d]+)/g +u.ssbRefEncRegex = /((?:ssb:\/\/)?(?:[@%&]|%26|%40|%25)(?:[A-Za-z0-9\/+]|%2[fF]|%2[bB]){43}(?:=|%3[dD])\.[\w\d]+)/g u.isRef = function (str) { if (!str) return false @@ -62,18 +64,34 @@ u.hyperwrap = function (fn) { } } +u.ifString = function (str) { + if (typeof str === 'string') return str +} + +u.ifNumber = function (num) { + if (!isNaN(num)) return num +} + +u.toLink = function (link) { + return typeof link === 'string' ? {link: link} : link || null +} + u.linkDest = function (link) { - return typeof link === 'string' ? link : link && link.link || link + return link && (u.ifString(link) || u.ifString(link.link)) } u.toArray = function (x) { - return !x ? [] : Array.isArray(x) ? x : [x] + return x == null ? [] : Array.isArray(x) ? x : [x] } u.fromArray = function (arr) { return Array.isArray(arr) && arr.length === 1 ? arr[0] : arr } +u.toLinkArray = function (x) { + return u.toArray(x).map(u.toLink).filter(u.linkDest) +} + u.renderError = function(err) { return h('div.error', h('h3', err.name), @@ -97,7 +115,7 @@ u.tryDecodeJSON = function (json) { } } -u.extractFeedIds = function (str) { +u.extractRefs = function (str) { var ids = [] String(str).replace(u.ssbRefRegex, function (id) { ids.push(id) @@ -105,6 +123,18 @@ u.extractFeedIds = function (str) { return ids } +u.extractFeedIds = function (str) { + return u.extractRefs(str).filter(function (ref) { + return ref[0] === '@' + }) +} + +u.extractBlobIds = function (str) { + return u.extractRefs(str).filter(function (ref) { + return ref[0] === '&' + }) +} + u.isMsgReadable = function (msg) { var c = msg && msg.value && msg.value.content return typeof c === 'object' && c !== null @@ -114,3 +144,76 @@ u.isMsgEncrypted = function (msg) { var c = msg && msg.value.content return typeof c === 'string' } + +u.pullConcat = function (cb) { + return pull.collect(function (err, bufs) { + if (err) return cb(err) + cb(null, Buffer.concat(bufs)) + }) +} + +u.customError = function (name) { + return function (message) { + var error = new Error(message) + error.name = name + error.stack = error.stack.replace(/^ at .*\n/m, '') + return error + } +} + +u.escapeHTML = function (html) { + if (!html) return '' + return html.toString('utf8') + .replace(/</g, '<') + .replace(/>/g, '>') +} + +u.pullSlice = function (start, end) { + if (end == null) end = Infinity + var offset = 0 + return function (read) { + return function (abort, cb) { + if (abort) read(abort, cb) + else if (offset >= end) read(true, function (err) { + cb(err || true) + }) + else if (offset < start) read(null, function next(err, data) { + if (err) return cb(err) + offset += data.length + if (offset <= start) read(null, next) + else if (offset < end) cb(null, + data.slice(data.length - (offset - start))) + else cb(null, data.slice(data.length - (offset - start), + data.length - (offset - end))) + }) + else read(null, function (err, data) { + if (err) return cb(err) + offset += data.length + if (offset <= end) cb(null, data) + else cb(null, data.slice(0, data.length - (offset - end))) + }) + } + } +} + +u.mergeOpts = function (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 +} + +u.escapeId = function (id) { + return b64url.escape(id) +} + +u.unescapeId = function (str) { + var m = /^(.)(.*)(\..*)$/.exec(str) + if (!m) return b64url.unescape(str) + return m[1] + b64url.unescape(m[2]) + m[3] +} |