var fs = require('fs') var qs = require('querystring') var pull = require('pull-stream') var path = require('path') var paramap = require('pull-paramap') var sort = require('ssb-sort') var crypto = require('crypto') var toPull = require('stream-to-pull-stream') var u = require('./util') var cat = require('pull-cat') var h = require('hyperscript') var paginate = require('pull-paginate') var ssbMentions = require('../vendor/ssb-mentions') var multicb = require('multicb') var pkg = require('../package') var Busboy = require('busboy') var mime = require('mime-types') var ident = require('pull-identify-filetype') var htime = require('human-time') var ph = require('pull-hyperscript') var jpeg = require('jpeg-autorotate') var Catch = require('pull-catch') var Diff = require('diff') var split = require('pull-split') var utf8 = require('pull-utf8-decoder') var webresolve = require('ssb-web-resolver') var Url = require('url') var many = require('pull-many') var merge = require('pull-merge') var pSort = require('pull-sort') var channels = require("./logbook-lib"); // var channels = require("./channels-lib"); module.exports = Serve var hlCssDir = path.join(require.resolve('highlight.js'), '../../styles') var urlIdRegex = /^(?:\/+(([%&@]|%25|%26)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))([^?]*)?|(\/.*?))(?:\?(.*))?$/ function ctype(name) { switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { case 'html': return 'text/html' case 'txt': return 'text/plain' case 'js': return 'text/javascript' case 'css': return 'text/css' case 'png': return 'image/png' case 'json': return 'application/json' case 'ico': return 'image/x-icon' } return 'text/html' } function encodeDispositionFilename(fname) { fname = String(fname).replace(/\/g/, '\\\\').replace(/"/, '\\\"') return '"' + encodeURIComponent(fname) + '"' } function uniques() { var set = {} return function (item) { if (set[item]) return false return set[item] = true } } function reduceLink(link) { return link && typeof link.link === 'string' && typeof link.name === 'undefined' && Object.keys(link).length === 1 ? link.link : link } function Serve(app, req, res) { this.app = app this.req = req this.res = res this.startDate = new Date() var hostname = req.headers.host || app.hostname this.baseUrl = 'http://' + hostname + (app.opts.base || '/') } Serve.prototype.go = function () { console.log(this.req.method, this.req.url) var self = this this.res.setTimeout(0) var conf = self.app.config.patchfoo || {} this.conf = conf var authtok = conf.auth || null if (authtok) { var auth = this.req.headers['authorization'] var tok = null //console.log('Authorization: ',auth) if (auth) { var a = auth.split(' ') if (a[0] == 'Basic') { tok = Buffer.from(a[1],'base64').toString('ascii') } } if (tok != authtok) { self.res.writeHead(401, {'WWW-Authenticate': 'Basic realm="Patchfoo"'}) self.res.end('Not authorized') return } } var allowAddresses = conf.allowAddresses if (allowAddresses) { var ip = this.req.socket.remoteAddress if (allowAddresses.indexOf(ip) === -1) { this.res.writeHead(403) return this.res.end('Not authorized') } } if (!this.app.isAllowedHostHeader(this.req.headers.host)) { console.error('Host header not allowed: "' + this.req.headers.host + '"') this.res.writeHead(403) return this.res.end('Forbidden') } this.replyMentionFeeds = conf.replyMentionFeeds == null ? true : Boolean(conf.replyMentionFeeds) if (this.req.method === 'POST' || this.req.method === 'PUT') { var referer = this.req.headers.referer var refererPath = this.app.getRefererPath(referer) if (!refererPath) { if (!referer) console.error('Missing referer') else console.error('Referer not allowed: "' + referer + '"') this.res.writeHead(403) return this.res.end('Forbidden') } if (this.isUnsafePath(refererPath)) { console.error('Unsafe referer path not allowed: "' + refererPath + '"') this.res.writeHead(403) return this.res.end('Forbidden') } if (/^multipart\/form-data/.test(this.req.headers['content-type'])) { var data = {} var erred var busboy = new Busboy({headers: this.req.headers}) var filesCb = multicb({pluck: 1}) busboy.on('finish', filesCb()) filesCb(function (err) { gotData(err, data) }) function addField(name, value) { if (typeof value === 'string') value = value.replace(/\r\n/g, '\n') 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() var size = 0 pull( toPull(file), pull.map(function (data) { size += data.length return data }), self.app.addBlob(!!data.private, function (err, link) { if (err) return cb(err) if (size === 0 && !filename) return cb() link.name = filename link.type = mimetype addField(fieldname, link) cb() }) ) }) busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) { addField(fieldname, val) }) this.req.pipe(busboy) } else { pull( toPull(this.req), pull.collect(function (err, bufs) { var data if (!err) try { var str = Buffer.concat(bufs).toString('utf8') str = str.replace(/%0D%0A/ig, '\n') data = qs.parse(str) } catch(e) { err = e } gotData(err, data) }) ) } } else { gotData(null, {}) } function gotData(err, data) { self.data = data if (err) next(err) else if (data.action === 'publish' && data.about_new_content) self.publishNewAbout(next) else if (data.action === 'publish') self.publishJSON(next) else if (data.action === 'contact') self.publishContact(next) else if (data.action === 'want-blobs') self.wantBlobs(next) else if (data.action === 'poll-position') self.publishPollPosition(next) else if (data.action_vote) self.publishVote(next) else if (data.action_attend) self.publishAttend(next) else next() } function next(err, publishedMsg) { if (err) { if (err.redirectUrl) return self.redirect(err.redirectUrl) self.res.writeHead(400, {'Content-Type': 'text/plain'}) self.res.end(err.stack) } else if (publishedMsg) { if (self.data.redirect_to_published_msg) { self.redirect(self.app.render.toUrl(publishedMsg.key)) } else { var u = Url.parse(self.req.url) var q = u.query || (u.query = {}) q.published = publishedMsg.key self.redirect(self.app.render.toUrl(Url.format(u))) } } else { self.handle() } } } Serve.prototype.isUnsafePath = function (path) { return typeof path !== 'string' || /^&|^%26/.test(path) || path.indexOf('../') !== -1 || path.startsWith('/web/') || path.startsWith('/npm-readme/') } Serve.prototype.saveDraft = function (content, cb) { var self = this var data = self.data var form = {} for (var k in data) { if (k === 'url' || k === 'draft_id' || k === 'content' || k === 'draft_id' || k === 'save_draft') continue form[k] = data[k] } self.app.saveDraft(data.draft_id, data.url, form, content, function (err, id) { if (err) return cb(err) cb(null, id || data.draft_id) }) } Serve.prototype.publishJSON = function (cb) { var content try { content = JSON.parse(this.data.content) } catch(e) { return cb(e) } this.publish(content, cb) } Serve.prototype.publishNewAbout = function (cb) { var self = this var aboutContent, aboutNewContent try { aboutContent = JSON.parse(this.data.content) aboutNewContent = JSON.parse(this.data.about_new_content) } catch(e) { return cb(e) } if (typeof aboutContent !== 'object' || aboutContent === null) { return cb(new TypeError('content must be object')) } aboutNewContent.recps = aboutContent.recps self.app.publish(aboutNewContent, function (err, msg) { if (err) return cb(err) if (!msg) return cb(new Error('aborted')) aboutContent.about = msg.key self.publish(aboutContent, cb) }) } Serve.prototype.publishVote = function (next) { var content = { type: 'vote', channel: this.data.channel || undefined, root: this.data.root || undefined, branch: this.data.branches ? this.data.branches.split(',') : undefined, vote: { link: this.data.link, value: Number(this.data.vote_value), expression: this.data.vote_expression || undefined, } } if (this.data.recps) content.recps = this.data.recps.split(',') if (this.app.previewVotes) { var json = JSON.stringify(content, 0, 2) var q = qs.stringify({text: json, action: 'preview'}) var url = this.app.render.toUrl('/compose?' + q) this.redirect(url) } else { this.publish(content, next) } } Serve.prototype.requestReplicate = function (id, replicateIt, next) { var self = this var replicate = self.app.sbot.replicate var request = replicate && replicate.request if (!request) return this.respond(500, 'Missing replicate.request method') request(id, replicateIt, function (err) { if (err) return pull( pull.once(u.renderError(err, ext).outerHTML), self.wrapPage('replicate request'), self.respondSink(400) ) self.requestedReplicate = replicateIt next() }) } Serve.prototype.publishContact = function (next) { if (this.data.replicate) return this.requestReplicate(this.data.contact, true, next) if (this.data.unreplicate) return this.requestReplicate(this.data.contact, false, next) if (this.data.block1) return this.redirect(this.app.render.toUrl('/block/' + this.data.contact)) var content = { type: 'contact', contact: this.data.contact, } if (this.data.follow) content.following = true if (this.data.block) content.blocking = true if (this.data.unfollow) content.following = false if (this.data.unblock) content.blocking = false if (this.data.mute) content.blocking = true if (this.data.unmute) content.blocking = false if (this.data.mute || this.data.unmute) content.recps = [this.app.sbot.id] if (this.app.previewContacts) { var json = JSON.stringify(content, 0, 2) var q = qs.stringify({text: json, action: 'preview'}) var url = this.app.render.toUrl('/compose?' + q) this.redirect(url) } else { this.publish(content, next) } } Serve.prototype.publishPollPosition = function (cb) { var content = { type: 'position', version: 'v1', channel: this.data.channel || undefined, root: this.data.poll_root, branch: this.data.branches || [], reason: this.data.poll_reason || undefined, details: { type: this.data.poll_type } } if (this.data.poll_choice != null) { content.details.choice = Number(this.data.poll_choice) } else { content.details.choices = u.toArray(this.data.poll_choices).map(Number) } if (this.data.recps) content.recps = this.data.recps.split(',') var json = JSON.stringify(content, 0, 2) var q = qs.stringify({text: json, action: 'preview'}) var url = this.app.render.toUrl('/compose?' + q) this.redirect(url) // 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) } function removeQuery(ref) { return ref.replace(/\?.*/, '') } Serve.prototype.wantBlobs = function (cb) { var self = this if (!self.data.blob_ids) return cb() var ids = self.data.blob_ids.split(',') .map(removeQuery) 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}) u.toArray(content && content.mentions).forEach(function (mention) { if (mention.link && mention.link[0] === '&' && !isNaN(mention.size)) self.app.pushBlob(mention.link, done()) }) done(function (err) { if (err) return cb(err) self.publishMayRedirect(content, function (err, msg) { if (err) return cb(err) delete self.data.text delete self.data.recps return cb(null, msg) }) }) } Serve.prototype.publishMayRedirect = function (content, cb) { var publishguard = this.app.sbot.publishguard if (Array.isArray(content.recps)) { var recps = content.recps.map(u.linkDest) if (publishguard && publishguard.privatePublishGetUrl) { return publishguard.privatePublishGetUrl({ content: content, recps: recps, redirectBase: this.baseUrl }, onPublishGetUrl) } else { this.app.privatePublish(content, recps, cb) } } else { if (publishguard && publishguard.publishGetUrl) { publishguard.publishGetUrl({ content: content, redirectBase: this.baseUrl }, onPublishGetUrl) } else { this.app.sbot.publish(content, cb) } } function onPublishGetUrl(err, url) { if (err) return cb(err) cb({redirectUrl: url}) } } Serve.prototype.handle = function () { var m = urlIdRegex.exec(this.req.url) this.query = m[5] ? qs.parse(m[5]) : {} this.useOoo = this.query.ooo != null ? Boolean(this.query.ooo) : this.app.useOoo if (this.query.printView != null) { this.printView = true this.noNav = true this.noFooter = true this.noComposer = true this.noActions = true this.noAvatar = true this.noMsgTime = true this.msgDate = true } if (this.query.noThread != null) { this.noThread = true } switch (m[2]) { case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1]) case '%': return this.id(m[1], m[3]) case '@': return this.userFeed(m[1], m[3]) case '%26': m[2] = '&'; m[1] = decodeURIComponent(m[1]) case '&': return this.blob(m[1], m[3]) default: return this.path(m[4]) } } Serve.prototype.respond = function (status, message) { this.res.writeHead(status) this.res.end(message) } Serve.prototype.respondSink = function (status, headers, cb) { var self = this 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) }) } Serve.prototype.redirect = function (dest) { this.res.writeHead(302, { Location: dest }) this.res.end() } Serve.prototype.path = function (url) { if (url.substr(0, 5) === '/ssb:') { var link = u.translateFromURI(url.substr(1)) if (link) return this.redirect(this.app.render.toUrl(link)) } var m url = url.replace(/^\/+/, '/') switch (url) { case '/': return this.home() case '/robots.txt': return this.res.end('User-agent: *') } if (m = /^\/%23(.*)/.exec(url)) { return this.redirect(this.app.render.toUrl('/channel/' + decodeURIComponent(m[1]))) } m = /^([^.]*)(?:\.(.*))?$/.exec(url) switch (m[1]) { case '/new': return this.new(m[2]) case '/public': return this.public(m[2]) case '/logbook': return this.logbook2(m[2]) case '/threads': return this.threads(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 '/peers': return this.peers(m[2]) case '/status': return this.status(m[2]) case '/channels': return this.channels(m[2]) case '/tags': return this.tags(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]) case '/about-self': return this.aboutSelf(m[2]) case '/new-gathering': return this.newGathering(m[2]) } m = /^(\/?[^\/]*)(\/.*)?$/.exec(url) switch (m[1]) { case '/channel': return this.channel(m[2]) case '/logbookchannel': return this.logbook2(m[2]) case '/type': return this.type(m[2]) case '/links': return this.links(m[2]) case '/static': return this.static(m[2]) case '/highlight': return this.highlight(m[2]) case '/contacts': return this.contacts(m[2]) case '/about': return this.about(m[2]) case '/pub': return this.pub(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-prebuilds': return this.npmPrebuilds(m[2]) case '/npm-readme': return this.npmReadme(m[2]) case '/npm-registry': return this.npmRegistry(m[2]) case '/markdown': return this.markdown(m[2]) case '/edit-diff': return this.editDiff(m[2]) case '/about-diff': return this.aboutDiff(m[2]) case '/gathering': return this.gathering(m[2]) case '/edit-gathering': return this.editGathering(m[2]) case '/shard': return this.shard(m[2]) case '/zip': return this.zip(m[2]) case '/web': return this.web(m[2]) case '/block': return this.block(m[2]) case '/script': return this.script(m[2]) case '/drafts': return this.drafts(m[2]) } return this.respond(404, 'Not found') } Serve.prototype.home = function () { pull( pull.empty(), this.wrapPage('/'), this.respondSink(200, { 'Content-Type': 'text/html' }) ) } Serve.prototype.logbook2 = function (path) { var channelAndTag = path && decodeURIComponent(String(path).substr(1)) || "logbook" var q = this.query var opts = { reverse: !q.forwards, //sortByTimestamp: q.sort === 'claimed', sortByTimestamp: q.sort || 'claimed', lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, filter: q.filter, } channels.getMessages(this.app.sbot, channelAndTag, opts, this, function(messageStream, serve) { pull(messageStream, serve.renderThreadPaginated(opts, null, q), serve.wrapMessages(), serve.wrapChannel(channelAndTag), serve.wrapPage(channelAndTag), serve.respondSink(200, { 'Content-Type': ctype('html') }) ) } , hops=3) } compareMsgs = function(a, b) { return -(a.value.timestamp - b.value.timestamp) } Serve.prototype.logbook = function (ext) { var channel = "logbook" var q = this.query var gt = Number(q.gt) || -Infinity var lt = Number(q.lt) || Date.now() var opts = { sortByTimestamp: q.sort === 'claimed', reverse: !q.forwards, lt: lt, gt: gt, channel: channel, filter: q.filter, } var wordSearchQ = String(channel).toLowerCase() var opts2 = { query: wordSearchQ, } var outOpts = {} pull( merge( pull(this.app.streamChannel(opts), pSort(compareMsgs)), pull(this.app.sbot.search.query(opts2), pSort(compareMsgs)), compareMsgs ), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapChannel(channel), this.wrapPage('#' + channel), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } Serve.prototype.public = function (ext) { var q = this.query var opts = { reverse: !q.forwards, //sortByTimestamp: q.sort === 'claimed', sortByTimestamp: q.sort || 'claimed', lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, filter: q.filter, } pull( this.app.createLogStream(opts), pull.filter(msg => { return !msg.value.content.vote }), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapPublic(), this.wrapPage('public'), this.respondSink(200, { 'Content-Type': ctype(ext) }) ) } Serve.prototype.threads = function (ext) { var q = this.query var opts = { type: 'post', reverse: !q.forwards, sortByTimestamp: q.sort === 'claimed', lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, filter: q.filter, } pull( this.app.sbotMessagesByType(opts), pull.filter(msg => { return !msg.value.content.root }), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapPublic(), this.wrapPage('threads'), this.respondSink(200, { 'Content-Type': ctype(ext) }) ) } Serve.prototype.setCookie = function (key, value, options) { var header = key + '=' + value if (options) for (var k in options) { header += '; ' + k + '=' + options[k] } this.res.setHeader('Set-Cookie', header) } Serve.prototype.new = function (ext) { var self = this var q = self.query var latest = (/latest=([^;]*)/.exec(self.req.headers.cookie) || [])[1] var limit = Number(q.limit || self.conf.newLimit || 500) var now = Date.now() var opts = { gt: Number(q.gt) || Number(latest) || now, lte: now, limit: limit } if (q.catchup) self.setCookie('latest', opts.gt, {'Max-Age': 86400000}) var read = self.app.createLogStream(opts) self.req.on('closed', function () { console.error('closing') read(true, function (err) { console.log('closed') if (err && err !== true) console.error(new Error(err.stack)) }) }) pull.collect(function (err, msgs) { if (err) return pull( pull.once(u.renderError(err, ext).outerHTML), self.wrapPage('peers'), self.respondSink(500, {'Content-Type': ctype(ext)}) ) sort(msgs) var maxTS = msgs.reduce(function (max, msg) { return Math.max(msg.timestamp, max) }, -Infinity) pull( pull.values(msgs), self.renderThread(), self.wrapNew({ reachedLimit: msgs.length === limit && limit, gt: isFinite(maxTS) ? maxTS : Date.now() }), self.wrapMessages(), self.wrapPage('new'), self.respondSink(200, { 'Content-Type': ctype(ext) }) ) })(read) } Serve.prototype.private = function (ext) { var q = this.query var opts = { sortByTimestamp: q.sort === 'claimed', reverse: !q.forwards, lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, filter: q.filter, } pull( this.app.streamPrivate(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapPrivate(opts), this.wrapPage('private'), this.respondSink(200, { 'Content-Type': ctype(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, filter: q.filter, id: q.id } 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 if (/^ssb:\/\//.test(searchQ)) { var maybeId = searchQ.substr(6) if (u.isRef(maybeId)) searchQ = maybeId } var link = u.translateFromURI(searchQ) if (link) return self.redirect(self.app.render.toUrl(link)) if (u.isRef(searchQ) || searchQ[0] === '#') { return self.redirect(self.app.render.toUrl(searchQ)) } pull( self.app.search(searchQ), self.renderThread(), self.wrapMessages(), self.wrapPage('search - ' + searchQ, searchQ), self.respondSink(200, { 'Content-Type': ctype(ext), }) ) } Serve.prototype.advsearch = function (ext) { var self = this var q = this.query || {} if (q.source) q.source = u.extractFeedIds(q.source)[0] if (q.dest) q.dest = u.extractFeedIds(q.dest)[0] var hasQuery = q.text || q.source || q.dest || q.channel pull( cat([ ph('section', {}, [ ph('form', {action: '', method: 'get'}, [ ph('table', [ ph('tr', [ ph('td', 'text'), ph('td', ph('input', {name: 'text', placeholder: 'regex', class: 'id-input', value: u.escapeHTML(q.text)})) ]), ph('tr', [ ph('td', 'author'), ph('td', ph('input', {name: 'source', placeholder: '@id', class: 'id-input', value: u.escapeHTML(q.source)})) ]), ph('tr', [ ph('td', 'mentions'), ph('td', ph('input', {name: 'dest', placeholder: 'id', class: 'id-input', value: u.escapeHTML(q.dest)})) ]), ph('tr', [ ph('td', 'channel'), ph('td', ['#', ph('input', {name: 'channel', placeholder: 'channel', class: 'id-input', value: u.escapeHTML(q.channel)}) ]) ]), ph('tr', [ ph('td', {colspan: 2}, [ ph('input', {type: 'submit', value: 'search'}) ]) ]), ]) ]) ]), hasQuery && pull( self.app.advancedSearch(q), self.renderThread({ feed: q.source, }), self.wrapMessages() ) ]), self.wrapPage('advanced search'), self.respondSink(200, { 'Content-Type': ctype(ext), }) ) } Serve.prototype.live = function (ext) { var self = this var q = self.query var opts = { live: true, } var gt = Number(q.gt) if (gt) opts.gt = gt else opts.old = false pull( ph('table', {class: 'ssb-msgs'}, pull( self.app.sbot.createLogStream(opts), self.app.render.renderFeeds({ serve: self, withGt: true, filter: q.filter, }), pull.map(u.toHTML) )), self.wrapPage('live'), self.respondSink(200, { 'Content-Type': ctype(ext), }) ) } Serve.prototype.compose = function (ext) { var self = this self.composer({ channel: '', redirectToPublishedMsg: true, }, function (err, composer) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('compose'), self.respondSink(500) ) pull( pull.once(u.toHTML(composer)), self.wrapPage('compose'), self.respondSink(200, { 'Content-Type': ctype(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') { return self.app.sbot.gossip.connect(self.data.address, function (err) { if (err) return pull( pull.once(u.renderError(err, ext).outerHTML), self.wrapPage('peers'), self.respondSink(400, {'Content-Type': ctype(ext)}) ) self.data = {} return self.peers(ext) }) } pull( self.app.streamPeers(), paramap(function (peer, cb) { var done = multicb({pluck: 1, spread: true}) var connectedTime = Date.now() - peer.stateChange var addr = peer.host + ':' + peer.port + ':' + peer.key done()(null, h('section', h('form', {method: 'post', action: ''}, peer.client ? '→' : '←', ' ', h('code', peer.host, ':', peer.port, ':'), self.app.render.idLink(peer.key, done()), ' ', peer.stateChange ? [htime(new Date(peer.stateChange)), ' '] : '', peer.state === 'connected' ? 'connected' : [ h('input', {name: 'action', type: 'submit', value: 'connect'}), h('input', {name: 'address', type: 'hidden', value: addr}) ] ) // h('div', 'source: ', peer.source) // JSON.stringify(peer, 0, 2)).outerHTML )) done(cb) }, 8), pull.map(u.toHTML), self.wrapPeers(), self.wrapPage('peers'), self.respondSink(200, { 'Content-Type': ctype(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.sbotStatus(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 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( cat([ ph('section', {}, [ ph('h3', {}, 'Channels:'), renderMyChannels(), renderNetworkChannels() ]) ]), this.wrapPage('channels'), this.respondSink(200, { 'Content-Type': ctype(ext) }) ) } Serve.prototype.tags = function (path) { var self = this var seen = {} pull( ph('section', [ ph('h3', 'Tags'), pull( self.app.streamTags(), pull.map(function (msg) { return [self.phIdLink(msg.key), ' '] }) ) ]), this.wrapPage('tags'), this.respondSink(200) ) } Serve.prototype.contacts = function (path) { var self = this var id = String(path).substr(1) var contacts = self.app.contacts.createContactStreams({ id: id, msgIds: true, enemies: true }) var render = self.app.render pull( cat([ ph('section', {}, [ ph('h3', {}, ['Contacts: ', self.phIdLink(id)]), ph('h4', {}, 'Friends'), render.friendsList('/contacts/')(contacts.friends), ph('h4', {}, 'Follows'), render.friendsList('/contacts/')(contacts.follows), ph('h4', {}, 'Followers'), render.friendsList('/contacts/')(contacts.followers), ph('h4', {}, 'Blocks'), render.friendsList('/contacts/')(contacts.blocks), ph('h4', {}, 'Blocked by'), render.friendsList('/contacts/')(contacts.blockers), contacts.enemies ? [ ph('h4', {}, 'Enemies'), render.friendsList('/contacts/')(contacts.enemies), ] : '' ]) ]), this.wrapPage('contacts: ' + id), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } 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 (typeof value === 'object' && value !== null) { 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(u.toLink(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.aboutSelf = function (ext) { var self = this var id = self.app.sbot.id var render = self.app.render self.app.getAbout(id, function gotAbout(err, about) { if (err) return cb(err) var data = self.data var aboutName = about.name ? String(about.name).replace(/^@/, '') : '' var aboutImageLink = about.imageLink || {} var name = data.name != null ? data.name === '' ? null : data.name : aboutName || null var image = data.image_upload != null ? { link: data.image_upload.link, type: data.image_upload.type, size: data.image_upload.size } : data.image_id && data.image_id !== aboutImageLink.link ? { link: data.image_id, type: data.image_type, size: data.image_size } : aboutImageLink var imageId = image.link || '&Zq5m3UOWlFfyUoenOL75ukghOdjmv2yxHREkBNrorWM=.sha256' var description = data.description != null ? data.description === '' ? null : data.description : about.description || null var publicWebHosting = data.publicWebHosting != null ? data.publicWebHosting === 'false' ? false : data.publicWebHosting === 'null' ? null : !!data.publicWebHosting : about.publicWebHosting var content if (data.preview || data.preview_raw) { content = { type: 'about', about: id } if (name != aboutName) content.name = name if (image.link != about.image) content.image = image if (description != about.description) content.description = description if (publicWebHosting != about.publicWebHosting) content.publicWebHosting = publicWebHosting } pull( ph('section', {}, [ ph('h4', 'Your public profile'), ph('form', {action: '', method: 'post', enctype: 'multipart/form-data'}, [ ph('div', [ '@', ph('input', {id: 'name', name: 'name', placeholder: 'name', value: name}) ]), ph('table', ph('tr', [ ph('td', [ ph('a', {href: render.toUrl(imageId)}, [ ph('img', { class: 'ssb-avatar-image', src: render.imageUrl(imageId), alt: image.link || 'fallback avatar', title: image.link || 'fallback avatar' }) ]) ]), ph('td', [ image.link ? ph('div', [ ph('small', ph('code', u.escapeHTML(image.link))), ph('input', {type: 'hidden', name: 'image_id', value: image.link}), ' ', ]) : '', image.size ? [ ph('code', render.formatSize(image.size)), ph('input', {type: 'hidden', name: 'image_size', value: image.size}), ' ', ] : '', image.type ? [ ph('input', {type: 'hidden', name: 'image_type', value: image.type}) ] : '', ph('div', [ ph('input', {id: 'image_upload', type: 'file', name: 'image_upload'}) ]) ]) ])), ph('textarea', { id: 'description', name: 'description', placeholder: 'description', rows: Math.max(4, u.rows(description)) }, u.escapeHTML(description)), ph('div', { title: 'Allow your messages to be hosted on public viewer websites' }, [ ph('label', {for: 'publicWebHosting'}, 'Public web hosting: '), ph('select', {name: 'publicWebHosting', id: 'publicWebHosting'}, [ ph('option', {value: 'true', selected: publicWebHosting}, 'yes'), ph('option', {value: 'false', selected: publicWebHosting === false}, 'no'), ph('option', {value: 'null', selected: publicWebHosting == null}, '…'), ]) ]), self.phMsgActions(content), ]), content ? self.phPreview(content, {raw: data.preview_raw}) : '' ]), self.wrapPage('about self: ' + id), self.respondSink(200, { 'Content-Type': ctype('html') }) ) }) } Serve.prototype.gathering = function (url) { var self = this var id try { id = decodeURIComponent(url.substr(1)) } catch(err) { return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('Gathering ' + id), self.respondSink(400) ) } var q = self.query var data = self.data || {} var selfId = self.app.sbot.id var render = self.app.render function hashLink(id) { if (!id) return '' if (typeof id !== 'string') return u.escapeHTML(JSON.stringify(id)) return h('a', {style: 'font: monospace', href: render.toUrl(id)}, id.substr(0, 8) + '…').outerHTML + ' ' } self.app.getAbout(id, function (err, about) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.respondSink(400) ) var sources = about._sources || {} var start = about.startDateTime var img = about.imageLink var startDate = start && new Date(start.epoch) pull( ph('section', [ ph('h2', ['Gathering ', hashLink(id)]), ph('div', ph('a', {href: render.toUrl('/edit-gathering/' + encodeURIComponent(id))}, 'Edit')), ph('table', [ img ? ph('tr', [ ph('th', 'Image'), ph('td', sources.image.map(hashLink)), ph('td', u.isRef(img.link) ? ph('img', { class: 'ssb-avatar-image', src: render.imageUrl(img.link), alt: img.link + (img.size ? ' (' + render.formatSize(img.size) + ')' : '') }) : ph('code', JSON.stringify(img))), ]) : '', about.title ? ph('tr', [ ph('th', 'Title'), ph('td', sources.title.map(hashLink)), ph('td', ph('h1', {style: 'margin: 0'}, u.escapeHTML(about.title))) ]) : '', about.location ? ph('tr', [ ph('th', 'Location'), ph('td', sources.location.map(hashLink)), ph('td', u.escapeHTML(about.location)) ]) : '', start ? ph('tr', [ ph('th', 'Start time'), ph('td', sources.startDateTime.map(hashLink)), ph('td', [ ph('code', u.escapeHTML(startDate.toISOString().replace(/ .*/, ''))), start.tz ? [ '
', ph('code', u.escapeHTML(start.tz)) ] : '', ph('div', u.escapeHTML(startDate.toString())) ]) ]) : '', about.description ? ph('tr', [ ph('th', 'Description'), ph('td', sources.description.map(hashLink)), ph('td', render.markdown(about.description)) ]) : '', /* about.branch ? ph('tr', [ ph('th', 'Latest updates'), ph('th'), ph('td', about.branch.map(hashLink)) ]) : '', */ about.attendee ? about.attendee.map(function (link, i, rows) { return ph('tr', [ i === 0 ? ph('th', {rowspan: rows.length}, 'Attendees') : '', ph('td', hashLink(link.source)), ph('td', self.phIdLink(link.link)) ]) }) : '', ]) ]), self.wrapPage('Gathering ' + id), self.respondSink(200) ) }) } var ignoreDateTimeProps = { silent: true, // internal to spacetime module } function isDateTimeEqual(a, b) { if (a === b) return true if ((a && !b) || (!b && a)) return false for (var k in a) if (!ignoreDateTimeProps[k] && b[k] !== a[k]) return false for (var k in b) if (!ignoreDateTimeProps[k] && b[k] !== a[k]) return false return true } Serve.prototype.editGathering = function (url) { var self = this var id try { id = decodeURIComponent(url.substr(1)) } catch(err) { return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('Edit Gathering ' + id), self.respondSink(400) ) } var q = self.query var data = self.data || {} var selfId = self.app.sbot.id var render = self.app.render self.app.getAbout(id, function (err, about) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.respondSink(400) ) var aboutImageLink = about.imageLink || {} var aboutStartDateTime = about.startDateTime || {} var title = data.title != null ? data.title === '' ? null : data.title : about.title || null var location = data.location != null ? data.location === '' ? null : data.location : about.location || null var image = data.image_upload != null ? { link: data.image_upload.link, type: data.image_upload.type, size: data.image_upload.size } : data.remove_image ? null : data.image_id && data.image_id !== aboutImageLink.link ? { link: data.image_id, type: data.image_type, size: data.image_size } : aboutImageLink var description = data.description != null ? data.description === '' ? null : data.description : about.description || null // use undefined instead of null to reset these values, // since they are set as a whole object var startDateTimeEpoch = data.startDateTimeStr != null ? data.startDateTimeStr === '' ? null : new Date(data.startDateTimeStr).getTime() : aboutStartDateTime.epoch var startDateTime = startDateTimeEpoch === null ? null : typeof startDateTimeEpoch === 'number' && !isNaN(startDateTimeEpoch) ? { epoch: startDateTimeEpoch, tz: data.startTZ != null ? data.startTZ === '' ? undefined : data.startTZ : aboutStartDateTime.tz, _weekStart: data.startWeekStart != null ? data.startWeekStart === '' ? undefined : Number(data.startWeekStart) : aboutStartDateTime._weekStart } : about.startDateTime || null var attendeeIds = u.toLinkArray(about.attendee).map(u.linkDest) var aboutSelfAttending = attendeeIds.indexOf(selfId) !== -1 var selfAttending = data.attending != null ? Boolean(data.attending) : aboutSelfAttending var mentionAttendees = data.mention_attendees === '' ? true : Boolean(data.mention_attendees) var attendeeMentions = mentionAttendees ? attendeeIds.filter(function (id) { return id !== selfId }) : [] var previousMentions = u.toLinkArray(about.mentions).map(u.linkDest) var additionalMentions = data.mentions ? u.extractRefs(data.mentions).filter(uniques()) : [] var mentions = attendeeMentions.concat(additionalMentions) var content if (data.preview || data.preview_raw) { content = { type: 'about', about: id } if (about.recps) content.recps = about.recps if (about.branch) content.branch = about.branch if (title != about.title) content.title = title if (location != about.location) content.location = location if (image === null) { if (about.image) content.image = {link: about.image, remove: true} } else if (image.link != about.image) content.image = image if (description != about.description) { content.description = description var textMentions = ssbMentions(description, {bareFeedNames: false, emoji: false}) // don't mention ids already mentioned in the thread textMentions.forEach(function (link) { if (mentions.indexOf(link.link) === -1 && previousMentions.indexOf(link.link) === -1) { mentions.push(reduceLink(link)) } }) } if (!isDateTimeEqual(startDateTime, about.startDateTime)) content.startDateTime = startDateTime if (mentions.length) content.mentions = mentions if (selfAttending != aboutSelfAttending) content.attendee = selfAttending ? { link: selfId } : { link: selfId, remove: true } } var startDateTimeStr = '' if (startDateTime) try { startDateTimeStr = new Date(startDateTime.epoch).toISOString().replace(/ .*/, '') } catch(e) {} var startTz = startDateTime && startDateTime.tz || null var startWeekStart = startDateTime && startDateTime._weekStart || null pull( ph('section', [ ph('h2', ['Edit Gathering ', self.phIdLink(id)]), ph('form', {action: '', method: 'post', enctype: 'multipart/form-data'}, [ ph('div', [ ph('input', {name: 'title', size: 64, placeholder: 'Title', value: u.escapeHTML(title)}) ]), ph('div', [ ph('input', {name: 'location', size: 64, placeholder: 'Location', value: u.escapeHTML(location)}) ]), ph('div', [ ph('input', {name: 'startDateTimeStr', value: u.escapeHTML(startDateTimeStr), style: 'font: monospace', size: 30, placeholder: 'Start date time'}), ph('input', {name: 'startTZ', value: startTz ? u.escapeHTML(startTz) : '', style: 'font: monospace', size: 20, placeholder: 'TZ'}), ph('input', {name: 'startWeekStart', value: startWeekStart == null ? '' : u.toString(startWeekStart), style: 'font: monospace', size: 2, placeholder: '1', title: 'week start'}) ]), ph('table', ph('tr', [ ph('td', [ data.remove_image ? ph('input', {type: 'hidden', name: 'remove_image', value: u.escapeHTML(data.remove_image)}) : '', image && image.link ? ph('a', {href: render.toUrl(image.link)}, [ ph('img', { class: 'ssb-avatar-image', src: render.imageUrl(image.link), alt: image.link || 'fallback avatar', title: image.link || 'fallback avatar' }) ]) : '' ]), ph('td', [ image && image.link ? ph('div', [ ph('input', {type: 'submit', value: 'x', name: 'remove_image'}), ' ', ph('small', ph('code', u.escapeHTML(image.link))), ph('input', {type: 'hidden', name: 'image_id', value: image.link}), ' ', ]) : '', image && image.size ? [ ph('code', render.formatSize(image.size)), ph('input', {type: 'hidden', name: 'image_size', value: image.size}), ' ', ] : '', image && image.type ? [ ph('input', {type: 'hidden', name: 'image_type', value: image.type}) ] : '', ph('div', [ ph('input', {id: 'image_upload', type: 'file', name: 'image_upload'}) ]) ]) ])), ph('div', ph('textarea', { name: 'description', placeholder: 'Description', cols: 64, rows: Math.max(5, u.rows(description)) }, u.escapeHTML(description))), ph('div', ph('select', {name: 'attending'}, [ ph('option', {value: '1', selected: !selfAttending ? 'selected' : undefined}, 'You are not attending'), ph('option', {value: '1', selected: selfAttending ? 'selected' : undefined}, 'You are attending'), ])), ph('div', ph('label', {for: 'mention_attendees'}, [ ph('input', {id: 'mention_attendees', type: 'checkbox', name: 'mention_attendees', value: '1', checked: mentionAttendees ? 'checked' : undefined}), ' mention attendees' ])), ph('div', additionalMentions.length > 0 || data.add_mentions ? ph('textarea', { name: 'mentions', placeholder: 'Additional mentions', cols: 64, style: 'font: monospace', rows: Math.max(3, additionalMentions.length + 2), }, u.escapeHTML(additionalMentions.join('\n')) ) : ph('label', {for: 'additional_mentions'}, [ ph('input', {id: 'additional_mentions', type: 'checkbox', name: 'add_mentions', value: '1'}), ' additional mentions' ]) ), self.phMsgActions(content) ]), content ? self.phPreview(content, {raw: data.preview_raw}) : '' ]), self.wrapPage('Edit Gathering ' + id), self.respondSink(200) ) }) } Serve.prototype.newGathering = function () { var self = this var q = self.query var data = self.data || {} var selfId = self.app.sbot.id var render = self.app.render var title = data.title ? u.toString(data.title) : null var description = data.description ? u.toString(data.description) : null var location = data.location ? u.toString(data.location) : null var image = data.image_upload != null ? { link: data.image_upload.link, type: data.image_upload.type, size: data.image_upload.size } : data.remove_image ? null : data.image_id ? { link: data.image_id, type: data.image_type, size: data.image_size } : null var private = Boolean(data.private) var recps = data.recps ? u.extractRefs(data.recps) : private ? [selfId] : null // default recps to self id but allow removing it var startTs = data.startDateTimeStr ? new Date(data.startDateTimeStr).getTime() : null var startDateTime = typeof startTs === 'number' && !isNaN(startTs) ? { epoch: startTs, tz: data.startTZ ? u.toString(data.startTZ) : undefined, _weekStart: data.startWeekStart ? Number(data.startWeekStart) : undefined } : null var selfAttending = Boolean(data.attending) var initialMentions = data.mentions ? u.extractRefs(data.mentions) : [] var content if (data.preview || data.preview_raw) { content = { type: 'about' } if (private && recps) content.recps = recps if (title) content.title = title if (location) content.location = location if (image) content.image = image if (description) { content.description = description var mentions = ssbMentions(description, {bareFeedNames: false, emoji: false}) .map(reduceLink) if (mentions.length) content.mentions = mentions } if (startDateTime) content.startDateTime = startDateTime if (selfAttending) content.attendee = { link: selfId } } var aboutNewContent = { type: 'gathering', mentions: initialMentions.length ? initialMentions : undefined } var startDateTimeStr = '' if (startDateTime) try { startDateTimeStr = new Date(startDateTime.epoch).toISOString().replace(/ .*/, '') } catch(e) {} var startTz = startDateTime && startDateTime.tz || null var startWeekStart = startDateTime && startDateTime._weekStart || null pull( ph('section', [ ph('h2', ['New Gathering']), ph('form', {action: '', method: 'post', enctype: 'multipart/form-data'}, [ ph('div', [ ph('select', {name: 'private'}, [ ph('option', {value: '', selected: !private ? 'selected' : undefined}, 'Public'), ph('option', {value: '1', selected: private ? 'selected' : undefined}, 'Private') ]) ]), private ? ph('div', ph('textarea', { name: 'recps', placeholder: 'Recipients', title: 'Recipient IDs for private gathering', cols: 64, style: 'font: monospace', rows: Math.max(3, recps.length + 2), }, u.escapeHTML(recps.join('\n')) + '\n')) : recps ? ph('input', { type: 'hidden', name: 'recps', value: recps.join('\n') }) : null, ph('div', [ ph('input', {name: 'title', size: 64, placeholder: 'Title', value: u.escapeHTML(title)}) ]), ph('div', [ ph('input', {name: 'location', size: 64, placeholder: 'Location', value: u.escapeHTML(location)}) ]), ph('div', [ ph('input', {name: 'startDateTimeStr', value: u.escapeHTML(startDateTimeStr), style: 'font: monospace', size: 30, placeholder: 'Start date time'}), ph('input', {name: 'startTZ', value: startTz ? u.escapeHTML(startTz) : '', style: 'font: monospace', size: 20, placeholder: 'TZ'}), ph('input', {name: 'startWeekStart', value: startWeekStart == null ? '' : u.toString(startWeekStart), style: 'font: monospace', size: 2, placeholder: '1', title: 'week start'}) ]), ph('table', ph('tr', [ ph('td', [ image && image.link ? ph('a', {href: render.toUrl(image.link)}, [ ph('img', { class: 'ssb-avatar-image', src: render.imageUrl(image.link), alt: image.link || 'fallback avatar', title: image.link || 'fallback avatar' }) ]) : '' ]), ph('td', [ image && image.link ? ph('div', [ ph('input', {type: 'submit', value: 'x', name: 'remove_image'}), ' ', ph('small', ph('code', u.escapeHTML(image.link))), ph('input', {type: 'hidden', name: 'image_id', value: image.link}), ' ', ]) : '', image && image.size ? [ ph('code', render.formatSize(image.size)), ph('input', {type: 'hidden', name: 'image_size', value: image.size}), ' ', ] : '', image && image.type ? [ ph('input', {type: 'hidden', name: 'image_type', value: image.type}) ] : '', ph('div', [ ph('input', {id: 'image_upload', type: 'file', name: 'image_upload'}) ]) ]) ])), ph('div', ph('textarea', { name: 'description', placeholder: 'Description', cols: 64, rows: Math.max(5, u.rows(description)) }, u.escapeHTML(description))), ph('div', ph('label', {for: 'attending'}, [ ph('input', {id: 'attending', type: 'checkbox', name: 'attending', value: '1', checked: selfAttending ? 'checked' : undefined}), ' attending' ])), ph('div', ph('textarea', { name: 'mentions', placeholder: 'Initial mentions', title: 'SSB feed/blob/message IDs to mention in the gathering root message', cols: 53, style: 'font: monospace', rows: Math.max(3, initialMentions.length + 1), }, u.escapeHTML(initialMentions.join('\n')+'\n'))), self.phMsgActions(content) ]), content ? self.phPreview(content, { raw: data.preview_raw, aboutNewContent: aboutNewContent }) : '' ]), self.wrapPage('New Gathering'), self.respondSink(200) ) } Serve.prototype.block = function (path) { var self = this var data = self.data var id = String(path).substr(1) try { id = decodeURIComponent(id) } catch(e) {} var content if (data.preview || data.preview_raw) { content = { type: 'contact', contact: id, blocking: true } var reason = typeof data.reason === 'string' ? data.reason : null if (reason) content.reason = reason } function renderDraftLink(draftId) { return pull.values([ ph('a', {href: self.app.render.toUrl('/drafts/' + encodeURI(draftId)), title: 'draft link'}, u.escapeHTML(draftId)), ph('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}), ' ', ]) } pull( ph('section', [ ph('h2', ['Block ', self.phIdLink(id)]), ph('form', {action: '', method: 'post', enctype: 'multipart/form-data'}, [ 'Reason: ', ph('input', {name: 'reason', value: reason || '', className: 'wide', placeholder: 'spam, abuse, etc.'}), self.phMsgActions(content), ]), content ? self.phPreview(content, {raw: data.preview_raw}) : '' ]), self.wrapPage('Block ' + id), self.respondSink(200) ) } Serve.prototype.type = function (path) { var q = this.query var type = decodeURIComponent(path.substr(1)) var opts = { sortByTimestamp: q.sort === 'claimed', reverse: !q.forwards, lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, type: type, filter: q.filter, } pull( this.app.sbotMessagesByType(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapType(type), this.wrapPage('type: ' + type), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } Serve.prototype.links = function (path) { var q = this.query var dest = path.substr(1) var sbot = this.app.sbot pull( q.rel || q.author || !sbot.backlinks ? (q.type ? pull.error(new Error('Unable to satisfy query')) : sbot.links({ source: q.author || undefined, dest: dest, reverse: true, values: true, rel: q.rel })) : sbot.backlinks.read({ reverse: true, query: [ {$filter: { dest: dest, value: q.type ? { content: { type: q.type } } : {} }} ] }), this.renderThread(), this.wrapMessages(), this.wrapLinks(dest), this.wrapPage('links: ' + dest), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } Serve.prototype.rawId = function (id) { var self = this self.getMsgDecryptedMaybeOoo(id, function (err, msg) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.respondSink(400, {'Content-Type': ctype('html')}) ) return pull( pull.once(msg), self.renderRawMsgPage(id), self.respondSink(200, { 'Content-Type': ctype('html'), }) ) }) } Serve.prototype.channel = function (path) { var channel = decodeURIComponent(String(path).substr(1)) var q = this.query var gt = Number(q.gt) || -Infinity var lt = Number(q.lt) || Date.now() var opts = { sortByTimestamp: q.sort === 'claimed', reverse: !q.forwards, lt: lt, gt: gt, channel: channel, filter: q.filter, } pull( this.app.streamChannel(opts), this.renderThreadPaginated(opts, null, q), this.wrapMessages(), this.wrapChannel(channel), this.wrapPage('#' + channel), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } function threadHeads(msgs, rootId, opts) { var includeVotes = opts && opts.includeVotes return sort.heads(msgs.filter(function (msg) { var c = msg.value && msg.value.content return (c && ( c.type === 'web-root' ? c.site === rootId : c.type === 'talenet-idea-comment_reply' ? c.ideaKey === rootId : c.type === 'vote' ? includeVotes : c.root === rootId)) || msg.key === rootId })) } Serve.prototype.streamThreadWithComposer = function (opts) { var self = this var id = opts.root var threadHeadsOpts = {includeVotes: self.app.voteBranches} return ph('table', {class: 'ssb-msgs'}, u.readNext(next)) function next(cb) { self.getMsgDecryptedMaybeOoo(id, function (err, rootMsg) { if (err && err.name === 'NotFoundError') err = null, rootMsg = { key: id, value: {content: false}} if (err) return cb(new Error(err.stack || err)) if (!rootMsg) { console.log('id', id, 'opts', opts) } var rootContent = rootMsg && rootMsg.value && rootMsg.value.content var recps = rootContent && rootContent.recps || ((rootMsg.value.private || typeof rootMsg.value.content === 'string') ? [rootMsg.value.author, self.app.sbot.id].filter(uniques()) : undefined) var threadRootId = rootContent && ( rootContent.type === 'web-root' ? rootContent.site : rootContent.root ) || id var channel = opts.channel pull( self.noThread ? pull.once(rootMsg) : self.app.getThread(rootMsg), pull.unique('key'), self.app.unboxMessages(), pull.through(function (msg) { var c = msg && msg.value.content if (!channel && c && c.channel) channel = c.channel }), pull.collect(function (err, links) { if (err) return gotLinks(err) if (!self.useOoo) return gotLinks(null, links) self.app.expandOoo({msgs: links, dest: id}, gotLinks) }) ) function gotLinks(err, links) { if (err) return cb(new Error(err.stack)) var branches = threadHeads(links, threadRootId, threadHeadsOpts) cb(null, pull( pull.values(sort(links)), self.app.voteBranches && pull.map(function (link) { var o = {} for (var k in link) o[k] = link[k] o.threadBranches = branches o.threadRoot = threadRootId return o }), self.renderThread({ msgId: id, branches: branches, links: links, }), self.wrapMessages(), self.wrapThread({ recps: recps, root: threadRootId, post: id, branches: branches, links: links, postBranches: threadRootId !== id && threadHeads(links, id, threadHeadsOpts), placeholder: opts.placeholder, channel: channel, }) )) } }) } } Serve.prototype.streamMsg = function (id) { var self = this return pull( self.app.pullGetMsg(id), self.renderThread({ msgId: id, single: self.query.single != null }), self.wrapMessages() ) } Serve.prototype.id = function (id, path) { var self = this if (self.query.raw != null) return self.rawId(id) pull( self.query.single != null ? self.streamMsg(id) : self.streamThreadWithComposer({root: id}), self.wrapPage(id), self.respondSink(200) ) } Serve.prototype.userFeed = function (id, path) { var self = this var q = self.query var opts = { id: id, reverse: !q.forwards, lt: Number(q.lt) || Date.now(), gt: Number(q.gt) || -Infinity, feed: id, filter: q.filter, } var isScrolled = q.lt || q.gt self.app.getAbout(id, function (err, about) { if (err) self.app.error(err) pull( self.app.sbotCreateUserStream(opts), self.renderThreadPaginated(opts, id, q), self.wrapMessages(), self.wrapUserFeed(isScrolled, id), self.wrapPage(about && about.name || id), self.respondSink(200) ) }) } Serve.prototype.file = function (file) { var self = this fs.stat(file, function (err, stat) { if (err && err.code === 'ENOENT') return self.respond(404, 'Not found') if (err) return self.respond(500, err.stack || err) if (!stat.isFile()) return self.respond(403, 'May only load files') if (self.ifModified(stat.mtime)) return self.respond(304, 'Not modified') self.res.writeHead(200, { 'Content-Type': ctype(file), 'Content-Length': stat.size, 'Last-Modified': stat.mtime.toGMTString() }) fs.createReadStream(file).pipe(self.res) }) } Serve.prototype.static = function (file) { this.file(path.join(__dirname, '../static', file)) } Serve.prototype.highlight = function (dirs) { this.file(path.join(hlCssDir, dirs)) } Serve.prototype.blob = function (id, path) { var self = this var unbox = typeof this.query.unbox === 'string' && this.query.unbox.replace(/\s/g, '+') var etag = '"' + id + (path || '') + (unbox || '') + '"' if (self.req.headers['if-none-match'] === etag) return self.respond(304) var key if (path) { try { path = decodeURIComponent(path) } catch(e) {} if (path[0] === '#') { unbox = path.substr(1) } else { return self.respond(400, 'Bad blob request') } } if (unbox) { try { key = Buffer.from(unbox, 'base64') } catch(err) { return self.respond(400, err.message) } if (key.length !== 32) { return self.respond(400, 'Bad blob key') } } 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 (key) { // Transform size of boxed blob to size of cleartext. // Assume that the blob contains box-stream packets that are all full 4096-byte packets except maybe the last one, plus one goodbye packet // boxedlen = origlen + ceil(origlen / 4096)*34 + 34 const numBoxHeaders = Math.ceil((size - 34) / 4130) + 1 size -= numBoxHeaders * 34 } self.res.setHeader('Accept-Ranges', 'bytes') var range = self.req.headers.range if (range) { if (self.req.headers['if-none-match'] === etag) return self.respond(304) // TODO: support multiple ranges var m = /^bytes=([0-9]*)-([0-9]*)$/.exec(range) if (!m) return self.respond(416, 'Unable to parse range') var start = m[1] var last = m[2] if (start === '') { start = size - last last = size - 1 } else if (last === '') { start = Number(start) last = size - 1 } else { start = Number(start) last = Number(last) } if (start > size || last >= size) return self.respond(416, 'Range not satisfiable') var end = last + 1 var length = end - start var wroteHeaders = false pull( // TODO: figure out how to use readBlobSlice for private blob range request key ? pull( self.app.getBlob(id, key), u.pullSlice(start, end) ) : self.app.readBlobSlice({ link: id, size: size }, { start: start, end: end, }), pull.through(function (buf) { if (wroteHeaders) return self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('ETag', etag) self.res.setHeader('Content-Length', length) self.res.setHeader('Content-Range', 'bytes ' + start + '-' + last + '/' + size) self.res.writeHead(206) wroteHeaders = true }), pull.map(Buffer.from), self.respondSink() ) } else { pull( self.app.getBlob(id, key), pull.map(Buffer.from), ident(gotType), self.respondSink() ) 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 // the ciphertext if (typeof size === 'number' && !key) self.res.setHeader('Content-Length', size) if (self.query.filename) self.res.setHeader('Content-Disposition', 'inline; filename='+encodeDispositionFilename(self.query.filename)) if (self.query.gzip) self.res.setHeader('Content-Encoding', 'gzip') if (self.query.contentType) self.res.setHeader('Content-Type', self.query.contentType) 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 unbox = typeof this.query.unbox === 'string' && this.query.unbox.replace(/\s/g, '+') var etag = '"image-' + id + (path || '') + (unbox || '') + '"' if (self.req.headers['if-none-match'] === etag) return self.respond(304) if (path) { try { path = decodeURIComponent(path) } catch(e) {} if (path[0] === '#') { unbox = path.substr(1) } else { return self.respond(400, 'Bad blob request') } } if (unbox) { try { key = Buffer.from(unbox, 'base64') } catch(err) { return self.respond(400, err.message) } if (key.length !== 32) { return self.respond(400, 'Bad blob key') } } 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.from), ident(heresTheType), pull.collect(onFullBuffer) ) function onFullBuffer (err, buffer) { if (err) return heresTheData(err) buffer = Buffer.concat(buffer) try { jpeg.rotate(buffer, {}, function (err, rotatedBuffer, orientation) { if (!err) buffer = rotatedBuffer heresTheData(null, buffer) pull( pull.once(buffer), self.respondSink() ) }) } catch (err) { console.trace(err) self.respond(500, err.message || err) } } done(function (err, data, type) { if (err) { console.trace(err) self.respond(500, err.message || err) return } type = type && mime.lookup(type) if (type) self.res.setHeader('Content-Type', type) self.res.setHeader('Content-Length', data.length) if (self.query.filename) self.res.setHeader('Content-Disposition', 'inline; filename='+encodeDispositionFilename(self.query.filename)) if (self.query.gzip) self.res.setHeader('Content-Encoding', 'gzip') if (self.query.contentType) self.res.setHeader('Content-Type', self.query.contentType) self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('ETag', etag) self.res.writeHead(200) }) }) } Serve.prototype.ifModified = function (lastMod) { var ifModSince = this.req.headers['if-modified-since'] if (!ifModSince) return false var d = new Date(ifModSince) return d && Math.floor(d/1000) >= Math.floor(lastMod/1000) } Serve.prototype.wrapMessages = function () { var self = this return u.hyperwrap(function (content, cb) { cb(null, h('table.ssb-msgs', content)) }) } Serve.prototype.renderThread = function (opts) { return pull( this.app.render.renderFeeds({ raw: false, full: this.query.full != null, feed: opts && opts.feed, msgId: opts && opts.msgId, filter: this.query.filter, limit: Number(this.query.limit), serve: this, links: opts && opts.links, single: opts && opts.single, branches: opts && opts.branches, }), pull.map(u.toHTML) ) } Serve.prototype.renderThreadPaginated = function (opts, feedId, q) { var self = this function linkA(opts, name) { 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(u.mergeOpts(opts, {limit: 1})), ' ', linkA(u.mergeOpts(opts, {limit: 10})), ' ', linkA(u.mergeOpts(opts, {limit: 100})) )) } return pull( self.app.filterMessages({ feed: opts && opts.feed, msgId: opts && opts.msgId, filter: this.query.filter, limit: Number(this.query.limit) || 12, }), paginate( function onFirst(msg, cb) { var num = feedId ? msg.value.sequence : opts.sortByTimestamp ? msg.value.timestamp : msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: num, gt: null, forwards: null, filter: opts.filter, })) } else { cb(null, links({ lt: null, gt: num, forwards: 1, filter: opts.filter, })) } }, this.app.render.renderFeeds({ raw: false, full: this.query.full != null, feed: opts && opts.feed, msgId: opts && opts.msgId, filter: this.query.filter, serve: this, limit: Number(this.query.limit) || 12, }), function onLast(msg, cb) { var num = feedId ? msg.value.sequence : opts.sortByTimestamp ? msg.value.timestamp : msg.timestamp || msg.ts if (q.forwards) { cb(null, links({ lt: null, gt: num, forwards: 1, filter: opts.filter, })) } else { cb(null, links({ lt: num, gt: null, forwards: null, filter: opts.filter, })) } }, function onEmpty(cb) { if (q.forwards) { cb(null, links({ gt: null, lt: opts.gt + 1, forwards: null, filter: opts.filter, })) } else { cb(null, links({ gt: opts.lt - 1, lt: null, forwards: 1, filter: opts.filter, })) } } ), pull.map(u.toHTML) ) } Serve.prototype.renderRawMsgPage = function (id) { var showMarkdownSource = (this.query.raw === 'md') var raw = !showMarkdownSource return pull( this.app.render.renderFeeds({ raw: raw, msgId: id, filter: this.query.filter, serve: this, markdownSource: showMarkdownSource }), pull.map(u.toHTML), this.wrapMessages(), this.wrapPage(id) ) } function catchHTMLError() { return function (read) { var ended return function (abort, cb) { if (ended) return cb(ended) read(abort, function (end, data) { if (!end || end === true) { try { return cb(end, data) } catch(e) { return console.trace(e) } } ended = true cb(null, u.renderError(end).outerHTML) }) } } } 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') } Serve.prototype.appendFooter = function () { var self = this return function (read) { if (self.noFooter) return read return cat([read, u.readNext(function (cb) { var ms = new Date() - self.startDate cb(null, pull.once(h('footer', h('a', {href: pkg.homepage}, pkg.name), ' - ', ms/1000 + 's' ).outerHTML)) })]) } } Serve.prototype.wrapPage = function (title, searchQ) { var self = this var render = self.app.render return pull( catchHTMLError(), self.appendFooter(), u.hyperwrap(function (content, cb) { var done = multicb({pluck: 1, spread: true}) done()(null, h('html', h('head', h('meta', {'http-equiv': 'Content-Type', content: 'text/html; charset=utf-8'}), 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('link', {rel: 'stylesheet', href: render.toUrl('/highlight/foundation.css')}) ), h('body' + (self.printView ? '.print-view' : ''), self.noNav ? '' : h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'}, self.app.navLinks.map(function (link, i) { return [i == 0 ? '' : ' ', link === 'self' ? render.idLink(self.app.sbot.id, done()) : link === 'searchbox' ? h('input.search-input', {name: 'q', value: searchQ, placeholder: 'search'}) : link === 'search' ? h('a', {href: render.toUrl('/advsearch')}, 'search') : typeof link === 'string' ? h('a', {href: render.toUrl('/' + link)}, link) : link ? h('a', {href: render.toUrl(link.url)}, link.name) : '' ] }) )), self.query.published ? h('div', 'published ', render.msgIdLink(self.query.published, done()) ) : '', // self.note, content ))) done(cb) }) ) } Serve.prototype.phIdLink = function (id, opts) { return pull( pull.once(id), this.renderIdsList(opts) ) } Serve.prototype.phIdAvatar = function (id) { var self = this return u.readNext(function (cb) { var el = self.app.render.avatarImage(id, function (err) { if (err) return cb(err) cb(null, pull.once(u.toHTML(el))) }) }) } Serve.prototype.friends = function (path) { var self = this var friends = self.app.sbot.friends if (!friends) return pull( pull.once('missing ssb-friends plugin'), this.wrapPage('friends'), self.respondSink(400) ) if (!friends.createFriendStream) return pull( pull.once('missing friends.createFriendStream method'), this.wrapPage('friends'), self.respondSink(400) ) pull( friends.createFriendStream({hops: 1}), self.renderIdsList(), u.hyperwrap(function (items, cb) { cb(null, [ h('section', h('h3', 'Friends') ), h('section', items) ]) }), this.wrapPage('friends'), this.respondSink(200, { 'Content-Type': ctype('html') }) ) } Serve.prototype.renderIdsList = function (opts) { var self = this return pull( paramap(function (id, cb) { self.app.render.getNameLink(id, opts, cb) }, 8), pull.map(function (el) { return [el, ' '] }), pull.map(u.toHTML) ) } Serve.prototype.aboutDescription = function (id) { var self = this return u.readNext(function (cb) { self.app.getAbout(id, function (err, about) { if (err) return cb(err) if (!about.description) return cb(null, pull.empty()) cb(null, ph('div', self.app.render.markdown(about.description))) }) }) } Serve.prototype.followInfo = function (id, myId) { var self = this return u.readNext(function (cb) { var done = multicb({pluck: 1, spread: true}) self.app.getContact(myId, id, done()) self.app.getContact(id, myId, done()) self.app.isMuted(id, done()) done(function (err, contactToThem, contactFromThem, isMuted) { if (err) return cb(err) cb(null, ph('form', {action: '', method: 'post'}, [ contactFromThem ? contactToThem ? 'friend ' : 'follows you ' : contactFromThem === false ? 'blocks you ' : '', ph('input', {type: 'hidden', name: 'action', value: 'contact'}), ph('input', {type: 'hidden', name: 'contact', value: id}), ph('input', {type: 'submit', name: contactToThem ? 'unfollow' : 'follow', value: contactToThem ? 'unfollow' : 'follow'}), ' ', contactToThem === false ? ph('input', {type: 'submit', name: 'unblock', value: 'unblock'}) : ph('input', {type: 'submit', name: 'block1', value: 'block…'}), ' ', ph('input', {type: 'submit', name: isMuted ? 'unmute' : 'mute', value: isMuted ? 'unmute' : 'mute', title: isMuted ? 'unmute (private unblock)' : 'mute (private block)'}), ' ', ph('input', {type: 'submit', name: self.requestedReplicate ? 'unreplicate' : 'replicate', value: self.requestedReplicate ? 'unreplicate' : 'replicate', title: self.requestedReplicate ? 'Temporarily cancel replicating this feed' : 'Temporarily replicate this feed'}) ])) }) }) } Serve.prototype.friendInfo = function (id, myId) { var first = false return pull( this.app.contacts.createFollowedFollowersStream(myId, id), this.app.render.friendsList(), pull.map(function (html) { if (!first) { first = true return 'followed by your friends: ' + html } return html }) ) } Serve.prototype.wrapUserFeed = function (isScrolled, id) { var self = this var myId = self.app.sbot.id var render = self.app.render return function (thread) { return cat([ ph('section', {class: 'ssb-feed'}, ph('table', [ isScrolled ? '' : ph('tr', [ ph('td', self.phIdAvatar(id)), ph('td', {class: 'feed-about'}, [ ph('h3', {class: 'feed-name'}, ph('strong', self.phIdLink(id))), ph('code', ph('small', id)), self.aboutDescription(id) ]) ]), ph('tr', [ ph('td'), ph('td', [ ph('a', {href: render.toUrl('/contacts/' + id)}, 'contacts'), ' ', ph('a', {href: render.toUrl('/about/' + id)}, 'about'), id === myId ? [' ', ph('a', {href: render.toUrl('/about-self')}, 'about-self')] : '', !isScrolled ? u.readNext(function (cb) { self.app.isPub(id, function (err, isPub) { if (err) return cb(err) if (!isPub) return cb(null, pull.empty()) cb(null, ph('span', [' ', ph('a', {href: render.toUrl('/pub/' + id)}, 'pub')])) }) }) : '' ]) ]), ph('tr', [ ph('td'), ph('td', ph('form', {action: render.toUrl('/advsearch'), method: 'get'}, [ ph('input', {type: 'hidden', name: 'source', value: id}), ph('input', {type: 'text', name: 'text', placeholder: 'text'}), ph('input', {type: 'submit', value: 'search'}) ]) ) ]), isScrolled || id === myId ? '' : [ ph('tr', [ ph('td'), ph('td', {class: 'follow-info'}, self.followInfo(id, myId)) /* ]), ph('tr', [ ph('td'), ph('td', self.friendInfo(id, myId)) */ ]) ] ])), thread ]) } } Serve.prototype.git = function (url) { var m = /^\/?([^\/]*)\/?(.*)?$/.exec(url) switch (m[1]) { case 'object': return this.gitObject(m[2]) 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]) case 'diff': return this.gitDiff(m[2]) case 'signature': return this.gitSignature(m[2]) case 'line-comment': return this.gitLineComment(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 object ' + 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.gitObject = 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) ) if (self.query.search) { return self.app.git.getObjectMsg({ obj: rev, headMsgId: self.query.msg, }, function (err, msg) { if (err && err.name === 'BlobNotFoundError') return self.askWantBlobs(err.links) if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('git object ' + rev), self.respondSink(400) ) var path = '/git/object/' + rev + '?msg=' + encodeURIComponent(msg.key) return self.redirect(self.app.render.toUrl(path)) }) } 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 object ' + rev), self.respondSink(400) ) self.app.git.statObject(obj, function (err, stat) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('git object ' + rev), self.respondSink(400) ) var path = '/git/' + stat.type + '/' + rev + '?msg=' + encodeURIComponent(self.query.msg) return self.redirect(self.app.render.toUrl(path)) }) }) } Serve.prototype.gitSignature = function (id) { var self = this if (!/[0-9a-f]{24}/.test(id)) { return pull( ph('div.error', '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 signature for ' + id), self.respondSink(400) ) self.app.git.openObject({ obj: id, msg: self.query.msg, type: self.query.type, }, function (err, obj) { if (err) return handleError(err) var msgDate = new Date(obj.msg.value.timestamp) self.app.verifyGitObjectSignature(obj, function (err, verification) { if (err) return handleError(err) var objPath = '/git/object/' + id + '?msg=' + encodeURIComponent(obj.msg.key) pull( ph('section', [ ph('h3', [ ph('a', {href: self.app.render.toUrl(objPath)}, id), ': ', ph('a', {href: ''}, 'signature') ]), ph('div', [ self.phIdLink(obj.msg.value.author), ' pushed ', ph('a', { href: self.app.render.toUrl(obj.msg.key), title: msgDate.toLocaleString(), }, htime(msgDate)) ]), ph('pre', u.escapeHTML(verification.output)) /* verification.goodsig ? 'good' : 'bad', ph('pre', u.escapeHTML(verification.status)) */ ]), self.wrapPage('git signature for ' + id), self.respondSink(200) ) }) }) function handleError(err) { if (err && err.name === 'BlobNotFoundError') return self.askWantBlobs(err.links) if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('git signature for ' + id), self.respondSink(400) ) } } 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) ) if (self.query.search) { return self.app.git.getObjectMsg({ obj: rev, headMsgId: self.query.msg, }, function (err, msg) { 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 path = '/git/commit/' + rev + '?msg=' + encodeURIComponent(msg.key) return self.redirect(self.app.render.toUrl(path)) }) } self.app.git.openObject({ obj: rev, msg: self.query.msg, type: 'commit', }, 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') )]) : '', commit.gpgsig ? ph('div', [ ph('a', {href: self.app.render.toUrl( '/git/signature/' + rev + '?msg=' + encodeURIComponent(self.query.msg) )}, 'signature'), commit.signatureVersion ? [' from ', ph('code', u.escapeHTML(commit.signatureVersion))] : '' ]) : '', h('blockquote', self.app.render.gitCommitBody(commit.body)).outerHTML, ph('h4', 'files'), ph('table', pull( self.app.git.readCommitChanges(commit), pull.map(function (file) { var msg = file.msg || obj.msg return ph('tr', [ ph('td', ph('code', u.escapeHTML(file.name))), ph('td', file.deleted ? 'deleted' : file.created ? ph('a', {href: self.app.render.toUrl('/git/blob/' + (file.hash[1] || file.hash[0]) + '?msg=' + encodeURIComponent(msg.key)) + '&commit=' + rev + '&path=' + encodeURIComponent(file.name) + '&search=1' }, 'created') : file.hash ? ph('a', {href: self.app.render.toUrl('/git/diff/' + file.hash[0] + '..' + file.hash[1] + '?msg=' + encodeURIComponent(msg.key)) + '&commit=' + rev + '&path=' + encodeURIComponent(file.name) + '&search=1' }, 'changed') : file.mode ? 'mode changed' : JSON.stringify(file)) ]) }), Catch(function (err) { if (err && err.name === 'ObjectNotFoundError') return if (err && err.name === 'BlobNotFoundError') return self.askWantBlobsForm(err.links) return false }) )) ] ]), 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) ) if (self.query.search) { return self.app.git.getObjectMsg({ obj: rev, headMsgId: self.query.msg, }, function (err, msg) { 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 path = '/git/tag/' + rev + '?msg=' + encodeURIComponent(msg.key) return self.redirect(self.app.render.toUrl(path)) }) } self.app.git.openObject({ obj: rev, msg: self.query.msg, type: 'tag', }, 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) { if (err && err.message === 'expected type \'tag\' but found \'commit\'') { var path = '/git/commit/' + rev + '?msg=' + encodeURIComponent(self.query.msg) return self.redirect(self.app.render.toUrl(path)) } 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)), tag.gpgsig ? ph('div', [ ph('a', {href: self.app.render.toUrl( '/git/signature/' + rev + '?msg=' + encodeURIComponent(self.query.msg) )}, 'signature'), tag.signatureVersion ? [' from ', ph('code', u.escapeHTML(tag.signatureVersion))] : '' ]) : '', 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.readTreeFull(obj), 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)) ), ]) }), Catch(function (err) { if (err && err.name === 'ObjectNotFoundError') return if (err && err.name === 'BlobNotFoundError') return self.askWantBlobsForm(err.links) return false }) ) ]), ]), 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) ) if (self.query.search) { return self.app.git.getObjectMsg({ obj: rev, headMsgId: self.query.msg, }, function (err, msg) { if (err && err.name === 'BlobNotFoundError') return self.askWantBlobs(err.links) if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('git blob ' + rev), self.respondSink(400) ) var path = '/git/blob/' + rev + '?msg=' + encodeURIComponent(msg.key) return self.redirect(self.app.render.toUrl(path)) }) } self.getMsgDecryptedMaybeOoo(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({ obj: obj, 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.gitDiff = function (revs) { var self = this var parts = revs.split('..') if (parts.length !== 2) return pull( ph('div.error', 'revs should be ..'), self.wrapPage('git diff'), self.respondSink(400) ) var rev1 = parts[0] var rev2 = parts[1] if (!/[0-9a-f]{24}/.test(rev1)) return pull( ph('div.error', 'rev 1 is not a git object id'), self.wrapPage('git diff'), self.respondSink(400) ) if (!/[0-9a-f]{24}/.test(rev2)) return pull( ph('div.error', 'rev 2 is not a git object id'), self.wrapPage('git diff'), self.respondSink(400) ) if (!u.isRef(self.query.msg)) return pull( ph('div.error', 'missing message id'), self.wrapPage('git diff'), self.respondSink(400) ) var done = multicb({pluck: 1, spread: true}) // the msg qs param should point to the message for rev2 object. the msg for // rev1 object we will have to look up. self.app.git.getObjectMsg({ obj: rev1, headMsgId: self.query.msg, type: 'blob', }, done()) self.getMsgDecryptedMaybeOoo(self.query.msg, done()) done(function (err, msg1, msg2) { if (err && err.name === 'BlobNotFoundError') return self.askWantBlobs(err.links) if (err) return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('git diff ' + revs), self.respondSink(400) ) var msg1Date = new Date(msg1.value.timestamp) var msg2Date = new Date(msg2.value.timestamp) var revsShort = rev1.substr(0, 8) + '..' + rev2.substr(0, 8) var path = self.query.path && String(self.query.path) var ext = path && path.replace(/^[^.\/]*/, '') var blob1Url = '/git/blob/' + rev1 + '?msg=' + encodeURIComponent(msg1.key) + (ext ? '&ext=' + encodeURIComponent(ext) : '') var blob2Url = '/git/blob/' + rev2 + '?msg=' + encodeURIComponent(msg2.key) + (ext ? '&ext=' + encodeURIComponent(ext) : '') pull( ph('section', [ ph('h3', ph('a', {href: ''}, revsShort)), ph('div', [ ph('a', { href: self.app.render.toUrl(blob1Url) }, rev1), ' ', self.phIdLink(msg1.value.author), ' ', ph('a', { href: self.app.render.toUrl(msg1.key), title: msg1Date.toLocaleString(), }, htime(msg1Date)) ]), ph('div', [ ph('a', { href: self.app.render.toUrl(blob2Url) }, rev2), ' ', self.phIdLink(msg2.value.author), ' ', ph('a', { href: self.app.render.toUrl(msg2.key), title: msg2Date.toLocaleString(), }, htime(msg2Date)) ]), u.readNext(function (cb) { var done = multicb({pluck: 1, spread: true}) self.app.git.openObject({ obj: rev1, msg: msg1.key, }, done()) self.app.git.openObject({ obj: rev2, msg: msg2.key, }, done()) /* self.app.git.guessCommitAndPath({ obj: rev2, msg: msg2.key, }, done()) */ done(function (err, obj1, obj2/*, info2*/) { if (err && err.name === 'BlobNotFoundError') return cb(null, self.askWantBlobsForm(err.links)) if (err) return cb(err) var done = multicb({pluck: 1, spread: true}) pull.collect(done())(self.app.git.readObject(obj1)) pull.collect(done())(self.app.git.readObject(obj2)) self.app.getLineComments({obj: obj2, hash: rev2}, done()) done(function (err, bufs1, bufs2, lineComments) { if (err) return cb(err) var str1 = Buffer.concat(bufs1, obj1.length).toString('utf8') var str2 = Buffer.concat(bufs2, obj2.length).toString('utf8') var diff = Diff.structuredPatch('', '', str1, str2) cb(null, self.gitDiffTable(diff, lineComments, { obj: obj2, hash: rev2, commit: self.query.commit, // info2.commit, path: self.query.path, // info2.path, })) }) }) }) ]), self.wrapPage('git diff'), self.respondSink(200) ) }) } Serve.prototype.gitDiffTable = function (diff, lineComments, lineCommentInfo) { var updateMsg = lineCommentInfo.obj.msg var self = this return pull( ph('table', [ pull( pull.values(diff.hunks), pull.map(function (hunk) { var oldLine = hunk.oldStart var newLine = hunk.newStart return [ ph('tr', [ ph('td', {colspan: 3}), ph('td', ph('pre', '@@ -' + oldLine + ',' + hunk.oldLines + ' ' + '+' + newLine + ',' + hunk.newLines + ' @@')) ]), pull( pull.values(hunk.lines), pull.map(function (line) { var s = line[0] if (s == '\\') return var html = self.app.render.highlight(line) var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++] var hash = lineCommentInfo.hash var newLineNum = lineNums[lineNums.length-1] var id = hash + '-' + (newLineNum || (lineNums[0] + '-')) var idEnc = encodeURIComponent(id) var allowComment = s !== '-' && self.query.commit && self.query.path return [ ph('tr', { class: s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : '' }, [ lineNums.map(function (num, i) { return ph('td', [ ph('a', { name: i === 0 ? idEnc : undefined, href: '#' + idEnc }, String(num)) ]) }), ph('td', allowComment ? ph('a', { href: '?msg=' + encodeURIComponent(self.query.msg) + '&comment=' + idEnc + '&commit=' + encodeURIComponent(self.query.commit) + '&path=' + encodeURIComponent(self.query.path) + '#' + idEnc }, '…') : '' ), ph('td', ph('pre', html)) ]), (lineComments[newLineNum] ? ph('tr', ph('td', {colspan: 4}, self.renderLineCommentThread(lineComments[newLineNum], id) ) ) : newLineNum && lineCommentInfo && self.query.comment === id ? ph('tr', ph('td', {colspan: 4}, self.renderLineCommentForm({ id: id, line: newLineNum, updateId: updateMsg.key, blobId: hash, repoId: updateMsg.value.content.repo, commitId: lineCommentInfo.commit, filePath: lineCommentInfo.path, }) ) ) : '') ] }) ) ] }) ) ]) ) } Serve.prototype.renderLineCommentThread = function (lineComment, id) { return this.streamThreadWithComposer({ root: lineComment.msg.key, id: id, placeholder: 'reply to line comment thread' }) } Serve.prototype.renderLineCommentForm = function (opts) { return [ this.phComposer({ placeholder: 'comment on this line', id: opts.id, lineComment: opts }) ] } // return a composer, pull-hyperscript style Serve.prototype.phComposer = function (opts) { var self = this return u.readNext(function (cb) { self.composer(opts, function (err, composer) { if (err) return cb(err) cb(null, pull.once(composer.outerHTML)) }) }) } Serve.prototype.gitLineComment = function (path) { var self = this var id try { id = decodeURIComponent(String(path)) if (id[0] === '%') { return self.getMsgDecryptedMaybeOoo(id, gotMsg) } else { msg = JSON.parse(id) } } catch(e) { return gotMsg(e) } gotMsg(null, msg) function gotMsg(err, msg) { if (err) return pull( pull.once(u.renderError(err).outerHTML), self.respondSink(400, {'Content-Type': ctype('html')}) ) var c = msg && msg.value && msg.value.content if (!c) return pull( pull.once('Missing message ' + id), self.respondSink(500, {'Content-Type': ctype('html')}) ) self.app.git.diffFile({ msg: c.updateId, commit: c.commitId, path: c.filePath, }, function (err, file) { 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'}) ) var path if (file.created) { path = '/git/blob/' + file.hash[1] + '?msg=' + encodeURIComponent(c.updateId) + '&commit=' + c.commitId + '&path=' + encodeURIComponent(c.filePath) + '#' + file.hash[1] + '-' + c.line } else { path = '/git/diff/' + file.hash[0] + '..' + file.hash[1] + '?msg=' + encodeURIComponent(c.updateId) + '&commit=' + c.commitId + '&path=' + encodeURIComponent(c.filePath) + '#' + file.hash[1] + '-' + c.line } var url = self.app.render.toUrl(path) /* return pull( ph('a', {href: url}, path), self.wrapPage(id), self.respondSink(200) ) */ self.redirect(url) }) } } 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('th', 'publisher'), ph('th', 'package'), ph('th', 'version'), ph('th', 'tag'), ph('th', 'size'), ph('th', 'tarball'), ph('th', '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.npmPrebuilds = 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 prefix = 'prebuild:' + (name ? name + '-' + (version ? version + '-' : '') : '') var render = self.app.render var base = '/npm-prebuilds/' + (author ? u.escapeId(author) + '/' : '') return pull( ph('section', {}, [ ph('h3', [ph('a', {href: render.toUrl('/npm-prebuilds/')}, 'npm prebuilds'), ' : ', name ? [ph('a', {href: render.toUrl(base + name)}, name), ' : '] : '', version ? [ph('a', {href: render.toUrl(base + name + '/' + version)}, version)] : '', ]), ph('table', [ ph('thead', ph('tr', [ ph('th', 'publisher'), ph('th', 'name'), ph('th', 'version'), ph('th', 'runtime'), ph('th', 'abi'), ph('th', 'platform+libc'), ph('th', 'arch'), ph('th', 'size'), ph('th', 'tarball') ])), ph('tbody', pull( self.app.blobMentions({ name: {$prefix: prefix}, author: author, }), paramap(function (link, cb) { self.app.render.npmPrebuildMention(link, { withAuthor: true, author: author, name: name, version: version, }, 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) ) } Serve.prototype.npmRegistry = function (url) { var self = this self.req.url = url self.app.serveSsbNpmRegistry(self.req, self.res) } Serve.prototype.markdown = function (url) { var self = this var id = decodeURIComponent(url.substr(1)) if (typeof self.query.unbox === 'string') id += '?unbox=' + self.query.unbox.replace(/\s/g, '+') return pull( ph('section', {}, [ ph('h3', [ ph('a', {href: '/links/' + id}, id.substr(0, 8) + '…') ]), u.readNext(function (cb) { self.app.getHasBlob(id, function (err, has) { if (err) return cb(err) if (!has) return cb(null, self.askWantBlobsForm([id])) pull(self.app.getBlob(id), pull.collect(function (err, chunks) { if (err) return cb(null, ph('div', u.renderError(err).outerHTML)) var text = Buffer.concat(chunks).toString() cb(null, ph('blockquote', self.app.render.markdown(text))) })) }) }) ]), self.wrapPage('markdown'), self.respondSink(200) ) } Serve.prototype.zip = function (url) { var self = this var parts = url.split('/').slice(1) var id = decodeURIComponent(parts.shift()) var filename = parts.join('/') var blobs = self.app.sbot.blobs var etag = '"' + id + filename + '"' var index = filename === '' || /\/$/.test(filename) var indexFilename = index && (filename + 'index.html') if (filename === '/' || /\/\/$/.test(filename)) { // force directory listing if path ends in // filename = filename.replace(/\/$/, '') indexFilename = false } var files = index && [] if (self.req.headers['if-none-match'] === etag) return self.respond(304) blobs.size(id, function (err, size) { if (size == null) return askWantBlobsForm([id]) if (err) { if (/^invalid/.test(err.message)) return self.respond(400, err.message) else return self.respond(500, err.message || err) } var unzip = require('unzip') var parseUnzip = unzip.Parse() var gotEntry = false parseUnzip.on('entry', function (entry) { if (index) { if (!gotEntry) { if (entry.path === indexFilename) { gotEntry = true return serveFile(entry) } else if (entry.path.substr(0, filename.length) === filename) { files.push({path: entry.path, type: entry.type, props: entry.props}) } } } else { if (!gotEntry && entry.path === filename) { gotEntry = true // if (false && entry.type === 'Directory') return serveDirectory(entry) return serveFile(entry) } } entry.autodrain() }) parseUnzip.on('close', function () { if (gotEntry) return if (!index) return self.respond(404, 'Entry not found') pull( ph('section', {}, [ ph('h3', [ ph('a', {href: self.app.render.toUrl('/links/' + id)}, id.substr(0, 8) + '…'), ' ', ph('a', {href: self.app.render.toUrl('/zip/' + encodeURIComponent(id) + '/' + filename)}, filename || '/'), ]), pull( pull.values(files), pull.map(function (file) { var path = '/zip/' + encodeURIComponent(id) + '/' + file.path return ph('li', [ ph('a', {href: self.app.render.toUrl(path)}, file.path) ]) }) ) ]), self.wrapPage(id + filename), self.respondSink(200) ) gotEntry = true // so that the handler on error event does not run }) parseUnzip.on('error', function (err) { if (!gotEntry) return self.respond(400, err.message) }) var size function serveFile(entry) { size = entry.size pull( toPull.source(entry), ident(gotType), self.respondSink() ) } pull( self.app.getBlob(id), toPull(parseUnzip) ) function gotType(type) { type = type && mime.lookup(type) if (type) self.res.setHeader('Content-Type', type) if (size) self.res.setHeader('Content-Length', size) self.res.setHeader('Cache-Control', 'public, max-age=315360000') self.res.setHeader('ETag', etag) self.res.writeHead(200) } }) } Serve.prototype.web = function (url) { var self = this var id = url.substr(1) try { id = decodeURIComponent(id) } catch(e) {} var components = url.split('/') if (components[0] === '') components.shift() components[0] = decodeURIComponent(components[0]) var type = mime.lookup(components[components.length - 1]) var headers = {} if (type) headers['Content-Type'] = type webresolve(this.app.sbot, components, function (err, res) { if (err) { return pull( pull.once(err.toString()), self.respondSink(404) ) } headers['Content-Length'] = res.length return pull( pull.once(res), self.respondSink(200, headers) ) }) } Serve.prototype.script = function (url) { var self = this var filepath = url.split('?')[0] this.app.getScript(filepath, function (err, fn) { try { if (err) throw err fn(self) } catch(e) { return pull( pull.once(u.renderError(e).outerHTML), self.wrapPage('local: ' + path), self.respondSink(400) ) } }) } // wrap a binary source and render it or turn into an embed Serve.prototype.wrapBinary = function (opts) { var self = this var ext = opts.ext var hash = opts.obj.hash 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 }) } if (type === 'text/markdown') { // TODO: rewrite links to files/images to be correct return ph('blockquote', u.readNext(function (cb) { pull.collect(function (err, bufs) { if (err) return cb(pull.error(err)) var text = Buffer.concat(bufs).toString('utf8') return cb(null, pull.once(self.app.render.markdown(text))) })(read) })) } var i = 1 var updateMsg = opts.obj.msg var commitId = self.query.commit var filePath = self.query.path var lineComments = opts.lineComments || {} return u.readNext(function (cb) { if (commitId && filePath) { self.app.getLineComments({ obj: opts.obj, hash: hash, }, gotLineComments) } else { gotLineComments(null, {}) } function gotLineComments(err, lineComments) { if (err) return cb(err) cb(null, ph('table', pull( read, utf8(), split(), pull.map(function (line) { var lineNum = i++ var id = hash + '-' + lineNum var idEnc = encodeURIComponent(id) var allowComment = self.query.commit && self.query.path return [ ph('tr', [ ph('td', allowComment ? ph('a', { href: '?msg=' + encodeURIComponent(self.query.msg) + '&commit=' + encodeURIComponent(self.query.commit) + '&path=' + encodeURIComponent(self.query.path) + '&comment=' + idEnc + '#' + idEnc }, '…') : '' ), ph('td', ph('a', { name: id, href: '#' + idEnc }, String(lineNum))), ph('td', ph('pre', self.app.render.highlight(line, ext))) ]), lineComments[lineNum] ? ph('tr', ph('td', {colspan: 4}, self.renderLineCommentThread(lineComments[lineNum], id) ) ) : '', self.query.comment === id ? ph('tr', ph('td', {colspan: 4}, self.renderLineCommentForm({ id: id, line: lineNum, updateId: updateMsg.key, repoId: updateMsg.value.content.repo, commitId: commitId, blobId: hash, filePath: filePath, }) ) ) : '' ] }) ) )) } }) } } Serve.prototype.wrapPublic = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { self.composer({ channel: '', }, function (err, composer) { if (err) return cb(err) cb(null, [ composer, thread ]) }) }) } function uniqueLink() { var seen = {} return function (link) { if (seen[link.link]) return false return seen[link.link] = true } } 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).filter(uniqueLink()).map(function (link) { var id = removeQuery(link.link) if (!u.isRef(id)) 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) { self.composer({ placeholder: 'private message', private: true, }, function (err, composer) { if (err) return cb(err) cb(null, [ composer, thread ]) }) }) } Serve.prototype.wrapThread = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { self.app.render.prepareLinks(opts.recps, function (err, recps) { if (err) return cb(er) if (self.noComposer) return cb(null, thread) self.composer({ placeholder: opts.placeholder || (recps ? 'private reply' : 'reply'), id: 'reply', root: opts.root, post: opts.post, channel: opts.channel || '', branches: opts.branches, postBranches: opts.postBranches, recps: recps, links: opts.links, private: opts.recps != null, }, function (err, composer) { if (err) return cb(err) cb(null, [ thread, composer ]) }) }) }) } Serve.prototype.wrapNew = function (opts) { var self = this return u.hyperwrap(function (thread, cb) { self.composer({ channel: '', }, function (err, composer) { if (err) return cb(err) cb(null, [ composer, h('table.ssb-msgs', opts.reachedLimit ? h('tr', h('td.paginate.msg-left', {colspan: 3}, 'Reached limit of ' + opts.reachedLimit + ' messages' )) : '', thread, h('tr', h('td.paginate.msg-left', {colspan: 3}, h('form', {method: 'get', action: ''}, h('input', {type: 'hidden', name: 'gt', value: opts.gt}), self.query.limit ? h('input', {type: 'hidden', name: 'limit', value: self.query.limit}) : '', h('input', {type: 'hidden', name: 'catchup', value: '1'}), h('input', {type: 'submit', value: 'catchup'}) ) )) ) ]) }) }) } Serve.prototype.wrapChannel = function (channel) { var self = this return u.hyperwrap(function (thread, cb) { self.composer({ placeholder: 'public message in #' + channel, channel: channel, }, function (err, composer) { if (err) return cb(err) cb(null, [ h('section', h('h3.feed-name', h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel) ) ), composer, thread ]) }) }) } Serve.prototype.wrapType = function (type) { var self = this return u.hyperwrap(function (thread, cb) { cb(null, [ h('section', h('h3.feed-name', h('a', {href: self.app.render.toUrl('/type/' + type)}, h('code', type), 's')) ), thread ]) }) } Serve.prototype.wrapLinks = function (dest) { var self = this return u.hyperwrap(function (thread, cb) { cb(null, [ h('section', h('h3.feed-name', 'links: ', h('a', {href: self.app.render.toUrl('/links/' + dest)}, h('code', dest))) ), thread ]) }) } Serve.prototype.wrapPeers = function (opts) { var self = this return u.hyperwrap(function (peers, cb) { cb(null, [ h('section', h('h3', 'Peers') ), peers ]) }) } Serve.prototype.wrapChannels = function (opts) { var self = this return u.hyperwrap(function (channels, cb) { cb(null, [ h('section', 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 ) ]) }) } var blobPrefixesByType = { audio: 'audio:', video: 'video:', } var blobPrefixesByExt = { mp3: 'audio:', mp4: 'video:', } Serve.prototype.composer = function (opts, cb) { var self = this opts = opts || {} var data = self.data var myId = self.app.sbot.id var links = opts.links || [] if (opts.id && data.composer_id && opts.id !== data.composer_id) { // don't share data between multiple composers data = {} } if (!data.text && self.query.text) data.text = self.query.text if (!data.action && self.query.action) data.action = self.query.action var blobs = u.tryDecodeJSON(data.blobs) || {} var upload = data.upload if (upload && typeof upload === 'object') { data.upload = null var href = upload.link + (upload.key ? '?unbox=' + upload.key + '.boxs': '') var blobType = String(upload.type).split('/')[0] var blobExt = String(upload.name).split('.').pop() var blobPrefix = blobPrefixesByType[blobType] || blobPrefixesByExt[blobExt] || '' var isMedia = blobPrefix || blobType === 'image' var blobName = blobPrefix + upload.name blobs[upload.link] = { type: upload.type, size: upload.size, name: blobName, key: upload.key, } data.text = (data.text ? data.text + '\n' : '') + (isMedia ? '!' : '') + '[' + blobName + '](' + href + ')' } var channel = data.channel != null ? data.channel : opts.channel var formNames = {} var mentionIds = u.toArray(data.mention_id) var mentionNames = u.toArray(data.mention_name) for (var i = 0; i < mentionIds.length && i < mentionNames.length; i++) { 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, name: upload.name, } } var blobIds = u.toArray(data.blob_id) var blobTypes = u.toArray(data.blob_type) var blobNames = u.toArray(data.blob_name) for (var i = 0; i < blobIds.length && i < blobTypes.length; i++) { var id = blobIds[i] var blob = blobs[id] || (blobs[id] = {}) blob.type = blobTypes[i] blob.nameOverride = data['blob_name_override_' + i] blob.name = blobNames[i] } // get bare feed names var unknownMentionNames = {} var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) var unknownMentions = mentions .filter(function (mention) { return mention.link === '@' }) .map(function (mention) { return mention.name }) .filter(uniques()) .map(function (name) { var id = formNames[name] || self.app.getReverseNameSync('@' + name) 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 // 4. check recently seen emoji id = self.app.getReverseEmojiNameSync(name) return {name: name, id: id} }) var blobMentions = mentions .filter(function (mention) { return mention && typeof mention.link === 'string' && mention.link[0] === '&' }) blobMentions.forEach(function (mention) { var blob = blobs[mention.link] if (blob) { mention.type = blob.type if (blob.nameOverride) mention.name = blob.name } }) // strip content other than names and feed ids from the recps field if (data.recps) { data.recps = recpsToFeedIds(data.recps) } var draftLinkContainer function renderDraftLink(draftId) { if (!draftId) return [] var draftHref = self.app.render.toUrl('/drafts/' + encodeURI(draftId)) return [ h('a', {href: draftHref, title: 'draft link'}, u.escapeHTML(draftId)), h('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}) ] } var done = multicb({pluck: 1, spread: true}) done()(null, h('section.composer', h('form', {method: 'post', action: opts.id ? '#' + opts.id : '', enctype: 'multipart/form-data'}, h('input', {type: 'hidden', name: 'blobs', value: JSON.stringify(blobs)}), h('input', {type: 'hidden', name: 'url', value: self.req.url}), h('input', {type: 'hidden', name: 'composer_id', value: opts.id}), opts.recps ? self.app.render.privateLine({recps: opts.recps, isAuthorRecp: true}, done()) : opts.private ? h('div', h('input.wide', {name: 'recps', size: 64, value: data.recps || '', placeholder: 'recipient ids'})) : '', channel != null ? h('div', '#', h('input', {name: 'channel', placeholder: 'channel', value: channel})) : '', opts.root !== opts.post ? h('div', h('label', {for: 'fork_thread'}, h('input', {id: 'fork_thread', type: 'checkbox', name: 'fork_thread', value: 'post', checked: data.fork_thread || undefined}), ' fork thread' ) ) : '', closeIssueCheckbox(done()), mentionAttendeesCheckbox(done()), h('div', h('input.wide', {name: 'content_warning', size: 64, value: data.content_warning || '', placeholder: 'Content Warning'})), h('textarea', { id: opts.id, name: 'text', rows: Math.max(4, u.rows(data.text)), cols: 64, placeholder: opts.placeholder || 'public message', }, data.text || ''), unknownMentions.length > 0 ? [ h('div', h('em', 'names:')), h('ul.mentions', unknownMentions.map(function (mention) { return h('li', h('code', '@' + mention.name), ': ', h('input', {name: 'mention_name', type: 'hidden', value: mention.name}), h('input.id-input', {name: 'mention_id', size: 60, 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})) })) ] : '', blobMentions.length > 0 ? [ h('div', h('em', 'blobs:')), h('ul.mentions', blobMentions.map(function (link, i) { var link1 = blobs[link.link] || {} var linkHref = self.app.render.toUrl(link.link) return h('li', h('a', {href: linkHref}, h('code', String(link.link).substr(0, 8) + '…')), ' ', h('input', {name: 'blob_id', type: 'hidden', value: link.link}), h('input', {name: 'blob_type', size: 24, value: link.type, placeholder: 'application/octet-stream', title: 'blob mime-type'}), h('input', {name: 'blob_name', size: 24, value: link.name || '', placeholder: 'name', title: 'blob name'}), ' ', h('input', {name: 'blob_name_override_' + i, type: 'checkbox', value: 1, checked: link1.nameOverride ? '1' : undefined, title: 'override name in markdown'})) })) ] : '', h('table.ssb-msgs', h('tr.msg-row', h('td.msg-left', {colspan: 2}, opts.private ? h('input', {type: 'hidden', name: 'private', value: '1'}) : '', h('input', {type: 'file', name: 'upload'}) ), h('td.msg-right', draftLinkContainer = h('span', renderDraftLink(data.draft_id)), ' ', h('label', {for: 'save_draft'}, h('input', {type: 'checkbox', id: 'save_draft', name: 'save_draft', value: '1', checked: data.save_draft || data.restored_draft ? 'checked' : undefined}), ' save draft '), h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ', h('input', {type: 'submit', name: 'action', value: 'preview'}) ) ) ), data.action === 'preview' ? preview(false, done()) : data.action === 'raw' ? preview(true, done()) : '' ) )) done(cb) function recpsToFeedIds (recps) { var res = data.recps.split(',') .map(function (str) { str = str.trim() var ids = u.extractFeedIds(str).filter(uniques()) if (ids.length >= 1) { return ids[0] } else { ids = u.extractFeedIds(self.app.getReverseNameSync(str)) if (ids.length >= 1) { return ids[0] } else { return null } } }) .filter(Boolean) return res.join(', ') } function prepareContent(cb) { var done = multicb({pluck: 1}) content = { type: 'post', text: String(data.text), } if (opts.lineComment) { content.type = 'line-comment' content.updateId = opts.lineComment.updateId content.repo = opts.lineComment.repoId content.commitId = opts.lineComment.commitId content.filePath = opts.lineComment.filePath content.blobId = opts.lineComment.blobId content.line = opts.lineComment.line } var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true}) .filter(function (mention) { if (typeof mention.name === 'string') { mention.name = mention.name .replace(/'/g, "'") } if (mention.emoji) { mention.link = formEmojiNames[mention.name] if (!mention.link) { 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 if (blob.nameOverride) mention.name = blob.name } 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 }) .map(reduceLink) 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.fork = opts.root || undefined content.branch = u.fromArray(opts.postBranches) || undefined } else { content.root = opts.root || undefined content.branch = u.fromArray(opts.branches) || undefined } if (data.close_issue) { content.issue = opts.root || undefined content.project = data.project || undefined content.repo = data.repo || undefined content.issues = [ { link: opts.root, open: false } ] } if (self.replyMentionFeeds && links && content.branch) { var reply = {} var ids = {} u.toArray(content.branch).forEach(function (branch) { ids[branch] = true }) links.forEach(function (link) { if (ids[link.key]) { var author = link.value.author if (author !== myId) reply[link.key] = author } }) if (Object.keys(reply).length > 0) content.reply = reply } if (data.mention_attendees) { var attendeeLinks = u.toArray(String(data.attendees || '').split(',')) .filter(function (id) { return id !== myId }) if (!content.mentions) content.mentions = attendeeLinks else { var alreadyMentioned = {} content.mentions.map(u.linkDest).forEach(function (id) { alreadyMentioned[id] = true }) attendeeLinks.forEach(function (link) { if (!alreadyMentioned[link]) content.mentions.push(link) }) } } if (data.content_warning) content.contentWarning = String(data.content_warning) if (channel) content.channel = data.channel done(function (err) { cb(err, content) }) } function closeIssueCheckbox(cb) { var container = h('div') if (opts.root && opts.root[0] === '%') self.getMsgDecryptedMaybeOoo(opts.root, function (err, rootMsg) { if (err) return console.trace(err), cb(null) var rootC = rootMsg && rootMsg.value.content && rootMsg.value.content if (!rootC) return cb(null) var canCloseIssue = rootC.type === 'issue' var canClosePR = rootC.type === 'pull-request' if (canCloseIssue || canClosePR) container.appendChild(h('label', {for: 'close_issue'}, h('input', {id: 'close_issue', type: 'checkbox', name: 'close_issue', value: 'post', checked: data.close_issue || undefined}), ' close ' + (canClosePR ? 'pull-request' : 'issue'), rootC.project ? h('input', {type: 'hidden', name: 'project', value: rootC.project}) : '', rootC.repo ? h('input', {type: 'hidden', name: 'repo', value: rootC.repo}) : '' )) cb(null) }) else cb(null) return container } function mentionAttendeesCheckbox(cb) { var container = h('div') if (opts.root && opts.root[0] === '%') self.getMsgDecryptedMaybeOoo(opts.root, function (err, rootMsg) { if (err) return console.trace(err), cb(null) var rootC = rootMsg && rootMsg.value.content && rootMsg.value.content if (!rootC) return cb(null) var canMentionAttendees = rootC.type === 'gathering' if (!canMentionAttendees) return cb(null) if (opts.id === opts.root) gotLinks(null, links) else pull( self.app.getLinksBy(opts.root, 'about'), pull.unique('key'), self.app.unboxMessages(), pull.collect(gotLinks) ) function gotLinks(err, links2) { if (err) console.trace(err), links2 = links var attendees = {} links2.forEach(function (link) { var c = link && link.value && link.value.content var attendee = c && c.type === 'about' && c.about === opts.root && u.toLink(c.attendee) if (!attendee) return var author = link.value.author if (attendee.link !== author) return if (attendee.remove) delete attendees[author] else attendees[author] = true }) var attendeeIds = Object.keys(attendees) container.appendChild(h('label', {for: 'mention_attendees'}, h('input', {id: 'mention_attendees', type: 'checkbox', name: 'mention_attendees', value: 'post', checked: data.mention_attendees || undefined}), ' mention attendees (' + attendeeIds.length + ')', h('input', {type: 'hidden', name: 'attendees', value: attendeeIds.join(',')}) )) cb(null) } }) else cb(null) return container } function preview(raw, cb) { var msgContainer = h('table.ssb-msgs') var contentInput = h('input', {type: 'hidden', name: 'content'}) var warningsContainer = h('div') var sizeEl = h('span') var blobsSizeEl = h('span') var blobsSizeContainer = h('span') var content 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) content = _content if (data.save_draft) self.saveDraft(content, saved) else saved() } function saved(err, draftId) { if (err) return cb(err) if (draftId) { draftLinkContainer.childNodes = renderDraftLink(draftId) } contentInput.value = JSON.stringify(content) var msg = { value: { author: myId, timestamp: Date.now(), content: content } } if (content.recps) msg.value.private = true var warnings = [] var blobsSize = 0 u.toLinkArray(content.mentions).forEach(function (link) { var isBlob = link.link[0] === '&' if (isBlob && !isNaN(link.size)) blobsSize += link.size 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 (isBlob && link.size >= 5242880) { warnings.push(h('li', 'linked blob ', h('code', String(link.link).substr(0, 8) + '…'), ' (', h('code', self.app.render.formatSize(link.size)), ')', ' is larger than 5MB')) } }) var estSize = u.estimateMessageSize(content) sizeEl.innerHTML = self.app.render.formatSize(estSize) if (estSize > 8192) warnings.push(h('li', 'message is too long')) if (blobsSize) blobsSizeContainer.appendChild(h('span', ', ', h('em', {title: 'total size of linked blobs'}, 'blobs:'), ' ', self.app.render.formatSize(blobsSize) )) 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: raw, dualMarkdown: self.query.dualMd != null ? self.query.dualMd : self.conf.dualMarkdownPreview, serve: self, filter: self.query.filter, }), pull.drain(function (el) { msgContainer.appendChild(h('tbody', el)) }, cb) ) } return [ contentInput, opts.redirectToPublishedMsg ? h('input', {type: 'hidden', name: 'redirect_to_published_msg', value: '1'}) : '', warningsContainer, h('div', h('em', 'draft:'), ' ', sizeEl, blobsSizeContainer), msgContainer, h('div.composer-actions', h('input', {type: 'submit', name: 'action', value: 'publish'}) ) ] } } Serve.prototype.phPreview = function (content, opts) { var self = this var msg = { value: { author: this.app.sbot.id, timestamp: Date.now(), content: content } } opts = opts || {} if (content.recps) msg.value.private = true var warnings = [] var estSize = u.estimateMessageSize(content) if (estSize > 8192) warnings.push(ph('li', 'message is too long')) var aboutNewContent = opts.aboutNewContent, aboutNewMsg if (aboutNewContent) { aboutNewMsg = { value: { author: this.app.sbot.id, timestamp: Date.now(), content: aboutNewContent } } // this duplicates functionality in publishNewAbout, for display purposes if (content.recps) { aboutNewContent.recps = content.recps aboutNewMsg.value.private = true } } return ph('form', {action: '', method: 'post'}, [ ph('input', {type: 'hidden', name: 'redirect_to_published_msg', value: '1'}), warnings.length ? [ ph('div', ph('em', 'warning:')), ph('ul', {class: 'mentions'}, warnings) ] : '', ph('div', [ ph('em', 'draft:'), ' ', u.escapeHTML(this.app.render.formatSize(estSize)) ]), ph('table', {class: 'ssb-msgs'}, pull( aboutNewMsg ? pull.values([aboutNewMsg, msg]) : pull.once(msg), this.app.unboxMessages(), this.app.render.renderFeeds({ serve: self, raw: opts.raw, filter: this.query.filter, }), pull.map(u.toHTML) )), ph('input', {type: 'hidden', name: 'content', value: u.escapeHTML(JSON.stringify(content))}), aboutNewContent ? ph('input', {type: 'hidden', name: 'about_new_content', value: u.escapeHTML(JSON.stringify(aboutNewContent))}) : null, ph('div', {class: 'composer-actions'}, [ ph('input', {type: 'submit', name: 'action', value: 'publish'}) ]) ]) } Serve.prototype.phMsgActions = function (content) { var self = this var data = self.data function renderDraftLink(draftId) { return pull.values([ ph('a', {href: self.app.render.toUrl('/drafts/' + encodeURI(draftId)), title: 'draft link'}, u.escapeHTML(draftId)), ph('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}), ' ', ]) } return [ ph('input', {type: 'hidden', name: 'url', value: self.req.url}), ph('p', {class: 'msg-right'}, [ data.save_draft && content ? u.readNext(function (cb) { self.saveDraft(content, function (err, draftId) { if (err) return cb(err) cb(null, renderDraftLink(draftId)) }) }) : data.draft_id ? renderDraftLink(data.draft_id) : '', ph('label', {for: 'save_draft'}, [ ph('input', {type: 'checkbox', id: 'save_draft', name: 'save_draft', value: '1', checked: data.save_draft || data.restored_draft ? 'checked' : undefined}), ' save draft ' ]), ph('input', {type: 'submit', name: 'preview_raw', value: 'Raw'}), ' ', ph('input', {type: 'submit', name: 'preview', value: 'Preview'}), ]) ] } function hashBuf(buf) { var hash = crypto.createHash('sha256') hash.update(buf) return '&' + hash.digest('base64') + '.sha256' } Serve.prototype.getMsgDecryptedMaybeOoo = function (key, cb) { var self = this if (this.useOoo) this.app.getMsgDecryptedOoo(key, next) else this.app.getMsgDecrypted(key, next) function next(err, msg) { if (err) return cb(err) var c = msg && msg.value && msg.value.content if (typeof c === 'string' && self.query.unbox) self.app.unboxMsgWithKey(msg, String(self.query.unbox).replace(/ /g, '+'), cb) else cb(null, msg) } } 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) ) } Serve.prototype.editDiff = function (url) { var self = this var id try { id = decodeURIComponent(url.substr(1)) } catch(err) { return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('diff: ' + id), self.respondSink(400) ) } return pull( ph('section', {}, [ 'diff: ', ph('a', {href: self.app.render.toUrl(id)}, ph('code', id.substr(0, 8) + '…')), u.readNext(function (cb) { self.getMsgDecryptedMaybeOoo(id, function (err, msg) { if (err) return cb(null, pull.once(u.renderError(err).outerHTML)) var c = msg.value.content || {} self.getMsgDecryptedMaybeOoo(c.updated, function (err, oldMsg) { if (err) return cb(null, pull.once(u.renderError(err).outerHTML)) cb(null, self.textEditDiffTable(oldMsg, msg)) }) }) }) ]), self.wrapPage('diff: ' + id), self.respondSink(200) ) } function findMsg(msgs, id) { for (var i = 0; i < msgs.length; i++) { if (msgs[i].key === id) return i } return -1 } Serve.prototype.aboutDiff = function (url) { var self = this var id try { id = decodeURIComponent(url.substr(1)) } catch(err) { return pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('diff: ' + id), self.respondSink(400) ) } return pull( ph('section', {}, [ 'diff: ', ph('a', {href: self.app.render.toUrl(id)}, ph('code', id.substr(0, 8) + '…')), u.readNext(function (cb) { // About messages don't always include branch links. So get the whole thread // and use ssb-sort to find what to consider the previous message(s). self.getMsgDecryptedMaybeOoo(id, function (err, msg) { if (err) return cb(null, pull.once(u.renderError(err).outerHTML)) var c = msg.value.content || {} var rootId = c.about if (!rootId) return gotLinks(new Error('Missing about root')) var msgDate = new Date(msg.value.timestamp) cb(null, ph('div', [ self.phIdLink(msg.value.author), ' ', ph('span', {title: msgDate.toLocaleString()}, htime(msgDate)), ph('div', u.readNext(next.bind(this, rootId, msg))) ])) }) }) ]), self.wrapPage('diff: ' + id), self.respondSink(200) ) function next(rootId, msg, cb) { pull( rootId === msg.value.author && !self.query.fromAny ? self.app.getLinks3(rootId, msg.value.author, 'about') : self.app.getLinksBy(rootId, 'about'), pull.unique('key'), self.app.unboxMessages(), pull.collect(function (err, links) { if (err) return gotLinks(err) if (!self.useOoo) return gotLinks(null, links) self.app.expandOoo({msgs: links, dest: id}, gotLinks) }) ) function gotLinks(err, links) { if (err) return cb(null, pull.once(u.renderError(err).outerHTML)) sort(links) links = links.filter(function (msg) { var c = msg && msg.value && msg.value.content return c && c.type === 'about' && c.about === rootId && typeof c.description === 'string' }) var i = findMsg(links, id) if (i < 0) return cb(null, ph('div', 'Unable to find previous message')) var prevMsg = links[i-1] var nextMsg = links[i+1] var prevHref = prevMsg ? self.app.render.toUrl('/about-diff/' + encodeURIComponent(prevMsg.key)) : null var nextHref = nextMsg ? self.app.render.toUrl('/about-diff/' + encodeURIComponent(nextMsg.key)) : null cb(null, cat([ prevMsg ? pull.values(['prev: ', ph('a', {href: prevHref}, ph('code', prevMsg.key.substr(0, 8) + '…')), ', ']) : pull.empty(), nextMsg ? pull.values(['next: ', ph('a', {href: nextHref}, ph('code', nextMsg.key.substr(0, 8) + '…'))]) : pull.empty(), prevMsg || msg ? self.textEditDiffTable(prevMsg, msg) : pull.empty() ])) } } } Serve.prototype.textEditDiffTable = function (oldMsg, newMsg) { var oldC = oldMsg && oldMsg.value.content || {} var newC = newMsg && newMsg.value.content || {} var oldText = String(oldC.text || oldC.description || '') var newText = String(newC.text || newC.description || '') var diff = Diff.structuredPatch('', '', oldText, newText) var self = this // note: this structure is duplicated in lib/serve.js return pull( ph('table', {class: 'diff-table'}, [ pull( pull.values(diff.hunks), pull.map(function (hunk) { var oldLine = hunk.oldStart var newLine = hunk.newStart return [ ph('tr', [ ph('td', {colspan: 2}), ph('td', {colspan: 2}, ph('pre', '@@ -' + oldLine + ',' + hunk.oldLines + ' ' + '+' + newLine + ',' + hunk.newLines + ' @@')) ]), pull( pull.values(hunk.lines), pull.map(function (line) { var s = line[0] if (s == '\\') return var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++] return [ ph('tr', { class: s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : '' }, [ lineNums.map(function (num, i) { return ph('td', String(num)) }), ph('td', {class: 'diff-sigil'}, ph('code', s)), ph('td', {class: 'diff-line'}, [ u.unwrapP(self.app.render.markdown(line.substr(1), s == '-' ? oldC.mentions : newC.mentions)) ]) ]) ] }) ) ] }) ) ]) ) } Serve.prototype.shard = function (url) { var self = this var id try { id = decodeURIComponent(url.substr(1)) } catch(err) { return onError(err) } function onError(err) { pull( pull.once(u.renderError(err).outerHTML), self.wrapPage('shard: ' + id), self.respondSink(400) ) } self.app.getShard(id, function (err, shard) { if (err) return onError(err) pull( pull.once(shard), self.respondSink(200, {'Content-Type': 'text/plain'}) ) }) } Serve.prototype.pub = function (path) { var self = this var id = String(path).substr(1) try { id = decodeURIComponent(id) } catch(e) {} pull( ph('section', [ ph('h3', ['Pub addresses: ', self.phIdLink(id)]), pull( self.app.getAddresses(id), pull.map(function (address) { return ph('div', [ ph('code', self.app.removeDefaultPort(address)) ]) }) ) ]), self.wrapPage('Addresses: ' + id), self.respondSink(200) ) } function hiddenInput(key, value) { return Array.isArray(value) ? value.map(function (value) { return ph('input', {type: 'hidden', name: key, value: u.escapeHTML(value)}) }) : ph('input', {type: 'hidden', name: key, value: u.escapeHTML(value)}) } Serve.prototype.drafts = function (filepath) { var self = this var id = filepath && String(filepath).substr(1) if (id) try { id = decodeURIComponent(id) } catch(e) {} var dir if (id) try { var draftFile = path.join(self.app.draftsDir, id) var stat = fs.statSync(draftFile) if (stat.isDirectory()) { dir = id id = null } } catch(e) { return this.respond(404, 'Not found') } if (id) { var idHref = '/drafts' return pull( ph('section', [ ph('h3', [ ph('a', {href: self.app.render.toUrl('/drafts')}, 'Drafts'), id.split(/\/+/).map(function (part, i) { idHref += '/' + part var href = self.app.render.toUrl(idHref) return [ ': ', ph('a', {href: href}, u.escapeHTML(part)) ] }) ]), u.readNext(function (cb) { if (self.data.draft_discard) { return self.app.discardDraft(id, function (err) { if (err) return cb(err) cb(null, ph('div', 'Discarded')) }) } self.app.getDraft(id, function (err, draft) { if (err) return cb(err) var form = draft.form || {} var content = draft.content || {type: 'post', text: ''} var composerUrl = self.app.render.toUrl(draft.url) + (form.composer_id ? '#' + encodeURIComponent(form.composer_id) : '') cb(null, ph('div', [ ph('table', ph('tr', [ ph('td', ph('form', {method: 'post', action: u.escapeHTML(composerUrl)}, [ hiddenInput('draft_id', id), hiddenInput('restored_draft', '1'), Object.keys(form).map(function (key) { if (key === 'draft_id' || key === 'save_draft') return '' return hiddenInput(key, draft.form[key]) }), ph('input', {type: 'submit', name: 'draft_edit', value: 'Edit'}) ])), ph('td', ph('form', {method: 'post', action: ''}, [ ph('input', {type: 'submit', name: 'draft_discard', value: 'Discard', title: 'Discard draft'}) ])) ])), self.phPreview(content, {draftId: id}) ])) }) }) ]), self.wrapPage('draft: ' + id), self.respondSink(200) ) } var render = self.app.render var idHref = '/drafts' return pull( ph('section', [ ph('h3', [ ph('a', {href: render.toUrl('/drafts')}, 'Drafts'), dir ? dir.split(/\/+/).map(function (part, i) { idHref += '/' + part return [ ': ', ph('a', {href: render.toUrl(idHref)}, u.escapeHTML(part)) ] }) : '' ]), ph('ul', pull( self.app.listDrafts(dir), pull.asyncMap(function (draft, cb) { var form = draft.form || {} var msg = { key: '/drafts/' + draft.id, value: { author: self.app.sbot.id, timestamp: Date.now(), content: draft.content || ( draft.isDir ? {type: draft.id + '/'} : {type: 'post'} ) } } cb(null, ph('li', self.app.render.phMsgLink(msg))) }) )) ]), self.wrapPage('drafts'), self.respondSink(200) ) }