aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/about.js114
-rw-r--r--lib/app.js109
-rw-r--r--lib/git.js532
-rw-r--r--lib/render-msg.js32
-rw-r--r--lib/render.js54
-rw-r--r--lib/serve.js670
-rw-r--r--lib/util.js65
-rw-r--r--package.json3
8 files changed, 1504 insertions, 75 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)
+ })
+ )
+}
diff --git a/lib/app.js b/lib/app.js
index e503443..03df277 100644
--- a/lib/app.js
+++ b/lib/app.js
@@ -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, '&lt;')
+ .replace(/>/g, '&gt;')
+}
+
+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)))
+ })
+ }
+ }
+}
diff --git a/package.json b/package.json
index 1bc5812..2a78964 100644
--- a/package.json
+++ b/package.json
@@ -13,13 +13,14 @@
"mime-types": "^2.1.12",
"multicb": "^1.2.1",
"pull-cat": "^1.1.11",
+ "pull-git-packidx-parser": "^1.0.0",
"pull-hash": "^1.0.0",
"pull-hyperscript": "^0.2.2",
"pull-identify-filetype": "^1.1.0",
"pull-paginate": "^1.0.0",
"pull-paramap": "^1.2.1",
+ "pull-reader": "^1.2.9",
"pull-stream": "^3.5.0",
- "ssb-avatar": "^0.2.0",
"ssb-contact": "^1.0.0",
"ssb-marked": "^0.7.1",
"ssb-mentions": "^0.2.0",