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 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',
'',
'',
'']),
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(
'' +
'' +
'
' + 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(''),
pull(
repo.refs(),
pull.map(function (ref) {
var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
var group = m[1]
var name = m[2]
var optgroup = (group === currentGroup) ? '' :
(currentGroup ? '' : '') +
''
currentGroup = group
var selected = (name == currentName) ? ' selected="selected"' : ''
var htmlName = escapeHTML(name)
return optgroup +
'' +
htmlName + ' '
})
),
readOnce(function (cb) {
cb(null, currentGroup ? ' ' : '')
}),
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' : 'blob'
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.getObject(file.id, function (err, obj) {
if (err) return cb(null, pull.empty())
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
return renderRepoPage(repo, null, pull.once(
(raw ? 'Info ' :
'Data ') +
'Update ' +
(raw ? '' :
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 '' +
'Pack: ' + link([info.pack.link]) + ' ' +
'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)))
})
})
}
}