diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/about.js | 114 | ||||
-rw-r--r-- | lib/app.js | 109 | ||||
-rw-r--r-- | lib/git.js | 532 | ||||
-rw-r--r-- | lib/render-msg.js | 32 | ||||
-rw-r--r-- | lib/render.js | 54 | ||||
-rw-r--r-- | lib/serve.js | 670 | ||||
-rw-r--r-- | lib/util.js | 65 |
7 files changed, 1502 insertions, 74 deletions
diff --git a/lib/about.js b/lib/about.js new file mode 100644 index 0000000..e94b7fc --- /dev/null +++ b/lib/about.js @@ -0,0 +1,114 @@ +var pull = require('pull-stream') +var multicb = require('multicb') +var cat = require('pull-cat') +var u = require('./util') + +module.exports = About + +function About(app, myId) { + this.app = app + this.myId = myId +} + +About.prototype.createAboutOpStream = function (id) { + return pull( + this.app.sbot.links({dest: id, rel: 'about', values: true, reverse: true}), + pull.map(function (msg) { + var c = msg.value.content || {} + return Object.keys(c).filter(function (key) { + return key !== 'about' + && key !== 'type' + && key !== 'recps' + }).map(function (key) { + var value = u.linkDest(c[key]) + // if (u.isRef(value)) value = {link: value} + return { + id: msg.key, + author: msg.value.author, + timestamp: msg.value.timestamp, + prop: key, + value: value, + remove: value && typeof value === 'object' && value.remove, + } + }) + }), + pull.flatten() + ) +} + +About.prototype.createAboutStreams = function (id) { + var ops = this.createAboutOpStream(id) + var scalars = {/* author: {prop: value} */} + var sets = {/* author: {prop: {link}} */} + + var setsDone = multicb({pluck: 1, spread: true}) + setsDone()(null, pull.values([])) + return { + scalars: pull( + ops, + pull.unique(function (op) { + return op.author + '-' + op.prop + '-' + }), + pull.filter(function (op) { + return !op.remove + }) + ), + sets: u.readNext(setsDone) + } +} + +function computeTopAbout(aboutByFeed) { + var propValueCounts = {/* prop: {value: count} */} + var topValues = {/* prop: value */} + var topValueCounts = {/* prop: count */} + for (var feed in aboutByFeed) { + var feedAbout = aboutByFeed[feed] + for (var prop in feedAbout) { + var value = feedAbout[prop] + var valueCounts = propValueCounts[prop] || (propValueCounts[prop] = {}) + var count = (valueCounts[value] || 0) + 1 + valueCounts[value] = count + if (count > (topValueCounts[prop] || 0)) { + topValueCounts[prop] = count + topValues[prop] = value + } + } + } + return topValues +} + +About.prototype.get = function (dest, cb) { + var self = this + var aboutByFeed = {} + pull( + cat([ + dest[0] === '%' && self.app.pullGetMsg(dest), + self.app.sbot.links({ + rel: 'about', + dest: dest, + values: true, + }) + ]), + self.app.unboxMessages(), + pull.drain(function (msg) { + var author = msg.value.author + var c = msg.value.content + if (!c) return + var about = aboutByFeed[author] || (aboutByFeed[author] = {}) + if (c.name) about.name = c.name + if (c.image) about.image = u.linkDest(c.image) + if (c.description) about.description = c.description + }, function (err) { + if (err) return cb(err) + // bias the author's choices by giving them an extra vote + aboutByFeed._author = aboutByFeed[dest] + var about = {} + var myAbout = aboutByFeed[self.myId] || {} + var topAbout = computeTopAbout(aboutByFeed) + for (var k in topAbout) about[k] = topAbout[k] + // always prefer own choices + for (var k in myAbout) about[k] = myAbout[k] + cb(null, about) + }) + ) +} @@ -4,14 +4,14 @@ var lru = require('hashlru') var pkg = require('../package') var u = require('./util') var pull = require('pull-stream') -var ssbAvatar = require('ssb-avatar') 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') module.exports = App @@ -32,15 +32,17 @@ function App(sbot, config) { } sbot.get = memo({cache: lru(100)}, sbot.get) + this.about = new About(this, sbot.id) this.getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot) this.getAbout = memo({cache: this.aboutCache = lru(500)}, - getAbout.bind(this), sbot, sbot.id) + this._getAbout.bind(this)) this.unboxContent = memo({cache: lru(100)}, sbot.private.unbox) - this.reverseNameCache = lru(100) + this.reverseNameCache = lru(500) this.unboxMsg = this.unboxMsg.bind(this) this.render = new Render(this, this.opts) + this.git = new Git(this) } App.prototype.go = function () { @@ -175,10 +177,7 @@ App.prototype.addBlob = function (cb) { done(function (err, hash, add) { cb(err, hash) }) - return pull( - hasher(hashCb), - this.sbot.blobs.add(addCb) - ) + return sink } App.prototype.pushBlob = function (id, cb) { @@ -186,11 +185,56 @@ 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.getNameSync = function (name) { + var about = this.aboutCache.get(name) + return about && about.name +} + function getMsgWithValue(sbot, id, cb) { if (!id) return cb() sbot.get(id, function (err, value) { @@ -199,11 +243,12 @@ function getMsgWithValue(sbot, id, cb) { }) } -function getAbout(sbot, src, id, cb) { +App.prototype._getAbout = function (id, cb) { var self = this - ssbAvatar(sbot, src, id, function (err, about) { + if (!u.isRef(id)) return cb(null, {}) + self.about.get(id, function (err, about) { if (err) return cb(err) - var sigil = id && id[0] || '@' + var sigil = id[0] || '@' if (about.name && about.name[0] !== sigil) { about.name = sigil + about.name } @@ -212,6 +257,10 @@ function getAbout(sbot, src, id, cb) { }) } +App.prototype.pullGetMsg = function (id) { + return pull.asyncMap(this.getMsg)(pull.once(id)) +} + App.prototype.createLogStream = function (opts) { opts = opts || {} return opts.sortByTimestamp @@ -282,6 +331,40 @@ App.prototype.streamChannels = function (opts) { ) } +App.prototype.streamMyChannels = function (id, opts) { + // use ssb-query plugin if it is available, since it has an index for + // author + type + if (this.sbot.query) return pull( + this.sbot.query.read({ + reverse: true, + query: [ + {$filter: { + value: { + author: id, + content: {type: 'channel', subscribed: true} + } + }}, + {$map: ['value', 'content', 'channel']} + ] + }), + pull.unique() + ) + + return pull( + this.sbot.createUserStream({id: id, reverse: true}), + this.unboxMessages(), + pull.filter(function (msg) { + if (msg.value.content.type == 'channel') { + return msg.value.content.subscribed + } + }), + pull.map(function (msg) { + return msg.value.content.channel + }), + pull.unique() + ) +} + App.prototype.createContactStreams = function (id) { return new Contacts(this.sbot).createContactStreams(id) } @@ -345,3 +428,7 @@ App.prototype.getVoted = function (_opts, cb) { }) ) } + +App.prototype.createAboutStreams = function (id) { + return this.about.createAboutStreams(id) +} diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 0000000..1360a6f --- /dev/null +++ b/lib/git.js @@ -0,0 +1,532 @@ +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 ObjectNotFoundError = u.customError('ObjectNotFoundError') + +var types = { + blob: true, + commit: true, + tree: true, +} + +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) { + 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 + 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 = [] + reader.read(1, function next(err, ch) { + if (err) return cb(err) + if (ch[0] === 0) return cb(null, Buffer.concat(chars).toString('utf8')) + chars.push(ch) + reader.read(1, next) + }) +} + +Git.prototype.readTree = function (obj) { + 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') + }) + }) + }) + } +} diff --git a/lib/render-msg.js b/lib/render-msg.js index 2a0e7df..72b8015 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() { @@ -149,6 +145,8 @@ RenderMsg.prototype.actions = function () { this.msg.rel ? [this.msg.rel, ' '] : '', this.opts.withGt && this.msg.timestamp ? [ 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') ) : [ @@ -342,7 +340,7 @@ RenderMsg.prototype.link = function (link, cb) { var ref = u.linkDest(link) if (!ref) return cb(null, '') self.getName(ref, function (err, name) { - if (err) return cb(err) + if (err) name = truncate(ref, 10) cb(null, h('a', {href: self.toUrl(ref)}, name)) }) } @@ -480,14 +478,20 @@ RenderMsg.prototype.gitUpdate = function (cb) { !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) ) @@ -500,7 +504,11 @@ RenderMsg.prototype.gitUpdate = function (cb) { h('code', String(tag.object).substr(0, 8)), ' ', String(tag.tag) ) - })) : '' + })) : '', + self.c.commits_more ? h('div', + '+ ' + self.c.commits_more + ' more commits') : '', + self.c.tags_more ? h('div', + '+ ' + self.c.tags_more + ' more tags') : '' ), cb) }) } diff --git a/lib/render.js b/lib/render.js index ea94329..102035b 100644 --- a/lib/render.js +++ b/lib/render.js @@ -34,18 +34,34 @@ MdRenderer.prototype.image = function (ref, title, text) { }).outerHTML } -MdRenderer.prototype.link = function(href, title, text) { - href = this.urltransform(href) +MdRenderer.prototype.link = function (ref, title, text) { + var href = this.urltransform(ref) var name = href && /^\/(&|%26)/.test(href) && (title || text) - return '<a' - + (href !== false - ? ' href="' + href + '"' - : ' class="bad"') - + (title ? ' title="' + title + '"' : '') - + (name ? ' download="' + encodeURIComponent(name) + '"' : '') - + '>' + text + '</a>' -}; + if (u.isRef(ref)) { + var myName = this.render.app.getNameSync(ref) + if (myName) title = title ? title + ' (' + myName + ')' : myName + } + var a = h('a', { + class: href === false ? 'bad' : undefined, + href: href !== false ? href : undefined, + title: title || undefined, + download: name ? encodeURIComponent(name) : undefined + }) + // text is already html-escaped + a.innerHTML = text + return a.outerHTML +} +MdRenderer.prototype.mention = function (preceding, id) { + var href = this.urltransform(id) + var myName = this.render.app.getNameSync(id) + if (id.length > 50) id = id.slice(0, 8) + '…' + return (preceding||'') + h('a', { + class: href === false ? 'bad' : undefined, + href: href !== false ? href : undefined, + title: myName || undefined, + }, id).outerHTML +} function lexerRenderEmoji(emoji) { var el = this.renderer.render.emoji(emoji) @@ -132,6 +148,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 @@ -186,7 +218,7 @@ Render.prototype.prepareLink = function (link, cb) { if (link.name || !link.link) cb(null, link) else this.app.getAbout(link.link, function (err, about) { if (err) return cb(null, link) - link.name = about.name || (link.link.substr(0, 8) + '…') + link.name = about.name || about.title || (link.link.substr(0, 8) + '…') if (link.name && link.name[0] === link.link[0]) { link.name = link.name.substr(1) } diff --git a/lib/serve.js b/lib/serve.js index 2f6caab..b971572 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -26,16 +26,6 @@ var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/ -function isMsgEncrypted(msg) { - var c = msg && msg.value.content - return typeof c === 'string' -} - -function isMsgReadable(msg) { - var c = msg && msg.value && msg.value.content - return typeof c === 'object' && c !== null -} - function ctype(name) { switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { case 'html': return 'text/html' @@ -129,6 +119,7 @@ Serve.prototype.go = function () { 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 next() } @@ -182,6 +173,22 @@ Serve.prototype.publishContact = function (cb) { 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.sbot.blobs.want(id, done()) + }) + 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}) @@ -219,7 +226,8 @@ Serve.prototype.respond = function (status, message) { Serve.prototype.respondSink = function (status, headers, cb) { var self = this - if (status && headers) self.res.writeHead(status, headers) + if (status || headers) + self.res.writeHead(status, headers || {'Content-Type': 'text/html'}) return toPull(self.res, cb || function (err) { if (err) self.app.error(err) }) @@ -266,6 +274,8 @@ Serve.prototype.path = function (url) { case '/static': return this.static(m[2]) case '/emoji': return this.emoji(m[2]) case '/contacts': return this.contacts(m[2]) + case '/about': return this.about(m[2]) + case '/git': return this.git(m[2]) } return this.respond(404, 'Not found') } @@ -365,9 +375,9 @@ Serve.prototype.private = function (ext) { pull( this.app.createLogStream(opts), - pull.filter(isMsgEncrypted), + pull.filter(u.isMsgEncrypted), this.app.unboxMessages(), - pull.filter(isMsgReadable), + pull.filter(u.isMsgReadable), pull.take(limit), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), @@ -538,7 +548,7 @@ Serve.prototype.votes = function (path) { cb(null, ph('tr', [ ph('td', [String(item.value)]), ph('td', [ - self.pullIdLink(item.id), + self.phIdLink(item.id), pull.once(' dug by '), self.renderIdsList()(pull.values(item.feeds)) ]) @@ -628,20 +638,48 @@ Serve.prototype.peers = function (ext) { Serve.prototype.channels = function (ext) { var self = this + var id = self.app.sbot.id + + function renderMyChannels() { + return pull( + self.app.streamMyChannels(id), + paramap(function (channel, cb) { + // var subscribed = false + cb(null, [ + h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel), + ' ' + ]) + }, 8), + pull.map(u.toHTML), + self.wrapMyChannels() + ) + } + + function renderNetworkChannels() { + return pull( + self.app.streamChannels(), + paramap(function (channel, cb) { + // var subscribed = false + cb(null, [ + h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel), + ' ' + ]) + }, 8), + pull.map(u.toHTML), + self.wrapChannels() + ) + } pull( - self.app.streamChannels(), - paramap(function (channel, cb) { - var subscribed = false - cb(null, [ - h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel), - ' ' + cat([ + ph('section', {}, [ + ph('h3', {}, 'Channels:'), + renderMyChannels(), + renderNetworkChannels() ]) - }, 8), - pull.map(u.toHTML), - self.wrapChannels(), - self.wrapPage('channels'), - self.respondSink(200, { + ]), + this.wrapPage('channels'), + this.respondSink(200, { 'Content-Type': ctype(ext) }) ) @@ -671,7 +709,7 @@ Serve.prototype.contacts = function (path) { pull( cat([ ph('section', {}, [ - ph('h3', {}, ['Contacts: ', self.pullIdLink(id)]), + ph('h3', {}, ['Contacts: ', self.phIdLink(id)]), ph('h4', {}, 'Friends'), renderFriendsList()(contacts.friends), ph('h4', {}, 'Follows'), @@ -687,9 +725,79 @@ Serve.prototype.contacts = function (path) { ) } +Serve.prototype.about = function (path) { + var self = this + var id = decodeURIComponent(String(path).substr(1)) + var abouts = self.app.createAboutStreams(id) + var render = self.app.render + + function renderAboutOpImage(link) { + if (!link) return + if (!u.isRef(link.link)) return ph('code', {}, JSON.stringify(link)) + return ph('img', { + class: 'ssb-avatar-image', + src: render.imageUrl(link.link), + alt: link.link + + (link.size ? ' (' + render.formatSize(link.size) + ')' : '') + }) + } + + function renderAboutOpValue(value) { + if (!value) return + if (u.isRef(value.link)) return self.phIdLink(value.link) + if (value.epoch) return new Date(value.epoch).toUTCString() + return ph('code', {}, JSON.stringify(value)) + } + + function renderAboutOpContent(op) { + if (op.prop === 'image') + return renderAboutOpImage(op.value) + if (op.prop === 'description') + return h('div', {innerHTML: render.markdown(op.value)}).outerHTML + if (op.prop === 'title') + return h('strong', op.value).outerHTML + if (op.prop === 'name') + return h('u', op.value).outerHTML + return renderAboutOpValue(op.value) + } + + function renderAboutOp(op) { + return ph('tr', {}, [ + ph('td', self.phIdLink(op.author)), + ph('td', + ph('a', {href: render.toUrl(op.id)}, + htime(new Date(op.timestamp)))), + ph('td', op.prop), + ph('td', renderAboutOpContent(op)) + ]) + } + + pull( + cat([ + ph('section', {}, [ + ph('h3', {}, ['About: ', self.phIdLink(id)]), + ph('table', {}, + pull(abouts.scalars, pull.map(renderAboutOp)) + ), + pull( + abouts.sets, + pull.map(function (op) { + return h('pre', JSON.stringify(op, 0, 2)) + }), + pull.map(u.toHTML) + ) + ]) + ]), + this.wrapPage('about: ' + id), + this.respondSink(200, { + 'Content-Type': ctype('html') + }) + ) +} + Serve.prototype.type = function (path) { var q = this.query - var type = path.substr(1) + var type = decodeURIComponent(path.substr(1)) var opts = { reverse: !q.forwards, lt: Number(q.lt) || Date.now(), @@ -801,7 +909,8 @@ Serve.prototype.id = function (id, ext) { if (self.query.raw != null) return self.rawId(id) this.app.getMsgDecrypted(id, function (err, rootMsg) { - if (err && err.name === 'NotFoundError') err = null, rootMsg = {key: id} + if (err && err.name === 'NotFoundError') err = null, rootMsg = { + key: id, value: {content: false}} if (err) return self.respond(500, err.stack || err) var rootContent = rootMsg && rootMsg.value && rootMsg.value.content var recps = rootContent && rootContent.recps @@ -895,26 +1004,31 @@ Serve.prototype.blob = function (id) { var self = this var blobs = self.app.sbot.blobs if (self.req.headers['if-none-match'] === id) return self.respond(304) + var done = multicb({pluck: 1, spread: true}) blobs.want(id, function (err, has) { if (err) { if (/^invalid/.test(err.message)) return self.respond(400, err.message) else return self.respond(500, err.message || err) } if (!has) return self.respond(404, 'Not found') + blobs.size(id, done()) pull( blobs.get(id), pull.map(Buffer), - ident(function (type) { - type = type && mime.lookup(type) - if (type) self.res.setHeader('Content-Type', type) - 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', id) - self.res.writeHead(200) - }), + ident(done().bind(self, null)), self.respondSink() ) + done(function (err, size, type) { + if (err) console.trace(err) + type = type && mime.lookup(type) + if (type) self.res.setHeader('Content-Type', type) + if (typeof size === 'number') self.res.setHeader('Content-Length', size) + 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', id) + self.res.writeHead(200) + }) }) } @@ -1044,6 +1158,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') } @@ -1099,6 +1227,7 @@ Serve.prototype.wrapPage = function (title, searchQ) { 'published ', self.app.render.msgLink(self.publishedMsg, done()) ) : '', + // self.note, content ))) done(cb) @@ -1106,7 +1235,7 @@ Serve.prototype.wrapPage = function (title, searchQ) { ) } -Serve.prototype.pullIdLink = function (id) { +Serve.prototype.phIdLink = function (id) { return pull( pull.once(id), this.renderIdsList() @@ -1187,7 +1316,8 @@ Serve.prototype.wrapUserFeed = function (isScrolled, id) { h('tr', h('td'), h('td', - h('a', {href: render.toUrl('/contacts/' + id)}, 'contacts') + h('a', {href: render.toUrl('/contacts/' + id)}, 'contacts'), ' ', + h('a', {href: render.toUrl('/about/' + id)}, 'about') ) ), h('tr', @@ -1222,6 +1352,422 @@ 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('pre', self.app.render.linkify(commit.body)).outerHTML, + ] + ]), + 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) { + var type = item.mode === 0040000 ? 'tree' : + item.mode === 0160000 ? 'commit' : 'blob' + if (!item.msg) return ph('tr', [ + ph('td', + u.escapeHTML(item.name) + (type === 'tree' ? '/' : '')), + ph('td', 'missing') + ]) + var path = '/git/' + type + '/' + item.hash + + '?msg=' + encodeURIComponent(item.msg.key) + 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) + (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)) + }) + ), + ]), + 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 === '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) +} + +// wrap a binary source and render it or turn into an embed +Serve.prototype.wrapBinary = function (opts) { + var self = this + return function (read) { + var readRendered, type + read = ident(function (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 h('div', + self.app.render.linkify(buf.toString('utf8')) + ).innerHTML + })(read)) + } +} + Serve.prototype.wrapPublic = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { @@ -1237,6 +1783,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)), + 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) { @@ -1368,7 +1944,21 @@ Serve.prototype.wrapChannels = function (opts) { return u.hyperwrap(function (channels, cb) { cb(null, [ h('section', - h('h3', 'Channels') + h('h4', 'Network') + ), + h('section', + channels + ) + ]) + }) +} + +Serve.prototype.wrapMyChannels = function (opts) { + var self = this + return u.hyperwrap(function (channels, cb) { + cb(null, [ + h('section', + h('h4', 'Subscribed') ), h('section', channels diff --git a/lib/util.js b/lib/util.js index fe5b4f3..d25ecaf 100644 --- a/lib/util.js +++ b/lib/util.js @@ -4,6 +4,7 @@ var h = require('hyperscript') 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,6 +63,10 @@ u.hyperwrap = function (fn) { } } +u.toLink = function (link) { + return typeof link === 'string' ? {link: link} : link +} + u.linkDest = function (link) { return typeof link === 'string' ? link : link && link.link || link } @@ -104,3 +109,63 @@ u.extractFeedIds = function (str) { }) return ids } + +u.isMsgReadable = function (msg) { + var c = msg && msg.value && msg.value.content + return typeof c === 'object' && c !== null +} + +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) { + 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))) + }) + } + } +} |