var fs = require('fs') var http = require('http') var path = require('path') var url = require('url') var qs = require('querystring') 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 Repo = require('pull-git-repo') var ssbAbout = require('./about') var ssbVotes = require('./votes') var marked = require('ssb-marked') var asyncMemo = require('asyncmemo') var multicb = require('multicb') var schemas = require('ssb-msg-schemas') marked.setOptions({ gfm: true, mentions: true, tables: true, breaks: true, pedantic: false, sanitize: true, smartLists: true, smartypants: false }) function parseAddr(str, def) { if (!str) return def var i = str.lastIndexOf(':') if (~i) return {host: str.substr(0, i), port: str.substr(i+1)} if (isNaN(str)) return {host: str, port: def.port} return {host: def.host, port: str} } function link(parts, html) { var href = '/' + parts.map(encodeURIComponent).join('/') var innerHTML = html || escapeHTML(parts[parts.length-1]) return '' + innerHTML + '' } function timestamp(time) { time = Number(time) var d = new Date(time) return '' + d.toLocaleString() + '' } function pre(text) { return '
' + escapeHTML(text) + '
' } function json(obj) { return pre(JSON.stringify(obj, null, 2)) } function escapeHTML(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeHTMLStream() { return pull.map(function (buf) { return escapeHTML(buf.toString('utf8')) }) } function table(props) { return function (read) { return cat([ pull.once(''), pull( read, pull.map(function (row) { return row ? '' + row.map(function (cell) { return '' + cell + '' }).join('') + '' : '' }) ), pull.once('') ]) } } function readNext(fn) { var next return function (end, cb) { if (next) return next(end, cb) fn(function (err, _next) { if (err) return cb(err) next = _next next(null, cb) }) } } function readOnce(fn) { var ended return function (end, cb) { fn(function (err, data) { if (err || ended) return cb(err || ended) ended = true cb(null, data) }) } } function tryDecodeURIComponent(str) { if (!str || (str[0] == '%' && ref.isBlobId(str))) return str try { str = decodeURIComponent(str) } finally { return str } } function getRepoName(repoId, cb) { // TODO: use petnames cb(null, repoId.substr(0, 20) + '…') } var hasOwnProp = Object.prototype.hasOwnProperty function getContentType(filename) { var ext = filename.split('.').pop() return hasOwnProp.call(contentTypes, ext) ? contentTypes[ext] : 'text/plain' } var contentTypes = { css: 'text/css' } var staticBase = path.join(__dirname, 'static') function readReqJSON(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 } var refLabels = { heads: 'Branches' } module.exports = function (opts, cb) { var ssb, reconnect, myId, getRepo, getVotes, getMsg var about = function (id, cb) { cb(null, {name: id}) } var reqQueue = [] var isPublic = opts.public var ssbAppname = opts.appname || 'ssb' var addr = parseAddr(opts.listenAddr, {host: 'localhost', port: 7718}) http.createServer(onRequest).listen(addr.port, addr.host, onListening) var server = { setSSB: function (_ssb, _reconnect) { _ssb.whoami(function (err, feed) { if (err) throw err ssb = _ssb reconnect = _reconnect myId = feed.id about = ssbAbout(ssb, myId) while (reqQueue.length) onRequest.apply(this, reqQueue.shift()) getRepo = asyncMemo(function (id, cb) { getMsg(id, function (err, msg) { if (err) return cb(err) ssbGit.getRepo(ssb, {key: id, value: msg}, {live: true}, cb) }) }) getVotes = ssbVotes(ssb) getMsg = asyncMemo(ssb.get) }) } } function onListening() { var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host console.log('Listening on http://' + host + ':' + addr.port + '/') cb(null, server) } /* Serving a request */ function onRequest(req, res) { console.log(req.method, req.url) if (!ssb) return reqQueue.push(arguments) pull( handleRequest(req), pull.filter(function (data) { if (Array.isArray(data)) { res.writeHead.apply(res, data) return false } return true }), toPull(res) ) } function handleRequest(req) { var u = req._u = url.parse(req.url) var dirs = u.pathname.slice(1).split(/\/+/).map(tryDecodeURIComponent) var dir = dirs[0] if (dir == '') return serveIndex(req) else if (ref.isBlobId(dir)) return serveBlob(req, dir) else if (ref.isMsgId(dir)) return serveMessage(req, dir, dirs.slice(1)) else if (ref.isFeedId(dir)) return serveUserPage(dir) else return serveFile(req, dirs) } function serveFile(req, dirs) { var filename = path.join.apply(path, [staticBase].concat(dirs)) // prevent escaping base dir if (filename.indexOf(staticBase) !== 0) return servePlainError(403, '403 Forbidden') return readNext(function (cb) { fs.stat(filename, function (err, stats) { cb(null, err ? err.code == 'ENOENT' ? serve404(req) : servePlainError(500, err.message) : 'if-modified-since' in req.headers && new Date(req.headers['if-modified-since']) >= stats.mtime ? pull.once([304]) : stats.isDirectory() ? servePlainError(403, 'Directory not listable') : cat([ pull.once([200, { 'Content-Type': getContentType(filename), 'Content-Length': stats.size, 'Last-Modified': stats.mtime.toGMTString() }]), toPull(fs.createReadStream(filename)) ])) }) }) } function servePlainError(code, msg) { return pull.values([ [code, { 'Content-Length': Buffer.byteLength(msg), 'Content-Type': 'text/plain' }], msg ]) } function serve404(req) { return servePlainError(404, '404 Not Found') } function serveRedirect(path) { var msg = '' + 'Redirect' + '

Continue

' return pull.values([ [302, { 'Content-Length': Buffer.byteLength(msg), 'Content-Type': 'text/html', Location: path }], msg ]) } function renderTry(read) { 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, '

' + err.name + '

' + '
' + escapeHTML(err.stack) + '
') } else cb(null, data) }) } } function serveTemplate(title, code, read) { if (read === undefined) return serveTemplate.bind(this, title, code) return cat([ pull.values([ [code || 200, { 'Content-Type': 'text/html' }], '', '' + escapeHTML(title || 'git ssb') + '', '', '\n', '', '
', '

git ssb' + (ssbAppname != 'ssb' ? ' ' + ssbAppname + '' : '') + '

', '
', '
']), renderTry(read), pull.once('
') ]) } function serveError(err) { if (err.message == 'stream is closed') reconnect() return pull( pull.once( '

' + err.name + '

' + '
' + escapeHTML(err.stack) + '
'), serveTemplate(err.name, 500) ) } /* Feed */ function renderFeed(feedId) { var opts = { reverse: true, id: feedId } return pull( feedId ? ssb.createUserStream(opts) : ssb.createLogStream(opts), pull.filter(function (msg) { return msg.value.content.type in msgTypes }), pull.take(20), pull.asyncMap(function (msg, cb) { about.getName(msg.value.author, function (err, name) { if (err) return cb(err) switch (msg.value.content.type) { case 'git-repo': return renderRepoCreated(msg, name, cb) case 'git-update': return renderUpdate(msg, name, cb) } }) }) ) } function renderRepoCreated(msg, authorName, cb) { var repoLink = link([msg.key]) var authorLink = link([msg.value.author], authorName) cb(null, '
' + timestamp(msg.value.timestamp) + '
' + authorLink + ' created repo ' + repoLink + '
') } function renderUpdate(msg, authorName, cb) { var repoLink = link([msg.value.content.repo]) var authorLink = link([msg.value.author], authorName) cb(null, '
' + timestamp(msg.value.timestamp) + '
' + authorLink + ' pushed to ' + repoLink + '
') } /* Index */ function serveIndex() { return serveTemplate('git ssb')(renderFeed()) } function serveUserPage(feedId) { return serveTemplate(feedId)(cat([ readOnce(function (cb) { about.getName(feedId, function (err, name) { cb(null, '

' + link([feedId], name) + '' + feedId + '

') }) }), renderFeed(feedId), ])) } /* Message */ function serveMessage(req, id, path) { return readNext(function (cb) { ssb.get(id, function (err, msg) { if (err) return cb(null, serveError(err)) var c = msg.content || {} switch (c.type) { case 'git-repo': return getRepo(id, function (err, repo) { if (err) return cb(null, serveError(err)) cb(null, serveRepoPage(req, Repo(repo), path)) }) case 'git-update': return getRepo(c.repo, function (err, repo) { if (err) return cb(null, serveRepoNotFound(repo.id, err)) cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path)) }) default: if (ref.isMsgId(c.repo)) return getRepo(c.repo, function (err, repo) { if (err) return cb(null, serveRepoNotFound(repo.id, err)) cb(null, serveRepoSomething(req, Repo(repo), id, msg, path)) }) else return cb(null, serveGenericMessage(req, id, msg, path)) } }) }) } function serveGenericMessage(req, id, msg, path) { return serveTemplate(id)(pull.once( '

' + link([id]) + '

' + json(msg) + '
')) } /* Repo */ function serveRepoPage(req, repo, path) { var defaultBranch = 'master' if (req.method == 'POST') { return readNext(function (cb) { readReqJSON(req, function (err, data) { if (data && data.vote != null) { var voteValue = +data.vote || 0 ssb.publish(schemas.vote(repo.id, voteValue), function (err) { if (err) return cb(null, serveError(err)) cb(null, serveRedirect(req.url)) }) } else { cb(null, servePlainError(400, 'What are you trying to do?')) } }) }) } var branch = path[1] || defaultBranch var filePath = path.slice(2) switch (path[0]) { case undefined: return serveRepoTree(repo, branch, []) case 'activity': return serveRepoActivity(repo, branch) case 'commits': return serveRepoCommits(repo, branch) case 'commit': return serveRepoCommit(repo, path[1]) case 'tree': return serveRepoTree(repo, branch, filePath) case 'blob': return serveRepoBlob(repo, branch, filePath) default: return serve404(req) } } function serveRepoNotFound(id, err) { return serveTemplate('Repo not found', 404, pull.values([ '

Repo not found

', '

Repo ' + id + ' was not found

', '
' + escapeHTML(err.stack) + '
', ])) } function renderRepoPage(repo, branch, body) { var gitUrl = 'ssb://' + repo.id var gitLink = '' var done = multicb({ pluck: 1, spread: true }) getRepoName(repo.id, done()) about.getName(repo.feed, done()) getVotes(repo.id, done()) return readNext(function (cb) { done(function (err, repoName, authorName, votes) { if (err) return cb(null, serveError(err)) var upvoted = votes.upvoters[myId] > 0 cb(null, serveTemplate(repo.id)(cat([ pull.once( '
' + '
' + (isPublic ? ' ' : '' + '') + '' + votes.upvotes + '' + '
' + '

' + link([repo.feed], authorName) + ' / ' + link([repo.id], repoName) + '

' + '
' + link([repo.id], 'Code') + link([repo.id, 'activity'], 'Activity') + link([repo.id, 'commits', branch || ''], 'Commits') + gitLink + '
'), body ]))) }) }) } function serveRepoTree(repo, rev, path) { var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch' return renderRepoPage(repo, rev, cat([ pull.once('

' + type + ': ' + rev + ' '), revMenu(repo, rev), pull.once('

'), type == 'Branch' && renderRepoLatest(repo, rev), pull.once('
'), renderRepoTree(repo, rev, path), pull.once('
'), renderRepoReadme(repo, rev, path) ])) } /* Repo activity */ function serveRepoActivity(repo, branch) { return renderRepoPage(repo, branch, cat([ pull.once('

Activity

'), pull( ssb.links({ type: 'git-update', dest: repo.id, source: repo.feed, rel: 'repo', values: true, reverse: true, limit: 8 }), pull.map(renderRepoUpdate.bind(this, repo)) ) ])) } function renderRepoUpdate(repo, msg, full) { var c = msg.value.content var refs = c.refs ? Object.keys(c.refs).map(function (ref) { return {name: ref, value: c.refs[ref]} }) : [] var numObjects = c.objects ? Object.keys(c.objects).length : 0 return '
' + link([msg.key], new Date(msg.value.timestamp).toLocaleString()) + '
' + (numObjects ? 'Pushed ' + numObjects + ' objects
' : '') + refs.map(function (update) { var name = escapeHTML(update.name) if (!update.value) { return 'Deleted ' + name } else { var commitLink = link([repo.id, 'commit', update.value]) return name + ' → ' + commitLink } }).join('
') + '
' } /* Repo commits */ function serveRepoCommits(repo, branch) { return renderRepoPage(repo, branch, cat([ pull.once('

Commits

'), pull( repo.readLog(branch), pull.asyncMap(function (hash, cb) { repo.getCommitParsed(hash, function (err, commit) { if (err) return cb(err) var commitPath = [repo.id, 'commit', commit.id] var treePath = [repo.id, 'tree', commit.id] cb(null, '
' + '' + link(commitPath, escapeHTML(commit.title)) + '
' + '' + commit.id + ' ' + link(treePath, 'Tree') + '
' + (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '
' : '') + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() + '
') }) }) ) ])) } /* Repo tree */ function revMenu(repo, currentName) { var baseHref = '/' + encodeURIComponent(repo.id) + '/tree/' var onchange = 'location.href="' + baseHref + '" + this.value' var currentGroup return cat([ pull.once('') ]) } function renderRepoLatest(repo, rev) { return readOnce(function (cb) { repo.getCommitParsed(rev, function (err, commit) { if (err) return cb(err) var commitPath = [repo.id, 'commit', commit.id] cb(null, 'Latest: ' + link(commitPath, escapeHTML(commit.title)) + '
' + '' + commit.id + '
' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() + (commit.separateAuthor ? '
' + escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() : '')) }) }) } // breadcrumbs function linkPath(basePath, path) { path = path.slice() var last = path.pop() return path.map(function (dir, i) { return link(basePath.concat(path.slice(0, i+1)), dir) }).concat(last).join(' / ') } function renderRepoTree(repo, rev, path) { var pathLinks = path.length === 0 ? '' : ': ' + linkPath([repo.id, 'tree'], [rev].concat(path)) return cat([ pull.once('

Files' + pathLinks + '

'), pull( repo.readDir(rev, path), pull.map(function (file) { var type = (file.mode === 040000) ? 'tree' : (file.mode === 0160000) ? 'commit' : 'blob' if (type == 'commit') return ['🖈', '' + escapeHTML(file.name) + ''] var filePath = [repo.id, type, rev].concat(path, file.name) return [type == 'tree' ? '📁' : '📄', link(filePath, file.name)] }), table('class="files"') ) ]) } /* Repo readme */ function renderRepoReadme(repo, branch, path) { return readNext(function (cb) { pull( repo.readDir(branch, path), pull.filter(function (file) { return /readme(\.|$)/i.test(file.name) }), pull.take(1), pull.collect(function (err, files) { if (err) return cb(null, pull.empty()) var file = files[0] if (!file) return cb(null, pull.once(path.length ? '' : '

No readme

')) repo.getObjectFromAny(file.id, function (err, obj) { if (err) return cb(err) cb(null, cat([ pull.once('

' + escapeHTML(file.name) + '


'), /\.md|\/.markdown/i.test(file.name) ? readOnce(function (cb) { pull(obj.read, pull.collect(function (err, bufs) { if (err) return cb(err) var buf = Buffer.concat(bufs, obj.length) cb(null, marked(buf.toString())) })) }) : cat([ pull.once('
'),
                pull(obj.read, escapeHTMLStream()),
                pull.once('
') ]), pull.once('
') ])) }) }) ) }) } /* Repo commit */ function serveRepoCommit(repo, rev) { return renderRepoPage(repo, rev, cat([ pull.once('

Commit ' + rev + '

'), readOnce(function (cb) { repo.getCommitParsed(rev, function (err, commit) { if (err) return cb(err) var commitPath = [repo.id, 'commit', commit.id] var treePath = [repo.id, 'tree', commit.tree] cb(null, '

' + link(commitPath, escapeHTML(commit.title)) + '

' + pre(commit.body) + '

' + (commit.separateAuthor ? escapeHTML(commit.author.name) + ' authored on ' + commit.author.date.toLocaleString() + '
' : '') + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() + '

' + '

' + commit.parents.map(function (id) { return 'Parent: ' + link([repo.id, 'commit', id], id) }).join('
') + '

' + '

' + (commit.tree ? 'Tree: ' + link(treePath) : 'No tree') + '

') }) }) ])) } /* An unknown message linking to a repo */ function serveRepoSomething(req, repo, id, msg, path) { return renderRepoPage(repo, null, pull.once('

' + link([id]) + '

' + json(msg) + '
')) } /* Repo update */ function objsArr(objs) { return Array.isArray(objs) ? objs : Object.keys(objs).map(function (sha1) { var obj = Object.create(objs[sha1]) obj.sha1 = sha1 return obj }) } function serveRepoUpdate(req, repo, id, msg, path) { var raw = String(req._u.query).split('&').indexOf('raw') > -1 // convert packs to old single-object style if (msg.content.indexes) { for (var i = 0; i < msg.content.indexes.length; i++) { msg.content.packs[i] = { pack: {link: msg.content.packs[i].link}, idx: msg.content.indexes[i] } } } return renderRepoPage(repo, null, pull.once( (raw ? 'Info' : 'Data') + '

Update

' + (raw ? '
' + json(msg) + '
' : renderRepoUpdate(repo, {key: id, value: msg}, true) + (msg.content.objects ? '

Objects

' + objsArr(msg.content.objects).map(renderObject).join('\n') : '') + (msg.content.packs ? '

Packs

' + msg.content.packs.map(renderPack).join('\n') : '')))) } function renderObject(obj) { return '
' + obj.type + ' ' + link([obj.link], escapeHTML(obj.sha1)) + '
' + obj.length + ' bytes' + '
' } function renderPack(info) { return '
' + (info.pack ? 'Pack: ' + link([info.pack.link]) + '
' : '') + (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '
' } /* Blob */ function serveRepoBlob(repo, branch, path) { return readNext(function (cb) { repo.getFile(branch, path, function (err, object) { if (err) return cb(null, serveBlobNotFound(repoId, err)) cb(null, serveObjectRaw(object)) }) }) } function serveBlobNotFound(repoId, err) { return serveTemplate(400, 'Blob not found', pull.values([ '

Blob not found

', '

Blob in repo ' + link([repoId]) + ' was not found

', '
' + escapeHTML(err.stack) + '
' ])) } function serveRaw(length) { var inBody var headers = { 'Content-Type': 'text/plain', 'Cache-Control': 'max-age=31536000' } if (length != null) headers['Content-Length'] = length return function (read) { return function (end, cb) { if (inBody) return read(end, cb) if (end) return cb(true) cb(null, [200, headers]) inBody = true } } } function serveObjectRaw(object) { return pull(object.read, serveRaw(object.length)) } function serveBlob(req, key) { return readNext(function (cb) { ssb.blobs.want(key, function (err, got) { if (err) cb(null, serveError(err)) else if (!got) cb(null, serve404(req)) else cb(null, serveRaw()(ssb.blobs.get(key))) }) }) } }