Built with git-ssb-web
var fs = require('fs') var http = require('http') var path = require('path') var url = require('url') var qs = require('querystring') var util = require('util') var ref = require('ssb-ref') var pull = require('pull-stream') var ssbGit = require('ssb-git-repo') var toPull = require('stream-to-pull-stream') var cat = require('pull-cat') var GitRepo = require('pull-git-repo') var u = require('./lib/util') var markdown = require('./lib/markdown') var paginate = require('pull-paginate') var asyncMemo = require('asyncmemo') var multicb = require('multicb') var schemas = require('ssb-msg-schemas') var Issues = require('ssb-issues') var PullRequests = require('ssb-pull-requests') var paramap = require('pull-paramap') var Mentions = require('ssb-mentions') var many = require('pull-many') var ident = require('pull-identify-filetype') var mime = require('mime-types') var moment = require('moment') var LRUCache = require('lrucache') var hlCssPath = path.resolve(require.resolve('highlight.js'), '../../styles') var emojiPath = path.resolve(require.resolve('emoji-named-characters'), '../pngs') function ParamError(msg) { var err = Error.call(this, msg) err.name = ParamError.name return err } util.inherits(ParamError, Error) function parseAddr(str, def) { if (!str) return def var i = str.lastIndexOf(':') if (~i) return {host: str.substr(0, i), port: Number(str.substr(i+1))} if (isNaN(str)) return {host: str, port: def.port} return {host: def.host, port: Number(str)} } function tryDecodeURIComponent(str) { if (!str || (str[0] == '%' && ref.isBlobId(str))) return str try { str = decodeURIComponent(str) } finally { return str } } function getContentType(filename) { var ext = u.getExtension(filename) return contentTypes[ext] || u.imgMimes[ext] || 'text/plain; charset=utf-8' } var contentTypes = { css: 'text/css' } function readReqForm(req, cb) { pull( toPull(req), pull.collect(function (err, bufs) { if (err) return cb(err) var data try { data = qs.parse(Buffer.concat(bufs).toString('ascii')) } catch(e) { return cb(e) } cb(null, data) }) ) } var msgTypes = { 'git-repo': true, 'git-update': true, 'issue': true, 'pull-request': true } module.exports = { name: 'git-ssb-web', version: require('./package').version, manifest: {}, init: function (ssb, config) { var web = new GitSSBWeb(ssb, config) return {} } } function GitSSBWeb(ssb, config) { this.ssb = ssb this.config = config if (config.logging && config.logging.level) this.logLevel = this.logLevels.indexOf(config.logging.level) this.ssbAppname = config.appname || 'ssb' this.isPublic = config.public this.getVotes = require('./lib/votes')(ssb) this.getMsg = asyncMemo({cache: new LRUCache(100)}, this.getMsgRaw) this.issues = Issues.init(ssb) this.pullReqs = PullRequests.init(ssb) this.getRepo = asyncMemo({ cache: new LRUCache(32) }, function (id, cb) { if (id[0] === '#') return ssbGit.getRepo(ssb, id, {live: true}, cb) this.getMsg(id, function (err, msg) { if (err) return cb(err) if (msg.private && this.isPublic) return cb(new Error('Private Repo')) ssbGit.getRepo(ssb, msg, {live: true}, cb) }) }) this.about = function (id, cb) { cb(null, {name: id}) } ssb.whoami(function (err, feed) { this.myId = feed.id this.about = require('./lib/about')(ssb, this.myId) }.bind(this)) this.i18n = require('./lib/i18n')(path.join(__dirname, 'locale'), 'en') this.users = require('./lib/users')(this) this.repos = require('./lib/repos')(this) var webConfig = config['git-ssb-web'] || {} if (webConfig.computeIssueCounts) { this.indexCache = require('./lib/index-cache')(ssb) } this.serveAcmeChallenge = require('./lib/acme-challenge')(ssb) var addr = parseAddr(config.listenAddr, { host: webConfig.host || 'localhost', port: Number(webConfig.port) || 7718 }) this.listenHost = addr.host this.listenPort = addr.port this.listen(addr.host, addr.port) this.monitorSsbClient() } var G = GitSSBWeb.prototype G.logLevels = ['error', 'warning', 'notice', 'info'] G.logLevel = G.logLevels.indexOf('notice') G.log = function (level) { if (this.logLevels.indexOf(level) > this.logLevel) return console.log.apply(console, [].slice.call(arguments, 1)) } G.listen = function (host, port) { this.httpServer = http.createServer(G_onRequest.bind(this)) this.httpServer.listen(port, host, function () { var hostName = ~host.indexOf(':') ? '[' + host + ']' : host this.log('notice', 'Listening on http://' + hostName + ':' + port + '/') }.bind(this)) } G.getRepoName = function (ownerId, repoId, cb) { if (!repoId) return cb(null, '?') if (repoId[0] === '#') return cb(null, repoId) this.about.getName({ owner: ownerId, target: repoId }, cb) } G.getRepoFullName = function (author, repoId, cb) { var done = multicb({ pluck: 1, spread: true }) this.getRepoName(author, repoId, done()) this.about.getName(author, done()) done(cb) } G.addAuthorName = function () { var about = this.about return paramap(function (msg, cb) { var author = msg && msg.value && msg.value.author if (!author) return cb(null, msg) about.getName(author, function (err, authorName) { msg.authorName = authorName cb(err, msg) }) }, 8) } /* Serving a request */ function serve(req, res) { return pull( pull.filter(function (data) { if (Array.isArray(data)) { res.writeHead.apply(res, data) return false } return true }), toPull(res) ) } function G_onRequest(req, res) { this.log('info', req.method, req.url) if (req.url.startsWith('/.well-known/acme-challenge')) return this.serveAcmeChallenge(req, res) req._u = url.parse(req.url, true) if (!this.isHostAllowed(req.headers.host)) { res.writeHead(403) return res.end('403 Forbidden') } var locale = req._u.query.locale || (/locale=([^;]*)/.exec(req.headers.cookie) || [])[1] var reqLocales = req.headers['accept-language'] var locales = reqLocales ? reqLocales.split(/, */).map(function (item) { return item.split(';')[0] }) : [] req._locale = locale || locales[0] || this.i18n.fallback this.i18n.pickCatalog(reqLocales, req._locale, function (err, t) { if (err) return pull(this.serveError(req, err, 500), serve(req, res)) req._t = t pull(this.handleRequest(req), serve(req, res)) }.bind(this)) } G.isHostAllowed = function (hostname) { if (this.isPublic) return true if (!hostname) return false var addr = parseAddr(hostname, {port: 80}) if (addr.port !== this.listenPort) return false var host = addr.host return host === 'localhost' || host === '[::1]' || host === '127.0.0.1' || host === this.listenHost } var unsafePathRegex = /^\/(?:&|%26)|^%[^\/]+\/raw\// G.isRefererAllowed = function (referer) { if (this.isPublic) return true if (!referer) return false var u = url.parse(referer) return u.protocol === 'http:' && (Number(u.port) || 80) === this.listenPort && (u.hostname === this.listenHost || u.hostname === 'localhost' || u.hostname === '::1' || u.hostname === '127.0.0.1') && u.pathname && !unsafePathRegex.test(u.pathname) } G.handleRequest = function (req) { var path = req._u.pathname.slice(1) var dirs = ref.isLink(path) ? [path] : path.split(/\/+/).map(tryDecodeURIComponent) var dir = dirs[0] if (req.method == 'POST') return this.handlePOST(req, dir) if (dir == '') return this.serveIndex(req) else if (dir == 'search') return this.serveSearch(req) else if (dir[0] === '#') return this.serveChannel(req, dir, dirs.slice(1)) else if (ref.isBlobId(dir)) return this.serveBlob(req, dir) else if (ref.isMsgId(dir)) return this.serveMessage(req, dir, dirs.slice(1)) else if (ref.isFeedId(dir)) return this.users.serveUserPage(req, dir, dirs.slice(1)) else if (dir == 'static') return this.serveFile(req, dirs) else if (dir == 'highlight') return this.serveFile(req, [hlCssPath].concat(dirs.slice(1)), true) else if (dir == 'emoji') return this.serveFile(req, [emojiPath].concat(dirs.slice(1)), true) else return this.serve404(req) } G.handlePOST = function (req, dir) { var self = this if (self.isPublic) return self.serveBuffer(405, req._t('error.POSTNotAllowed')) if (!self.isRefererAllowed(req.headers.referer)) { return this.serveBuffer(403, 'Referer not allowed') } return u.readNext(function (cb) { readReqForm(req, function (err, data) { if (err) return cb(null, self.serveError(req, err, 400)) if (!data) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingData')), 400)) try {switch (data.action) { case 'fork-prompt': return cb(null, self.serveRedirect(req, u.encodeLink([data.id, 'fork']))) case 'fork': if (!data.id) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingId')), 400)) return ssbGit.createRepo(self.ssb, {upstream: data.id}, function (err, repo) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, u.encodeLink(repo.id))) }) case 'vote': var voteValue = +data.value || 0 if (!data.id) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingId')), 400)) var msg = schemas.vote(data.id, voteValue) return self.ssb.publish(msg, function (err) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, req.url)) }) case 'repo-name': if (!data.id) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingId')), 400)) if (!data.name) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingName')), 400)) var msg = schemas.name(data.id, data.name) return self.ssb.publish(msg, function (err) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, req.url)) }) case 'comment': if (!data.id) return cb(null, self.serveError(req, new ParamError(req._t('error.MissingId')), 400)) var msg = schemas.post(data.text, data.id, data.branch || data.id) msg.issue = data.issue msg.repo = data.repo if (data.open != null) Issues.schemas.reopens(msg, data.id) if (data.close != null) Issues.schemas.closes(msg, data.id) var mentions = Mentions(data.text) if (mentions.length) msg.mentions = mentions return self.ssb.publish(msg, function (err) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, req.url)) }) case 'line-comment': if (!data.repo) return cb(null, self.serveError(req, new ParamError('missing repo id'), 400)) if (!data.commitId) return cb(null, self.serveError(req, new ParamError('missing commit id'), 400)) if (!data.updateId) return cb(null, self.serveError(req, new ParamError('missing update id'), 400)) if (!data.filePath) return cb(null, self.serveError(req, new ParamError('missing file path'), 400)) if (!data.line) return cb(null, self.serveError(req, new ParamError('missing line number'), 400)) var lineNumber = Number(data.line) if (isNaN(lineNumber)) return cb(null, self.serveError(req, new ParamError('bad line number'), 400)) var msg = { type: 'line-comment', text: data.text, repo: data.repo, updateId: data.updateId, commitId: data.commitId, filePath: data.filePath, line: lineNumber, } msg.issue = data.issue var mentions = Mentions(data.text) if (mentions.length) msg.mentions = mentions return self.ssb.publish(msg, function (err) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, req.url)) }) case 'line-comment-reply': if (!data.root) return cb(null, self.serveError(req, new ParamError('missing thread root'), 400)) if (!data.branch) return cb(null, self.serveError(req, new ParamError('missing thread branch'), 400)) if (!data.text) return cb(null, self.serveError(req, new ParamError('missing post text'), 400)) var msg = { type: 'post', root: data.root, branch: data.branch, text: data.text, } var mentions = Mentions(data.text) if (mentions.length) msg.mentions = mentions return self.ssb.publish(msg, function (err) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, req.url)) }) case 'new-issue': var msg = Issues.schemas.new(dir, data.text) var mentions = Mentions(data.text) if (mentions.length) msg.mentions = mentions return self.ssb.publish(msg, function (err, msg) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, u.encodeLink(msg.key))) }) case 'new-pull': var msg = PullRequests.schemas.new(dir, data.branch, data.head_repo, data.head_branch, data.text) var mentions = Mentions(data.text) if (mentions.length) msg.mentions = mentions return self.ssb.publish(msg, function (err, msg) { if (err) return cb(null, self.serveError(req, err)) cb(null, self.serveRedirect(req, u.encodeLink(msg.key))) }) case 'markdown': return cb(null, self.serveMarkdown(data.text, {id: data.repo})) default: cb(null, self.serveBuffer(400, req._t('error.UnknownAction', data))) }} catch(e) { cb(null, self.serveError(req, e)) } }) }) } G.serveFile = function (req, dirs, outside) { var filename = path.resolve.apply(path, [__dirname].concat(dirs)) // prevent escaping base dir if (!outside && filename.indexOf('../') === 0) return this.serveBuffer(403, req._t("error.403Forbidden")) return u.readNext(function (cb) { fs.stat(filename, function (err, stats) { cb(null, err ? err.code == 'ENOENT' ? this.serve404(req) : this.serveBuffer(500, err.message) : u.ifModifiedSince(req, stats.mtime) ? pull.once([304]) : stats.isDirectory() ? this.serveBuffer(403, req._t('error.DirectoryNotListable')) : cat([ pull.once([200, { 'Content-Type': getContentType(filename), 'Content-Length': stats.size, 'Last-Modified': stats.mtime.toGMTString() }]), toPull(fs.createReadStream(filename)) ])) }.bind(this)) }.bind(this)) } G.serveBuffer = function (code, buf, contentType, headers) { headers = headers || {} headers['Content-Type'] = contentType || 'text/plain; charset=utf-8' headers['Content-Length'] = Buffer.byteLength(buf) return pull.values([ [code, headers], buf ]) } G.serve404 = function (req) { return this.serveBuffer(404, req._t("error.404NotFound")) } G.serveRedirect = function (req, path) { return this.serveBuffer(302, '
' + '' + u.escape(err.stack) + '' } G.renderTry = function (read) { var self = this var ended return function (end, cb) { if (ended) return cb(ended) read(end, function (err, data) { if (err === true) cb(true) else if (err) { ended = true cb(null, self.renderError(err)) } else cb(null, data) }) } } G.serveTemplate = function (req, title, code, read) { var self = this if (read === undefined) return this.serveTemplate.bind(this, req, title, code) var q = req._u.query.q && u.escape(req._u.query.q) || '' var app = 'git ssb' var appName = this.ssbAppname if (typeof req._t === 'function') app = req._t(app) else console.trace('Missing locale data') return cat([ pull.values([ [code || 200, { 'Content-Type': 'text/html' }], '', '
Built with git-ssb-web
' + '' + i + ' | ' + '' + line + ' |
' + markdown(c.text) + '' : '') + '
' + req._t('error.BlobNotFoundInRepo', { repo: u.link([repoId]) }) + '
' + '' + u.escape(err.stack) + '' )) } G.serveRaw = function (length, contentType) { var headers = { 'Content-Type': contentType || 'text/plain; charset=utf-8', 'Cache-Control': 'max-age=31536000' } if (length != null) headers['Content-Length'] = length return function (read) { return cat([pull.once([200, headers]), read]) } } G.getBlob = function (req, key, cb) { var blobs = this.ssb.blobs // use size to check for blob's presence, since has or want may broadcast blobs.size(key, function (err, size) { if (typeof size === 'number') cb(null, blobs.get(key)) else blobs.want(key, function (err, got) { if (err) cb(err) else if (!got) cb(new Error(req._t('error.MissingBlob', {key: key}))) else cb(null, blobs.get(key)) }) }) } G.serveBlob = function (req, key) { var self = this return u.readNext(function (cb) { self.getBlob(req, key, function (err, read) { if (err) cb(null, self.serveError(req, err)) else if (!read) cb(null, self.serve404(req)) else cb(null, identToResp(read)) }) }) } function identToResp(read) { var ended, type, queue var id = ident(function (_type) { type = _type && mime.lookup(_type) })(read) return function (end, cb) { if (ended) return cb(ended) if (end) id(end, function (end) { cb(end === true ? null : end) }) else if (queue) { var _queue = queue queue = null cb(null, _queue) } else if (!type) id(null, function (end, data) { if (ended = end) return cb(end) queue = data cb(null, [200, { 'Content-Type': type || 'text/plain; charset=utf-8', 'Cache-Control': 'max-age=31536000' }]) }) else id(null, cb) } } G.monitorSsbClient = function () { pull( function (abort, cb) { if (abort) throw abort setTimeout(function () { cb(null, 'keepalive') }, 15e3) }, this.ssb.gossip.ping(), pull.drain(null, function (err) { // exit when the rpc connection ends if (err) console.error(err) console.error('sbot client connection closed. aborting') process.exit(1) }) ) }