diff options
Diffstat (limited to 'lib/serve.js')
-rw-r--r-- | lib/serve.js | 1254 |
1 files changed, 1106 insertions, 148 deletions
diff --git a/lib/serve.js b/lib/serve.js index 1ce18c6..839725a 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -19,10 +19,13 @@ var mime = require('mime-types') var ident = require('pull-identify-filetype') var htime = require('human-time') var ph = require('pull-hyperscript') +var emojis = require('emoji-named-characters') +var jpeg = require('jpeg-autorotate') module.exports = Serve var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') +var hlCssDir = path.join(require.resolve('highlight.js'), '../../styles') var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))([^?]*)?|(\/.*?))(?:\?(.*))?$/ @@ -74,6 +77,11 @@ Serve.prototype.go = function () { filesCb(function (err) { gotData(err, data) }) + function addField(name, value) { + if (!(name in data)) data[name] = value + else if (Array.isArray(data[name])) data[name].push(value) + else data[name] = [data[name], value] + } busboy.on('file', function (fieldname, file, filename, encoding, mimetype) { var cb = filesCb() pull( @@ -83,15 +91,13 @@ Serve.prototype.go = function () { if (link.size === 0 && !filename) return cb() link.name = filename link.type = mimetype - data[fieldname] = link + addField(fieldname, link) cb() }) ) }) busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) { - if (!(fieldname in data)) data[fieldname] = val - else if (Array.isArray(data[fieldname])) data[fieldname].push(val) - else data[fieldname] = [data[fieldname], val] + addField(fieldname, val) }) this.req.pipe(busboy) } else { @@ -116,8 +122,10 @@ Serve.prototype.go = function () { self.data = data if (err) next(err) else if (data.action === 'publish') self.publishJSON(next) - else if (data.action === 'vote') self.publishVote(next) else if (data.action === 'contact') self.publishContact(next) + else if (data.action === 'want-blobs') self.wantBlobs(next) + else if (data.action_vote) self.publishVote(next) + else if (data.action_attend) self.publishAttend(next) else next() } @@ -154,8 +162,8 @@ Serve.prototype.publishVote = function (cb) { channel: this.data.channel || undefined, vote: { link: this.data.link, - value: Number(this.data.value), - expression: this.data.expression, + value: Number(this.data.vote_value), + expression: this.data.vote_expression, } } if (this.data.recps) content.recps = this.data.recps.split(',') @@ -171,6 +179,36 @@ Serve.prototype.publishContact = function (cb) { this.publish(content, cb) } +Serve.prototype.publishAttend = function (cb) { + var content = { + type: 'about', + channel: this.data.channel || undefined, + about: this.data.link, + attendee: { + link: this.app.sbot.id + } + } + if (this.data.recps) content.recps = this.data.recps.split(',') + this.publish(content, cb) +} + +Serve.prototype.wantBlobs = function (cb) { + var self = this + if (!self.data.blob_ids) return cb() + var ids = self.data.blob_ids.split(',') + if (!ids.every(u.isRef)) return cb(new Error('bad blob ids ' + ids.join(','))) + var done = multicb({pluck: 1}) + ids.forEach(function (id) { + self.app.wantSizeBlob(id, done()) + }) + if (self.data.async_want) return cb() + done(function (err) { + if (err) return cb(err) + // self.note = h('div', 'wanted blobs: ' + ids.join(', ') + '.') + cb() + }) +} + Serve.prototype.publish = function (content, cb) { var self = this var done = multicb({pluck: 1, spread: true}) @@ -238,14 +276,18 @@ Serve.prototype.path = function (url) { case '/new': return this.new(m[2]) case '/public': return this.public(m[2]) case '/private': return this.private(m[2]) + case '/mentions': return this.mentions(m[2]) case '/search': return this.search(m[2]) case '/advsearch': return this.advsearch(m[2]) case '/vote': return this.vote(m[2]) case '/peers': return this.peers(m[2]) + case '/status': return this.status(m[2]) case '/channels': return this.channels(m[2]) case '/friends': return this.friends(m[2]) case '/live': return this.live(m[2]) case '/compose': return this.compose(m[2]) + case '/emojis': return this.emojis(m[2]) + case '/votes': return this.votes(m[2]) } m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) switch (m[1]) { @@ -254,8 +296,13 @@ Serve.prototype.path = function (url) { case '/links': return this.links(m[2]) case '/static': return this.static(m[2]) case '/emoji': return this.emoji(m[2]) + case '/highlight': return this.highlight(m[2]) case '/contacts': return this.contacts(m[2]) case '/about': return this.about(m[2]) + case '/git': return this.git(m[2]) + case '/image': return this.image(m[2]) + case '/npm': return this.npm(m[2]) + case '/npm-readme': return this.npmReadme(m[2]) } return this.respond(404, 'Not found') } @@ -347,18 +394,13 @@ Serve.prototype.private = function (ext) { var q = this.query var opts = { reverse: !q.forwards, - sortByTimestamp: q.sort === 'claimed', lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 12, } - var limit = Number(q.limit) || 12 pull( - this.app.createLogStream(opts), - pull.filter(u.isMsgEncrypted), - this.app.unboxMessages(), - pull.filter(u.isMsgReadable), - pull.take(limit), + this.app.streamPrivate(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapPrivate(opts), @@ -369,6 +411,32 @@ Serve.prototype.private = function (ext) { ) } +Serve.prototype.mentions = function (ext) { + var self = this + var q = self.query + var opts = { + reverse: !q.forwards, + sortByTimestamp: q.sort === 'claimed', + lt: Number(q.lt) || Date.now(), + gt: Number(q.gt) || -Infinity, + limit: Number(q.limit) || 12, + } + + return pull( + ph('section', {}, [ + ph('h3', 'Mentions'), + pull( + self.app.streamMentions(opts), + self.app.unboxMessages(), + self.renderThreadPaginated(opts, null, q), + self.wrapMessages() + ) + ]), + self.wrapPage('mentions'), + self.respondSink(200) + ) +} + Serve.prototype.search = function (ext) { var searchQ = (this.query.q || '').trim() var self = this @@ -487,6 +555,90 @@ Serve.prototype.compose = function (ext) { }) } +Serve.prototype.votes = function (path) { + if (path) return pull( + pull.once(u.renderError(new Error('Not implemented')).outerHTML), + this.wrapPage('#' + channel), + this.respondSink(404, {'Content-Type': ctype('html')}) + ) + + var self = this + var q = self.query + var opts = { + reverse: !q.forwards, + limit: Number(q.limit) || 50, + } + var gt = Number(q.gt) + if (gt) opts.gt = gt + var lt = Number(q.lt) + if (lt) opts.lt = lt + + self.app.getVoted(opts, function (err, voted) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('#' + channel), + self.respondSink(500, {'Content-Type': ctype('html')}) + ) + + pull( + ph('table', [ + ph('thead', [ + ph('tr', [ + ph('td', {colspan: 2}, self.syncPager({ + first: voted.firstTimestamp, + last: voted.lastTimestamp, + })) + ]) + ]), + ph('tbody', pull( + pull.values(voted.items), + paramap(function (item, cb) { + cb(null, ph('tr', [ + ph('td', [String(item.value)]), + ph('td', [ + self.phIdLink(item.id), + pull.once(' dug by '), + self.renderIdsList()(pull.values(item.feeds)) + ]) + ])) + }, 8) + )), + ph('tfoot', {}, []), + ]), + self.wrapPage('votes'), + self.respondSink(200, { + 'Content-Type': ctype('html') + }) + ) + }) +} + +Serve.prototype.syncPager = function (opts) { + var q = this.query + var reverse = !q.forwards + var min = (reverse ? opts.last : opts.first) || Number(q.gt) + var max = (reverse ? opts.first : opts.last) || Number(q.lt) + var minDate = new Date(min) + var maxDate = new Date(max) + var qOlder = u.mergeOpts(q, {lt: min, gt: undefined, forwards: undefined}) + var qNewer = u.mergeOpts(q, {gt: max, lt: undefined, forwards: 1}) + var atNewest = reverse ? !q.lt : !max + var atOldest = reverse ? !min : !q.gt + if (atNewest && !reverse) qOlder.lt++ + if (atOldest && reverse) qNewer.gt-- + return h('div', + atOldest ? 'oldest' : [ + h('a', {href: '?' + qs.stringify(qOlder)}, '<<'), ' ', + h('span', {title: minDate.toString()}, htime(minDate)), ' ', + ], + ' - ', + atNewest ? 'now' : [ + h('span', {title: maxDate.toString()}, htime(maxDate)), ' ', + h('a', {href: '?' + qs.stringify(qNewer)}, '>>') + ] + ).outerHTML +} + Serve.prototype.peers = function (ext) { var self = this if (self.data.action === 'connect') { @@ -532,6 +684,34 @@ Serve.prototype.peers = function (ext) { ) } +Serve.prototype.status = function (ext) { + var self = this + + if (!self.app.sbot.status) return pull( + pull.once('missing sbot status method'), + this.wrapPage('status'), + self.respondSink(400) + ) + + pull( + ph('section', [ + ph('h3', 'Status'), + pull( + u.readNext(function (cb) { + self.app.sbot.status(function (err, status) { + cb(err, status && pull.once(status)) + }) + }), + pull.map(function (status) { + return h('pre', self.app.render.linkify(JSON.stringify(status, 0, 2))).outerHTML + }) + ) + ]), + this.wrapPage('status'), + this.respondSink(200) + ) +} + Serve.prototype.channels = function (ext) { var self = this var id = self.app.sbot.id @@ -581,14 +761,6 @@ Serve.prototype.channels = function (ext) { ) } -Serve.prototype.phIdLink = function (id) { - return pull( - pull.once(id), - pull.asyncMap(this.renderIdLink.bind(this)), - pull.map(u.toHTML) - ) -} - Serve.prototype.contacts = function (path) { var self = this var id = String(path).substr(1) @@ -655,7 +827,7 @@ Serve.prototype.about = function (path) { function renderAboutOpContent(op) { if (op.prop === 'image') - return renderAboutOpImage(op.value) + return renderAboutOpImage(u.toLink(op.value)) if (op.prop === 'description') return h('div', {innerHTML: render.markdown(op.value)}).outerHTML if (op.prop === 'title') @@ -772,23 +944,11 @@ Serve.prototype.channel = function (path) { lt: lt, gt: gt, limit: Number(q.limit) || 12, - query: [{$filter: { - value: {content: {channel: channel}}, - timestamp: { - $gt: gt, - $lt: lt, - } - }}] + channel: channel, } - if (!this.app.sbot.query) return pull( - pull.once(u.renderError(new Error('Missing ssb-query plugin')).outerHTML), - this.wrapPage('#' + channel), - this.respondSink(400, {'Content-Type': ctype('html')}) - ) - pull( - this.app.sbot.query.read(opts), + this.app.streamChannel(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapChannel(channel), @@ -900,9 +1060,12 @@ Serve.prototype.emoji = function (emoji) { serveEmoji(this.req, this.res, emoji) } +Serve.prototype.highlight = function (dirs) { + this.file(path.join(hlCssDir, dirs)) +} + Serve.prototype.blob = function (id, path) { var self = this - var blobs = self.app.sbot.blobs var etag = id + (path || '') if (self.req.headers['if-none-match'] === etag) return self.respond(304) var key @@ -921,22 +1084,18 @@ Serve.prototype.blob = function (id, path) { return self.respond(400, 'Bad blob request') } } - var done = multicb({pluck: 1, spread: true}) - blobs.want(id, function (err, has) { + self.app.wantSizeBlob(id, function (err, size) { if (err) { if (/^invalid/.test(err.message)) return self.respond(400, err.message) else return self.respond(500, err.message || err) } - if (!has) return self.respond(404, 'Not found') - blobs.size(id, done()) pull( self.app.getBlob(id, key), pull.map(Buffer), - ident(done().bind(self, null)), + ident(gotType), self.respondSink() ) - done(function (err, size, type) { - if (err) console.trace(err) + function gotType(type) { type = type && mime.lookup(type) if (type) self.res.setHeader('Content-Type', type) // don't serve size for encrypted blob, because it refers to the size of @@ -948,6 +1107,77 @@ Serve.prototype.blob = function (id, path) { self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('etag', etag) self.res.writeHead(200) + } + }) +} + +Serve.prototype.image = function (path) { + var self = this + var id, key + var m = urlIdRegex.exec(path) + if (m && m[2] === '&') id = m[1], path = m[3] + var etag = 'image-' + id + (path || '') + if (self.req.headers['if-none-match'] === etag) return self.respond(304) + if (path) { + path = decodeURIComponent(path) + if (path[0] === '#') { + try { + key = new Buffer(path.substr(1), 'base64') + } catch(err) { + return self.respond(400, err.message) + } + if (key.length !== 32) { + return self.respond(400, 'Bad blob key') + } + } else { + return self.respond(400, 'Bad blob request') + } + } + self.app.wantSizeBlob(id, function (err, size) { + if (err) { + if (/^invalid/.test(err.message)) return self.respond(400, err.message) + else return self.respond(500, err.message || err) + } + + var done = multicb({pluck: 1, spread: true}) + var heresTheData = done() + var heresTheType = done().bind(self, null) + + pull( + self.app.getBlob(id, key), + pull.map(Buffer), + ident(heresTheType), + pull.collect(onFullBuffer) + ) + + function onFullBuffer (err, buffer) { + if (err) return heresTheData(err) + buffer = Buffer.concat(buffer) + + jpeg.rotate(buffer, {}, function (err, rotatedBuffer, orientation) { + if (!err) buffer = rotatedBuffer + + heresTheData(null, buffer) + pull( + pull.once(buffer), + self.respondSink() + ) + }) + } + + done(function (err, data, type) { + if (err) { + console.trace(err) + self.respond(500, err.message || err) + } + type = type && mime.lookup(type) + if (type) self.res.setHeader('Content-Type', type) + self.res.setHeader('Content-Length', data.length) + if (self.query.name) self.res.setHeader('Content-Disposition', + 'inline; filename='+encodeDispositionFilename(self.query.name)) + self.res.setHeader('Cache-Control', 'public, max-age=315360000') + self.res.setHeader('etag', etag) + self.res.writeHead(200) }) }) } @@ -972,38 +1202,28 @@ Serve.prototype.renderThread = function () { ) } -function mergeOpts(a, b) { - var obj = {}, k - for (k in a) { - obj[k] = a[k] - } - for (k in b) { - if (b[k] != null) obj[k] = b[k] - else delete obj[k] - } - return obj -} - Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { var self = this function linkA(opts, name) { - var q1 = mergeOpts(q, opts) + var q1 = u.mergeOpts(q, opts) return h('a', {href: '?' + qs.stringify(q1)}, name || q1.limit) } function links(opts) { var limit = opts.limit || q.limit || 10 return h('tr', h('td.paginate', {colspan: 3}, opts.forwards ? '↑ newer ' : '↓ older ', - linkA(mergeOpts(opts, {limit: 1})), ' ', - linkA(mergeOpts(opts, {limit: 10})), ' ', - linkA(mergeOpts(opts, {limit: 100})) + linkA(u.mergeOpts(opts, {limit: 1})), ' ', + linkA(u.mergeOpts(opts, {limit: 10})), ' ', + linkA(u.mergeOpts(opts, {limit: 100})) )) } return pull( paginate( function onFirst(msg, cb) { - var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts + var num = feedId ? msg.value.sequence : + opts.sortByTimestamp ? msg.value.timestamp : + msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: num, @@ -1020,7 +1240,9 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { }, this.app.render.renderFeeds(), function onLast(msg, cb) { - var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts + var num = feedId ? msg.value.sequence : + opts.sortByTimestamp ? msg.value.timestamp : + msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: null, @@ -1056,8 +1278,13 @@ Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { } Serve.prototype.renderRawMsgPage = function (id) { + var showMarkdownSource = (this.query.raw === 'md') + var raw = !showMarkdownSource return pull( - this.app.render.renderFeeds(true), + this.app.render.renderFeeds({ + raw: raw, + markdownSource: showMarkdownSource + }), pull.map(u.toHTML), this.wrapMessages(), this.wrapPage(id) @@ -1078,6 +1305,20 @@ function catchHTMLError() { } } +function catchTextError() { + return function (read) { + var ended + return function (abort, cb) { + if (ended) return cb(ended) + read(abort, function (end, data) { + if (!end || end === true) return cb(end, data) + ended = true + cb(null, end.stack + '\n') + }) + } + } +} + function styles() { return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8') } @@ -1108,19 +1349,25 @@ Serve.prototype.wrapPage = function (title, searchQ) { h('title', title), h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}), h('link', {rel: 'icon', href: render.toUrl('/static/hermie.ico'), type: 'image/x-icon'}), - h('style', styles()) + h('style', styles()), + h('link', {rel: 'stylesheet', href: render.toUrl('/highlight/foundation.css')}) ), h('body', h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'}, h('a', {href: render.toUrl('/new')}, 'new') , ' ', h('a', {href: render.toUrl('/public')}, 'public'), ' ', h('a', {href: render.toUrl('/private')}, 'private') , ' ', + h('a', {href: render.toUrl('/mentions')}, 'mentions') , ' ', h('a', {href: render.toUrl('/peers')}, 'peers') , ' ', + self.app.sbot.status ? + [h('a', {href: render.toUrl('/status')}, 'status'), ' '] : '', h('a', {href: render.toUrl('/channels')}, 'channels') , ' ', h('a', {href: render.toUrl('/friends')}, 'friends'), ' ', h('a', {href: render.toUrl('/advsearch')}, 'search'), ' ', h('a', {href: render.toUrl('/live')}, 'live'), ' ', h('a', {href: render.toUrl('/compose')}, 'compose'), ' ', + h('a', {href: render.toUrl('/votes')}, 'votes'), ' ', + h('a', {href: render.toUrl('/emojis')}, 'emojis'), ' ', render.idLink(self.app.sbot.id, done()), ' ', h('input.search-input', {name: 'q', value: searchQ, placeholder: 'search'}) @@ -1132,6 +1379,7 @@ Serve.prototype.wrapPage = function (title, searchQ) { 'published ', self.app.render.msgLink(self.publishedMsg, done()) ) : '', + // self.note, content ))) done(cb) @@ -1139,25 +1387,18 @@ Serve.prototype.wrapPage = function (title, searchQ) { ) } -Serve.prototype.renderIdLink = function (id, cb) { - var render = this.app.render - var el = render.idLink(id, function (err) { - if (err || !el) { - el = h('a', {href: render.toUrl(id)}, id) - } - cb(null, el) - }) +Serve.prototype.phIdLink = function (id) { + return pull( + pull.once(id), + this.renderIdsList() + ) } Serve.prototype.friends = function (path) { var self = this pull( self.app.sbot.friends.createFriendStream({hops: 1}), - self.renderFriends(), - pull.map(function (el) { - return [el, ' '] - }), - pull.map(u.toHTML), + self.renderIdsList(), u.hyperwrap(function (items, cb) { cb(null, [ h('section', @@ -1173,14 +1414,17 @@ Serve.prototype.friends = function (path) { ) } -Serve.prototype.renderFriends = function () { +Serve.prototype.renderIdsList = function () { var self = this - return paramap(function (id, cb) { - self.renderIdLink(id, function (err, el) { - if (err) el = u.renderError(err, ext) - cb(null, el) - }) - }, 8) + return pull( + paramap(function (id, cb) { + self.app.render.getNameLink(id, cb) + }, 8), + pull.map(function (el) { + return [el, ' '] + }), + pull.map(u.toHTML) + ) } var relationships = [ @@ -1260,6 +1504,532 @@ Serve.prototype.wrapUserFeed = function (isScrolled, id) { }) } +Serve.prototype.git = function (url) { + var m = /^\/?([^\/]*)\/?(.*)?$/.exec(url) + switch (m[1]) { + case 'commit': return this.gitCommit(m[2]) + case 'tag': return this.gitTag(m[2]) + case 'tree': return this.gitTree(m[2]) + case 'blob': return this.gitBlob(m[2]) + case 'raw': return this.gitRaw(m[2]) + default: return this.respond(404, 'Not found') + } +} + +Serve.prototype.gitRaw = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + pull.once('\'' + rev + '\' is not a git object id'), + self.respondSink(400, {'Content-Type': 'text/plain'}) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(err.stack), + self.respondSink(400, {'Content-Type': 'text/plain'}) + ) + pull( + self.app.git.readObject(obj), + catchTextError(), + ident(function (type) { + type = type && mime.lookup(type) + if (type) self.res.setHeader('Content-Type', type) + self.res.setHeader('Cache-Control', 'public, max-age=315360000') + self.res.setHeader('etag', rev) + self.res.writeHead(200) + }), + self.respondSink() + ) + }) +} + +Serve.prototype.gitAuthorLink = function (author) { + if (author.feed) { + var myName = this.app.getNameSync(author.feed) + var sigil = author.name === author.localpart ? '@' : '' + return ph('a', { + href: this.app.render.toUrl(author.feed), + title: author.localpart + (myName ? ' (' + myName + ')' : '') + }, u.escapeHTML(sigil + author.name)) + } else { + return ph('a', {href: this.app.render.toUrl('mailto:' + author.email)}, + u.escapeHTML(author.name)) + } +} + +Serve.prototype.gitCommit = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + self.app.git.getCommit(obj, function (err, commit) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git commit ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' pushed ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : [ + ph('div', [ + self.gitAuthorLink(commit.committer), + ' committed ', + ph('span', {title: commit.committer.date.toLocaleString()}, + htime(commit.committer.date)), + ' in ', commit.committer.tz + ]), + commit.author ? ph('div', [ + self.gitAuthorLink(commit.author), + ' authored ', + ph('span', {title: commit.author.date.toLocaleString()}, + htime(commit.author.date)), + ' in ', commit.author.tz + ]) : '', + commit.parents.length ? ph('div', ['parents: ', pull( + pull.values(commit.parents), + self.gitObjectLinks(obj.msg.key, 'commit') + )]) : '', + commit.tree ? ph('div', ['tree: ', pull( + pull.once(commit.tree), + self.gitObjectLinks(obj.msg.key, 'tree') + )]) : '', + h('blockquote', + self.app.render.gitCommitBody(commit.body)).outerHTML, + ph('h4', 'files'), + ph('table', pull( + self.app.git.readCommitChanges(commit), + pull.map(function (file) { + return ph('tr', [ + ph('td', ph('code', u.escapeHTML(file.name))), + // ph('td', ph('code', u.escapeHTML(JSON.stringify(file.msg)))), + ph('td', file.deleted ? 'deleted' + : file.created ? 'created' + : file.hash ? 'changed' + : file.mode ? 'mode changed' + : JSON.stringify(file)) + ]) + }) + )) + ] + ]), + self.wrapPage('git commit ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) + }) +} + +Serve.prototype.gitTag = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + if (err && err.name === 'BlobNotFoundError') + return self.askWantBlobs(err.links) + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + self.app.git.getTag(obj, function (err, tag) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tag ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' pushed ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : [ + ph('div', [ + self.gitAuthorLink(tag.tagger), + ' tagged ', + ph('span', {title: tag.tagger.date.toLocaleString()}, + htime(tag.tagger.date)), + ' in ', tag.tagger.tz + ]), + tag.type, ' ', + pull( + pull.once(tag.object), + self.gitObjectLinks(obj.msg.key, tag.type) + ), ' ', + ph('code', u.escapeHTML(tag.tag)), + h('pre', self.app.render.linkify(tag.body)).outerHTML, + ] + ]), + self.wrapPage('git tag ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) + }) +} + +Serve.prototype.gitTree = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + + self.app.git.openObject({ + obj: rev, + msg: self.query.msg, + }, function (err, obj) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git tree ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(obj.msg.value.timestamp) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(obj.msg.value.author), ' ', + ph('a', { + href: self.app.render.toUrl(obj.msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : ph('table', [ + pull( + self.app.git.readTree(obj), + paramap(function (file, cb) { + self.app.git.getObjectMsg({ + obj: file.hash, + headMsgId: obj.msg.key, + }, function (err, msg) { + if (err && err.name === 'ObjectNotFoundError') return cb(null, file) + if (err) return cb(err) + file.msg = msg + cb(null, file) + }) + }, 8), + pull.map(function (item) { + if (!item.msg) return ph('tr', [ + ph('td', + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : '')), + ph('td', u.escapeHTML(item.hash)), + ph('td', 'missing') + ]) + var ext = item.name.replace(/.*\./, '') + var path = '/git/' + item.type + '/' + item.hash + + '?msg=' + encodeURIComponent(item.msg.key) + + (ext ? '&ext=' + ext : '') + var fileDate = new Date(item.msg.value.timestamp) + return ph('tr', [ + ph('td', + ph('a', {href: self.app.render.toUrl(path)}, + u.escapeHTML(item.name) + (item.type === 'tree' ? '/' : ''))), + ph('td', + self.phIdLink(item.msg.value.author)), + ph('td', + ph('a', { + href: self.app.render.toUrl(item.msg.key), + title: fileDate.toLocaleString(), + }, htime(fileDate)) + ), + ]) + }) + ) + ]), + ]), + self.wrapPage('git tree ' + rev), + self.respondSink(missingBlobs ? 409 : 200) + ) + }) +} + +Serve.prototype.gitBlob = function (rev) { + var self = this + if (!/[0-9a-f]{24}/.test(rev)) { + return pull( + ph('div.error', 'rev is not a git object id'), + self.wrapPage('git'), + self.respondSink(400) + ) + } + if (!u.isRef(self.query.msg)) return pull( + ph('div.error', 'missing message id'), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + + self.app.getMsgDecrypted(self.query.msg, function (err, msg) { + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + var msgDate = new Date(msg.value.timestamp) + self.app.git.openObject({ + obj: rev, + msg: msg.key, + }, function (err, obj) { + var missingBlobs + if (err && err.name === 'BlobNotFoundError') + missingBlobs = err.links, err = null + if (err) return pull( + pull.once(u.renderError(err).outerHTML), + self.wrapPage('git object ' + rev), + self.respondSink(400) + ) + pull( + ph('section', [ + ph('h3', ph('a', {href: ''}, rev)), + ph('div', [ + self.phIdLink(msg.value.author), ' ', + ph('a', { + href: self.app.render.toUrl(msg.key), + title: msgDate.toLocaleString(), + }, htime(msgDate)) + ]), + missingBlobs ? self.askWantBlobsForm(missingBlobs) : pull( + self.app.git.readObject(obj), + self.wrapBinary({ + rawUrl: self.app.render.toUrl('/git/raw/' + rev + + '?msg=' + encodeURIComponent(msg.key)), + ext: self.query.ext + }) + ), + ]), + self.wrapPage('git blob ' + rev), + self.respondSink(200) + ) + }) + }) +} + +Serve.prototype.gitObjectLinks = function (headMsgId, type) { + var self = this + return paramap(function (id, cb) { + self.app.git.getObjectMsg({ + obj: id, + headMsgId: headMsgId, + type: type, + }, function (err, msg) { + if (err && err.name === 'BlobNotFoundError') + return cb(null, self.askWantBlobsForm(err.links)) + if (err && err.name === 'ObjectNotFoundError') + return cb(null, [ + ph('code', u.escapeHTML(id.substr(0, 8))), '(missing)']) + if (err) return cb(err) + var path = '/git/' + type + '/' + id + + '?msg=' + encodeURIComponent(msg.key) + cb(null, [ph('code', ph('a', { + href: self.app.render.toUrl(path) + }, u.escapeHTML(id.substr(0, 8)))), ' ']) + }) + }, 8) +} + +Serve.prototype.npm = function (url) { + var self = this + var parts = url.split('/') + var author = parts[1] && parts[1][0] === '@' + ? u.unescapeId(parts.splice(1, 1)[0]) : null + var name = parts[1] + var version = parts[2] + var distTag = parts[3] + var prefix = 'npm:' + + (name ? name + ':' + + (version ? version + ':' + + (distTag ? distTag + ':' : '') : '') : '') + + var render = self.app.render + var base = '/npm/' + (author ? u.escapeId(author) + '/' : '') + var pathWithoutAuthor = '/npm' + + (name ? '/' + name + + (version ? '/' + version + + (distTag ? '/' + distTag : '') : '') : '') + return pull( + ph('section', {}, [ + ph('h3', [ph('a', {href: render.toUrl('/npm/')}, 'npm'), ' : ', + author ? [ + self.phIdLink(author), ' ', + ph('sub', ph('a', {href: render.toUrl(pathWithoutAuthor)}, '×')), + ' : ' + ] : '', + name ? [ph('a', {href: render.toUrl(base + name)}, name), ' : '] : '', + version ? [ph('a', {href: render.toUrl(base + name + '/' + version)}, version), ' : '] : '', + distTag ? [ph('a', {href: render.toUrl(base + name + '/' + version + '/' + distTag)}, distTag)] : '' + ]), + ph('table', [ + ph('thead', ph('tr', [ + ph('td', 'publisher'), + ph('td', 'package'), + ph('td', 'version'), + ph('td', 'tag'), + ph('td', 'size'), + ph('td', 'tarball'), + ph('td', 'readme') + ])), + ph('tbody', pull( + self.app.blobMentions({ + name: {$prefix: prefix}, + author: author, + }), + distTag && !version && pull.filter(function (link) { + return link.name.split(':')[3] === distTag + }), + paramap(function (link, cb) { + self.app.render.npmPackageMention(link, { + withAuthor: true, + author: author, + name: name, + version: version, + distTag: distTag, + }, cb) + }, 4), + pull.map(u.toHTML) + )) + ]) + ]), + self.wrapPage(prefix), + self.respondSink(200) + ) +} + +Serve.prototype.npmReadme = function (url) { + var self = this + var id = decodeURIComponent(url.substr(1)) + return pull( + ph('section', {}, [ + ph('h3', [ + 'npm readme for ', + ph('a', {href: '/links/' + id}, id.substr(0, 8) + '…') + ]), + ph('blockquote', u.readNext(function (cb) { + self.app.getNpmReadme(id, function (err, readme, isMarkdown) { + if (err) return cb(null, ph('div', u.renderError(err).outerHTML)) + cb(null, isMarkdown + ? ph('div', self.app.render.markdown(readme)) + : ph('pre', readme)) + }) + })) + ]), + self.wrapPage('npm readme'), + self.respondSink(200) + ) +} + +// wrap a binary source and render it or turn into an embed +Serve.prototype.wrapBinary = function (opts) { + var self = this + var ext = opts.ext + return function (read) { + var readRendered, type + read = ident(function (_ext) { + if (_ext) ext = _ext + type = ext && mime.lookup(ext) || 'text/plain' + })(read) + return function (abort, cb) { + if (readRendered) return readRendered(abort, cb) + if (abort) return read(abort, cb) + if (!type) read(null, function (end, buf) { + if (end) return cb(end) + if (!type) return cb(new Error('unable to get type')) + readRendered = pickSource(type, cat([pull.once(buf), read])) + readRendered(null, cb) + }) + } + } + function pickSource(type, read) { + if (/^image\//.test(type)) { + read(true, function (err) { + if (err && err !== true) console.trace(err) + }) + return ph('img', { + src: opts.rawUrl + }) + } + return ph('pre', pull.map(function (buf) { + return self.app.render.highlight(buf.toString('utf8'), ext) + })(read)) + } +} + Serve.prototype.wrapPublic = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { @@ -1275,6 +2045,36 @@ Serve.prototype.wrapPublic = function (opts) { }) } +Serve.prototype.askWantBlobsForm = function (links) { + var self = this + return ph('form', {action: '', method: 'post'}, [ + ph('section', [ + ph('h3', 'Missing blobs'), + ph('p', 'The application needs these blobs to continue:'), + ph('table', links.map(u.toLink).map(function (link) { + if (!u.isRef(link.link)) return + return ph('tr', [ + ph('td', ph('code', link.link)), + !isNaN(link.size) ? ph('td', self.app.render.formatSize(link.size)) : '', + ]) + })), + ph('input', {type: 'hidden', name: 'action', value: 'want-blobs'}), + ph('input', {type: 'hidden', name: 'blob_ids', + value: links.map(u.linkDest).join(',')}), + ph('p', ph('input', {type: 'submit', value: 'Want Blobs'})) + ]) + ]) +} + +Serve.prototype.askWantBlobs = function (links) { + var self = this + pull( + self.askWantBlobsForm(links), + self.wrapPage('missing blobs'), + self.respondSink(409) + ) +} + Serve.prototype.wrapPrivate = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { @@ -1438,6 +2238,7 @@ Serve.prototype.composer = function (opts, cb) { var self = this opts = opts || {} var data = self.data + var myId = self.app.sbot.id var blobs = u.tryDecodeJSON(data.blobs) || {} if (data.upload && typeof data.upload === 'object') { @@ -1459,6 +2260,19 @@ Serve.prototype.composer = function (opts, cb) { formNames[mentionNames[i]] = u.extractFeedIds(mentionIds[i])[0] } + var formEmojiNames = {} + var emojiIds = u.toArray(data.emoji_id) + var emojiNames = u.toArray(data.emoji_name) + for (var i = 0; i < emojiIds.length && i < emojiNames.length; i++) { + var upload = data['emoji_upload_' + i] + formEmojiNames[emojiNames[i]] = + (upload && upload.link) || u.extractBlobIds(emojiIds[i])[0] + if (upload) blobs[upload.link] = { + type: upload.type, + size: upload.size, + } + } + if (data.upload) { var href = data.upload.link + (data.upload.key ? '#' + data.upload.key : '') @@ -1471,7 +2285,8 @@ Serve.prototype.composer = function (opts, cb) { // get bare feed names var unknownMentionNames = {} - var unknownMentions = ssbMentions(data.text, {bareFeedNames: true}) + var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) + var unknownMentions = mentions .filter(function (mention) { return mention.link === '@' }) @@ -1484,6 +2299,26 @@ Serve.prototype.composer = function (opts, cb) { return {name: name, id: id} }) + var emoji = mentions + .filter(function (mention) { return mention.emoji }) + .map(function (mention) { return mention.name }) + .filter(uniques()) + .map(function (name) { + // 1. check emoji-image mapping for this message + var id = formEmojiNames[name] + if (id) return {name: name, id: id} + // 2. TODO: check user's preferred emoji-image mapping + // 3. check builtin emoji + var link = self.getBuiltinEmojiLink(name) + if (link) { + return {name: name, id: link.link} + blobs[id] = {type: link.type, size: link.size} + } + // 4. check recently seen emoji + id = self.app.getReverseEmojiNameSync(name) + return {name: name, id: id} + }) + // strip content other than feed ids from the recps field if (data.recps) { data.recps = u.extractFeedIds(data.recps).filter(uniques()).join(', ') @@ -1522,7 +2357,19 @@ Serve.prototype.composer = function (opts, cb) { h('input', {name: 'mention_name', type: 'hidden', value: mention.name}), h('input.id-input', {name: 'mention_id', size: 60, - value: mention.id, placeholder: 'id'})) + value: mention.id, placeholder: '@id'})) + })) + ] : '', + emoji.length > 0 ? [ + h('div', h('em', 'emoji:')), + h('ul.mentions', emoji.map(function (link, i) { + return h('li', + h('code', link.name), ': ', + h('input', {name: 'emoji_name', type: 'hidden', + value: link.name}), + h('input.id-input', {name: 'emoji_id', size: 60, + value: link.id, placeholder: '&id'}), ' ', + h('input', {type: 'file', name: 'emoji_upload_' + i})) })) ] : '', h('table.ssb-msgs', @@ -1544,75 +2391,132 @@ Serve.prototype.composer = function (opts, cb) { )) done(cb) + function prepareContent(cb) { + var done = multicb({pluck: 1}) + content = { + type: 'post', + text: String(data.text).replace(/\r\n/g, '\n'), + } + var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) + .filter(function (mention) { + if (mention.emoji) { + mention.link = formEmojiNames[mention.name] + if (!mention.link) { + var link = self.getBuiltinEmojiLink(mention.name) + if (link) { + mention.link = link.link + mention.size = link.size + mention.type = link.type + } else { + mention.link = self.app.getReverseEmojiNameSync(mention.name) + if (!mention.link) return false + } + } + } + var blob = blobs[mention.link] + if (blob) { + if (!isNaN(blob.size)) + mention.size = blob.size + if (blob.type && blob.type !== 'application/octet-stream') + mention.type = blob.type + } else if (mention.link === '@') { + // bare feed name + var name = mention.name + var id = formNames[name] || self.app.getReverseNameSync('@' + name) + if (id) mention.link = id + else return false + } + if (mention.link && mention.link[0] === '&' && mention.size == null) { + var linkCb = done() + self.app.sbot.blobs.size(mention.link, function (err, size) { + if (!err && size != null) mention.size = size + linkCb() + }) + } + return true + }) + if (mentions.length) content.mentions = mentions + if (data.recps != null) { + if (opts.recps) return cb(new Error('got recps in opts and data')) + content.recps = [myId] + u.extractFeedIds(data.recps).forEach(function (recp) { + if (content.recps.indexOf(recp) === -1) content.recps.push(recp) + }) + } else { + if (opts.recps) content.recps = opts.recps + } + if (data.fork_thread) { + content.root = opts.post || undefined + content.branch = u.fromArray(opts.postBranches) || undefined + } else { + content.root = opts.root || undefined + content.branch = u.fromArray(opts.branches) || undefined + } + if (channel) content.channel = data.channel + + done(function (err) { + cb(err, content) + }) + } + function preview(raw, cb) { - var myId = self.app.sbot.id + var msgContainer = h('table.ssb-msgs') + var contentInput = h('input', {type: 'hidden', name: 'content'}) + var warningsContainer = h('div') + var content - try { - content = JSON.parse(data.text) - } catch (err) { - data.text = String(data.text).replace(/\r\n/g, '\n') - content = { - type: 'post', - text: data.text, - } - var mentions = ssbMentions(data.text, {bareFeedNames: true}) - .filter(function (mention) { - var blob = blobs[mention.link] - if (blob) { - if (!isNaN(blob.size)) - mention.size = blob.size - if (blob.type && blob.type !== 'application/octet-stream') - mention.type = blob.type - } else if (mention.link === '@') { - // bare feed name - var name = mention.name - var id = formNames[name] || self.app.getReverseNameSync('@' + name) - if (id) mention.link = id - else return false - } - return true - }) - if (mentions.length) content.mentions = mentions - if (data.recps != null) { - if (opts.recps) return cb(new Error('got recps in opts and data')) - content.recps = [myId] - u.extractFeedIds(data.recps).forEach(function (recp) { - if (content.recps.indexOf(recp) === -1) content.recps.push(recp) - }) - } else { - if (opts.recps) content.recps = opts.recps - } - if (data.fork_thread) { - content.root = opts.post || undefined - content.branch = u.fromArray(opts.postBranches) || undefined - } else { - content.root = opts.root || undefined - content.branch = u.fromArray(opts.branches) || undefined + try { content = JSON.parse(data.text) } + catch (err) {} + if (content) gotContent(null, content) + else prepareContent(gotContent) + + function gotContent(err, content) { + if (err) return cb(err) + contentInput.value = JSON.stringify(content) + var msg = { + value: { + author: myId, + timestamp: Date.now(), + content: content + } } - if (channel) content.channel = data.channel - } - var msg = { - value: { - author: myId, - timestamp: Date.now(), - content: content + if (content.recps) msg.value.private = true + + var warnings = [] + u.toLinkArray(content.mentions).forEach(function (link) { + if (link.emoji && link.size >= 10e3) { + warnings.push(h('li', + 'emoji ', h('q', link.name), + ' (', h('code', String(link.link).substr(0, 8) + '…'), ')' + + ' is >10KB')) + } else if (link.link[0] === '&' && link.size >= 10e6 && link.type) { + // if link.type is set, we probably just uploaded this blob + warnings.push(h('li', + 'attachment ', + h('code', String(link.link).substr(0, 8) + '…'), + ' is >10MB')) + } + }) + if (warnings.length) { + warningsContainer.appendChild(h('div', h('em', 'warning:'))) + warningsContainer.appendChild(h('ul.mentions', warnings)) } + + pull( + pull.once(msg), + self.app.unboxMessages(), + self.app.render.renderFeeds(raw), + pull.drain(function (el) { + msgContainer.appendChild(h('tbody', el)) + }, cb) + ) } - if (content.recps) msg.value.private = true - var msgContainer = h('table.ssb-msgs') - pull( - pull.once(msg), - self.app.unboxMessages(), - self.app.render.renderFeeds(raw), - pull.drain(function (el) { - msgContainer.appendChild(h('tbody', el)) - }, cb) - ) + return [ - h('input', {type: 'hidden', name: 'content', - value: JSON.stringify(content)}), + contentInput, opts.redirectToPublishedMsg ? h('input', {type: 'hidden', name: 'redirect_to_published_msg', value: '1'}) : '', + warningsContainer, h('div', h('em', 'draft:')), msgContainer, h('div.composer-actions', @@ -1622,3 +2526,57 @@ Serve.prototype.composer = function (opts, cb) { } } + +function hashBuf(buf) { + var hash = crypto.createHash('sha256') + hash.update(buf) + return '&' + hash.digest('base64') + '.sha256' +} + +Serve.prototype.getBuiltinEmojiLink = function (name) { + if (!(name in emojis)) return + var file = path.join(emojiDir, name + '.png') + var fileBuf = fs.readFileSync(file) + var id = hashBuf(fileBuf) + // seed the builtin emoji + pull(pull.once(fileBuf), this.app.sbot.blobs.add(id, function (err) { + if (err) console.error('error adding builtin emoji as blob', err) + })) + return { + link: id, + type: 'image/png', + size: fileBuf.length, + } +} + +Serve.prototype.emojis = function (path) { + var self = this + var seen = {} + pull( + ph('section', [ + ph('h3', 'Emojis'), + ph('ul', {class: 'mentions'}, pull( + self.app.streamEmojis(), + pull.map(function (emoji) { + if (!seen[emoji.name]) { + // cache the first use, so that our uses take precedence over other feeds' + self.app.reverseEmojiNameCache.set(emoji.name, emoji.link) + seen[emoji.name] = true + } + return ph('li', [ + ph('a', {href: self.app.render.toUrl('/links/' + emoji.link)}, + ph('img', { + class: 'ssb-emoji', + src: self.app.render.imageUrl(emoji.link), + size: 32, + }) + ), ' ', + u.escapeHTML(emoji.name) + ]) + }) + )) + ]), + this.wrapPage('emojis'), + this.respondSink(200) + ) +} |