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')
var Issues = require('ssb-issues')
var paramap = require('pull-paramap')
var gitPack = require('pull-git-pack')
var Mentions = require('ssb-mentions')
var Highlight = require('highlight.js')
var JsDiff = require('diff')
var hlCssPath = path.resolve(require.resolve('highlight.js'), '../../styles')
// render links to git objects and ssb objects
var blockRenderer = new marked.Renderer()
blockRenderer.urltransform = function (url) {
if (ref.isLink(url))
return encodeLink(url)
if (/^[0-9a-f]{40}$/.test(url) && this.options.repo)
return encodeLink([this.options.repo.id, 'commit', url])
return url
}
function getExtension(filename) {
return (/\.([^.]+)$/.exec(filename) || [,filename])[1]
}
function highlight(code, lang) {
try {
return lang
? Highlight.highlight(lang, code).value
: Highlight.highlightAuto(code).value
} catch(e) {
if (/^Unknown language/.test(e.message))
return escapeHTML(code)
throw e
}
}
marked.setOptions({
gfm: true,
mentions: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
highlight: highlight,
renderer: blockRenderer
})
// hack to make git link mentions work
var mdRules = new marked.InlineLexer(1, marked.defaults).rules
mdRules.mention =
/^(\s)?([@%&][A-Za-z0-9\._\-+=\/]*[A-Za-z0-9_\-+=\/]|[0-9a-f]{40})/
mdRules.text = /^[\s\S]+?(?=[\\' + text + ''
}
function linkify(text) {
// regex is from ssb-ref
return text.replace(/(@|%|&)[A-Za-z0-9\/+]{43}=\.[\w\d]+/g, function (str) {
return '' + str + ' '
})
}
function timestamp(time) {
time = Number(time)
var d = new Date(time)
return '' + d.toLocaleString() + ' '
}
function pre(text) {
return '
' + escapeHTML(text) + ' '
}
function json(obj) {
return linkify(pre(JSON.stringify(obj, null, 2)))
}
function escapeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
function ucfirst(str) {
return str[0].toLocaleUpperCase() + str.slice(1)
}
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 ul(props) {
return function (read) {
return cat([
pull.once(''),
pull(
read,
pull.map(function (li) {
return '' + li + ' '
})
),
pull.once(' ')
])
}
}
function nav(links, page, after) {
return [''].concat(
links.map(function (link) {
var href = typeof link[0] == 'string' ? link[0] : encodeLink(link[0])
var props = link[2] == page ? ' class="active"' : ''
return '' + link[1] + ' '
}), after || '', ' ').join('')
}
function renderNameForm(enabled, id, name, action, inputId, title, header) {
if (!inputId) inputId = action
return ''
}
function renderPostForm(repo, placeholder, rows) {
return ' ' +
' ' +
'' +
'Write ' +
'Preview ' +
'
' +
' ' +
'' +
'' +
'
' +
'
' +
''
}
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 paginate(onFirst, through, onLast, onEmpty) {
var ended, last, first = true, queue = []
return function (read) {
var mappedRead = through(function (end, cb) {
if (ended = end) return read(ended, cb)
if (queue.length)
return cb(null, queue.shift())
read(null, function (end, data) {
if (end) return cb(end)
last = data
cb(null, data)
})
})
return function (end, cb) {
var tmp
if (ended) return cb(ended)
if (ended = end) return read(ended, cb)
if (first)
return read(null, function (end, data) {
if (ended = end) {
if (end === true && onEmpty)
return onEmpty(cb)
return cb(ended)
}
first = false
last = data
queue.push(data)
if (onFirst)
onFirst(data, cb)
else
mappedRead(null, cb)
})
mappedRead(null, function (end, data) {
if (ended = end) {
if (end === true && last)
return onLast(last, cb)
}
cb(end, data)
})
}
}
}
function readObjectString(obj, cb) {
pull(obj.read, pull.collect(function (err, bufs) {
if (err) return cb(err)
cb(null, Buffer.concat(bufs, obj.length).toString('utf8'))
}))
}
function getRepoObjectString(repo, id, cb) {
if (!id) return cb(null, '')
repo.getObjectFromAny(id, function (err, obj) {
if (err) return cb(err)
readObjectString(obj, cb)
})
}
function compareMsgs(a, b) {
return (a.value.timestamp - b.value.timestamp) || (a.key - b.key)
}
function pullSort(comparator) {
return function (read) {
return readNext(function (cb) {
pull(read, pull.collect(function (err, items) {
if (err) return cb(err)
items.sort(comparator)
cb(null, pull.values(items))
}))
})
}
}
function sortMsgs() {
return pullSort(compareMsgs)
}
function pullReverse() {
return function (read) {
return readNext(function (cb) {
pull(read, pull.collect(function (err, items) {
cb(err, items && pull.values(items.reverse()))
}))
})
}
}
function tryDecodeURIComponent(str) {
if (!str || (str[0] == '%' && ref.isBlobId(str)))
return str
try {
str = decodeURIComponent(str)
} finally {
return str
}
}
function getRepoName(about, ownerId, repoId, cb) {
about.getName({
owner: ownerId,
target: repoId,
toString: function () {
// hack to fit two parameters into asyncmemo
return ownerId + '/' + repoId
}
}, cb)
}
function addAuthorName(about) {
return paramap(function (msg, cb) {
about.getName(msg.value.author, function (err, authorName) {
msg.authorName = authorName
cb(err, msg)
})
}, 8)
}
function getMention(msg, id) {
if (msg.key == id) return msg
var mentions = msg.value.content.mentions
if (mentions) for (var i = 0; i < mentions.length; i++) {
var mention = mentions[i]
if (mention.link == id)
return mention
}
return null
}
var hasOwnProp = Object.prototype.hasOwnProperty
function getContentType(filename) {
var ext = getExtension(filename)
return contentTypes[ext] || 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 issueCommentScript = '(' + function () {
var $ = document.getElementById.bind(document)
$('tab-links').style.display = 'block'
$('preview-tab-link').onclick = function (e) {
with (new XMLHttpRequest()) {
open('POST', '', true)
onload = function() {
$('preview-tab').innerHTML = responseText
}
send('action=markdown' +
'&repo=' + encodeURIComponent($('repo-id').value) +
'&text=' + encodeURIComponent($('post-text').value))
}
}
}.toString() + ')()'
var hashHighlightScript = ''
var msgTypes = {
'git-repo': true,
'git-update': true,
'issue': true
}
var imgMimes = {
png: 'image/png',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
gif: 'image/gif',
tif: 'image/tiff',
svg: 'image/svg+xml',
bmp: 'image/bmp'
}
module.exports = function (opts, cb) {
var ssb, reconnect, myId, getRepo, getVotes, getMsg, issues
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)
issues = Issues.init(ssb)
})
}
}
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, true)
var path = u.pathname.slice(1)
var dirs = ref.isLink(path) ? [path] :
path.split(/\/+/).map(tryDecodeURIComponent)
var dir = dirs[0]
if (req.method == 'POST') {
if (isPublic)
return serveBuffer(405, 'POST not allowed on public site')
return readNext(function (cb) {
readReqForm(req, function (err, data) {
if (err) return cb(null, serveError(err, 400))
if (!data) return cb(null, serveError(new Error('No data'), 400))
switch (data.action) {
case 'repo':
if (data.fork != null) {
var repoId = data.id
if (!repoId) return cb(null,
serveError(new Error('Missing repo id'), 400))
ssbGit.createRepo(ssb, {upstream: repoId},
function (err, repo) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(encodeLink(repo.id)))
})
} else if (data.vote != null) {
// fallthrough
} else {
return cb(null, serveError(new Error('Unknown action'), 400))
}
case 'vote':
var voteValue = +data.value || 0
if (!data.id)
return cb(null, serveError(new Error('Missing vote id'), 400))
var msg = schemas.vote(data.id, voteValue)
return ssb.publish(msg, function (err) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(req.url))
})
case 'repo-name':
if (!data.name)
return cb(null, serveError(new Error('Missing name'), 400))
if (!data.id)
return cb(null, serveError(new Error('Missing id'), 400))
var msg = schemas.name(data.id, data.name)
return ssb.publish(msg, function (err) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(req.url))
})
case 'issue-title':
if (!data.name)
return cb(null, serveError(new Error('Missing name'), 400))
if (!data.id)
return cb(null, serveError(new Error('Missing id'), 400))
var msg = Issues.schemas.edit(data.id, {title: data.name})
return ssb.publish(msg, function (err) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(req.url))
})
case 'comment':
if (!data.id)
return cb(null, serveError(new Error('Missing id'), 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.opens(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 ssb.publish(msg, function (err) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(req.url))
})
case 'new-issue':
var msg = Issues.schemas.new(dir, data.title, data.text)
var mentions = Mentions(data.text)
if (mentions.length)
msg.mentions = mentions
return ssb.publish(msg, function (err, msg) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(encodeLink(msg.key)))
})
case 'markdown':
return cb(null, serveMarkdown(data.text, {id: data.repo}))
default:
cb(null, serveBuffer(400, 'What are you trying to do?'))
}
})
})
}
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(req, dir, dirs.slice(1))
else if (dir == 'static')
return serveFile(req, dirs)
else if (dir == 'highlight')
return serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
else
return serve404(req)
}
function serveFile(req, dirs, outside) {
var filename = path.resolve.apply(path, [__dirname].concat(dirs))
// prevent escaping base dir
if (!outside && filename.indexOf('../') === 0)
return serveBuffer(403, '403 Forbidden')
return readNext(function (cb) {
fs.stat(filename, function (err, stats) {
cb(null, err ?
err.code == 'ENOENT' ? serve404(req)
: serveBuffer(500, err.message)
: 'if-modified-since' in req.headers &&
new Date(req.headers['if-modified-since']) >= stats.mtime ?
pull.once([304])
: stats.isDirectory() ?
serveBuffer(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; charset=utf-8'
}],
msg
])
}
function serveBuffer(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
])
}
function serve404(req) {
return serveBuffer(404, '404 Not Found')
}
function serveRedirect(path) {
return serveBuffer(302,
'' +
'Redirect ' +
'Continue
' +
'', 'text/html; charset=utf-8', {Location: path})
}
function serveMarkdown(text, repo) {
return serveBuffer(200, markdown(text, repo), 'text/html; charset=utf-8')
}
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, status) {
if (err.message == 'stream is closed')
reconnect()
return pull(
pull.once(
'' + err.name + '' +
'' + escapeHTML(err.stack) + ' '),
serveTemplate(err.name, status || 500)
)
}
function renderObjectData(obj, filename, repo) {
var ext = getExtension(filename)
return readOnce(function (cb) {
readObjectString(obj, function (err, buf) {
buf = buf.toString('utf8')
if (err) return cb(err)
cb(null, (ext == 'md' || ext == 'markdown')
? markdown(buf, repo)
: renderCodeTable(buf, ext) + hashHighlightScript)
})
})
}
function renderCodeTable(buf, ext) {
return ' ' +
highlight(buf, ext).split('\n').map(function (line, i) {
i++
return '' +
'' + '' + i + ' ' +
'' + line + ' '
}).join('') +
'
'
}
/* Feed */
function renderFeed(req, feedId) {
var query = req._u.query
var opts = {
reverse: !query.forwards,
lt: query.lt && +query.lt || Date.now(),
gt: query.gt && +query.gt,
id: feedId
}
return pull(
feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
pull.filter(function (msg) {
return msg.value.content.type in msgTypes
}),
pull.take(20),
addAuthorName(about),
query.forwards && pullReverse(),
paginate(
function (first, cb) {
if (!query.lt && !query.gt) return cb(null, '')
var gt = feedId ? first.value.sequence : first.value.timestamp + 1
var q = qs.stringify({
gt: gt,
forwards: 1
})
cb(null, 'Newer ')
},
paramap(renderFeedItem, 8),
function (last, cb) {
cb(null, 'Older ')
},
function (cb) {
cb(null, query.forwards ?
'Older ' :
'Newer ')
}
)
)
}
function renderFeedItem(msg, cb) {
var c = msg.value.content
var msgLink = link([msg.key],
new Date(msg.value.timestamp).toLocaleString())
var author = msg.value.author
var authorLink = link([msg.value.author], msg.authorName)
switch (c.type) {
case 'git-repo':
return getRepoName(about, author, msg.key, function (err, repoName) {
if (err) return cb(err)
var repoLink = link([msg.key], repoName)
cb(null, '' + msgLink + ' ' +
authorLink + ' created repo ' + repoLink + ' ')
})
case 'git-update':
return getRepoName(about, author, c.repo, function (err, repoName) {
if (err) return cb(err)
var repoLink = link([c.repo], repoName)
cb(null, '' + msgLink + ' ' +
authorLink + ' pushed to ' + repoLink + ' ')
})
case 'issue':
var issueLink = link([msg.key], c.title)
return getRepoName(about, author, c.project, function (err, repoName) {
if (err) return cb(err)
var repoLink = link([c.project], repoName)
cb(null, '' + msgLink + ' ' +
authorLink + ' opened issue ' + issueLink +
' on ' + repoLink + ' ')
})
}
}
/* Index */
function serveIndex(req) {
return serveTemplate('git ssb')(renderFeed(req))
}
function serveUserPage(req, feedId, dirs) {
switch (dirs[0]) {
case undefined:
case '':
case 'activity':
return serveUserActivity(req, feedId)
case 'repos':
return serveUserRepos(feedId)
}
}
function renderUserPage(feedId, page, body) {
return serveTemplate(feedId)(cat([
readOnce(function (cb) {
about.getName(feedId, function (err, name) {
cb(null, '' + link([feedId], name) +
'' + feedId + '
' +
nav([
[[feedId], 'Activity', 'activity'],
[[feedId, 'repos'], 'Repos', 'repos']
], page))
})
}),
body,
]))
}
function serveUserActivity(req, feedId) {
return renderUserPage(feedId, 'activity', renderFeed(req, feedId))
}
function serveUserRepos(feedId) {
return renderUserPage(feedId, 'repos', pull(
ssb.messagesByType({
type: 'git-repo',
reverse: true
}),
pull.filter(function (msg) {
return msg.value.author == feedId
}),
pull.take(20),
paramap(function (msg, cb) {
getRepoName(about, feedId, msg.key, function (err, repoName) {
if (err) return cb(err)
cb(null, '' +
link([msg.key], repoName) +
' ')
})
}, 8)
))
}
/* 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(c.repo, err))
cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
})
case 'issue':
return getRepo(c.project, function (err, repo) {
if (err) return cb(null, serveRepoNotFound(c.project, err))
issues.get(id, function (err, issue) {
if (err) return cb(null, serveError(err))
cb(null, serveRepoIssue(req, Repo(repo), issue, path))
})
})
case 'post':
if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
var done = multicb({ pluck: 1, spread: true })
getRepo(c.repo, done())
issues.get(c.issue, done())
return done(function (err, repo, issue) {
if (err) {
if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
return cb(null, serveError(err))
}
cb(null, serveRepoIssue(req, Repo(repo), issue, path, id))
})
}
// fallthrough
default:
if (ref.isMsgId(c.repo))
return getRepo(c.repo, function (err, repo) {
if (err) return cb(null, serveRepoNotFound(c.repo, 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'
var query = req._u.query
if (query.rev != null) {
// Allow navigating revs using GET query param.
// Replace the branch in the path with the rev query value
path[0] = path[0] || 'tree'
path[1] = query.rev
req._u.pathname = encodeLink([repo.id].concat(path))
delete req._u.query.rev
delete req._u.search
return serveRedirect(url.format(req._u))
}
// get branch
return path[1] ?
serveRepoPage2(req, repo, path) :
readNext(function (cb) {
// TODO: handle this in pull-git-repo or ssb-git-repo
repo.getSymRef('HEAD', true, function (err, ref) {
if (err) return cb(err)
repo.resolveRef(ref, function (err, rev) {
path[1] = rev ? ref : null
cb(null, serveRepoPage2(req, repo, path))
})
})
})
}
function serveRepoPage2(req, repo, path) {
var branch = path[1]
var filePath = path.slice(2)
switch (path[0]) {
case undefined:
case '':
return serveRepoTree(repo, branch, [])
case 'activity':
return serveRepoActivity(repo, branch)
case 'commits':
return serveRepoCommits(req, repo, branch)
case 'commit':
return serveRepoCommit(repo, path[1])
case 'tree':
return serveRepoTree(repo, branch, filePath)
case 'blob':
return serveRepoBlob(repo, branch, filePath)
case 'raw':
return serveRepoRaw(repo, branch, filePath)
case 'digs':
return serveRepoDigs(repo)
case 'issues':
switch (path[1]) {
case 'new':
if (filePath.length == 0)
return serveRepoNewIssue(repo)
break
default:
return serveRepoIssues(req, 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, page, branch, body) {
var gitUrl = 'ssb://' + repo.id
var gitLink = ' '
var digsPath = [repo.id, 'digs']
var done = multicb({ pluck: 1, spread: true })
getRepoName(about, repo.feed, repo.id, done())
about.getName(repo.feed, done())
getVotes(repo.id, done())
if (repo.upstream) {
getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
about.getName(repo.upstream.feed, done())
}
return readNext(function (cb) {
done(function (err, repoName, authorName, votes,
upstreamName, upstreamAuthorName) {
if (err) return cb(null, serveError(err))
var upvoted = votes.upvoters[myId] > 0
var upstreamLink = !repo.upstream ? '' :
link([repo.upstream])
cb(null, serveTemplate(repo.id)(cat([
pull.once(
'' +
'' +
renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
'Rename the repo',
'
' + link([repo.feed], authorName) + ' / ' +
link([repo.id], repoName) + ' ') +
'' +
(repo.upstream ?
'forked from ' +
link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
link([repo.upstream.id], upstreamName) +
' ' : '') +
nav([
[[repo.id], 'Code', 'code'],
[[repo.id, 'activity'], 'Activity', 'activity'],
[[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
[[repo.id, 'issues'], 'Issues', 'issues']
], page, gitLink)),
body
])))
})
})
}
function serveEmptyRepo(repo) {
if (repo.feed != myId)
return renderRepoPage(repo, 'code', null, pull.once(
'' +
'Empty repository ' +
' '))
var gitUrl = 'ssb://' + repo.id
return renderRepoPage(repo, 'code', null, pull.once(
'' +
'Getting started ' +
'Create a new repository ' +
'touch README.md\n' +
'git init\n' +
'git add README.md\n' +
'git commit -m "Initial commit"\n' +
'git remote add origin ' + gitUrl + '\n' +
'git push -u origin master \n' +
'Push an existing repository \n' +
'git remote add origin ' + gitUrl + '\n' +
'git push -u origin master ' +
' '))
}
function serveRepoTree(repo, rev, path) {
if (!rev) return serveEmptyRepo(repo)
var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
return renderRepoPage(repo, 'code', rev, cat([
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, 'activity', branch, cat([
pull.once('Activity '),
pull(
ssb.links({
type: 'git-update',
dest: repo.id,
source: repo.feed,
rel: 'repo',
values: true,
reverse: true
}),
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(req, repo, branch) {
var query = req._u.query
return renderRepoPage(repo, 'commits', branch, cat([
pull.once('Commits '),
pull(
repo.readLog(query.start || branch),
pull.take(20),
paginate(
!query.start ? '' : function (first, cb) {
cb(null, '…')
},
pull(
paramap(repo.getCommitParsed.bind(repo), 8),
pull.map(renderCommit.bind(this, repo))
),
function (last, cb) {
cb(null, 'Older ')
}
)
)
]))
}
function renderCommit(repo, commit) {
var commitPath = [repo.id, 'commit', commit.id]
var treePath = [repo.id, 'tree', commit.id]
return '' +
'' + link(commitPath, commit.title) + ' ' +
'' + commit.id + ' ' +
link(treePath, 'Tree') + ' ' +
escapeHTML(commit.author.name) + ' · ' + commit.author.date.toLocaleString() +
(commit.separateAuthor ? ' ' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
' '
}
/* Repo tree */
function revMenu(repo, currentName) {
return readOnce(function (cb) {
repo.getRefNames(true, function (err, refs) {
if (err) return cb(err)
cb(null, '' +
Object.keys(refs).map(function (group) {
return '' +
refs[group].map(function (name) {
var htmlName = escapeHTML(name)
return '' + htmlName + ' '
}).join('') + ' '
}).join('') +
' ')
})
})
}
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, 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(' '),
renderObjectData(obj, file.name, repo),
pull.once(' ')
]))
})
})
)
})
}
/* Repo commit */
function serveRepoCommit(repo, rev) {
return renderRepoPage(repo, null, rev, cat([
readNext(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.id]
cb(null, cat([pull.once(
'' + link(commitPath, 'Commit ' + rev) + ' ' +
'' +
'' +
link(treePath, 'Browse Files') +
'
' +
'' + escapeHTML(commit.title) + ' ' +
(commit.body ? 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(' ') +
' '),
renderDiffStat(repo, commit.id, commit.parents)
]))
})
})
]))
}
/* Diff stat */
function renderDiffStat(repo, id, parentIds) {
if (parentIds.length == 0) parentIds = [null]
var lastI = parentIds.length
var oldTree = parentIds[0]
var changedFiles = []
return cat([
pull.once('Files changed '),
pull(
repo.diffTrees(parentIds.concat(id), true),
pull.map(function (item) {
var filename = item.filename = escapeHTML(item.path.join('/'))
var oldId = item.id && item.id[0]
var newId = item.id && item.id[lastI]
var oldMode = item.mode && item.mode[0]
var newMode = item.mode && item.mode[lastI]
var action =
!oldId && newId ? 'added' :
oldId && !newId ? 'deleted' :
oldMode != newMode ?
'changed mode from ' + oldMode.toString(8) +
' to ' + newMode.toString(8) :
'changed'
if (item.id)
changedFiles.push(item)
var fileHref = item.id ?
'#' + encodeURIComponent(item.path.join('/')) :
encodeLink([repo.id, 'blob', id].concat(item.path))
return ['' + filename + ' ', action]
}),
table()
),
pull(
pull.values(changedFiles),
paramap(function (item, cb) {
var done = multicb({ pluck: 1, spread: true })
getRepoObjectString(repo, item.id[0], done())
getRepoObjectString(repo, item.id[lastI], done())
done(function (err, strOld, strNew) {
if (err) return cb(err)
var commitId = item.id[lastI] ? id : parentIds.filter(Boolean)[0]
cb(null, htmlLineDiff(item.filename, item.filename,
strOld, strNew,
encodeLink([repo.id, 'blob', commitId].concat(item.path))))
})
}, 4)
),
pull.once(' ' + hashHighlightScript),
])
}
function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref, rawHref) {
var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
var groups = diff.hunks.map(function (hunk) {
var oldLine = hunk.oldStart
var newLine = hunk.newStart
var header = ''
return [header].concat(hunk.lines.map(function (line) {
var s = line[0]
if (s == '\\') return
var html = highlight(line, getExtension(filename))
var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
var id = [filename].concat(lineNums).join('-')
return '' +
lineNums.map(function (num) {
return '' +
(num ? '' +
num + ' ' : '') + ' '
}).join('') +
'' + html + ' '
}))
})
return '' +
'' + filename +
'' +
'View ' +
' ' +
[].concat.apply([], groups).join('') +
'
'
}
/* An unknown message linking to a repo */
function serveRepoSomething(req, repo, id, msg, path) {
return renderRepoPage(repo, null, 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 = req._u.query.raw != null
if (raw)
return renderRepoPage(repo, 'activity', null, pull.once(
'' +
'Update ' +
'' + json({key: id, value: msg}) + ' '))
// 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, 'activity', null, cat([
pull.once(
'' +
'Update ' +
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') : '')),
cat(!msg.content.packs ? [] : [
pull.once('Commits '),
pull(
pull.values(msg.content.packs),
paramap(function (pack, cb) {
var key = pack.pack.link
ssb.blobs.want(key, function (err, got) {
if (err) cb(err)
else if (!got) cb(null, pull.once('Missing blob ' + key))
else cb(null, ssb.blobs.get(key))
})
}, 8),
pull.map(function (readPack, cb) {
return gitPack.decode({}, repo, cb, readPack)
}),
pull.flatten(),
paramap(function (obj, cb) {
if (obj.type == 'commit')
Repo.getCommitParsed(obj, cb)
else
pull(obj.read, pull.drain(null, cb))
}, 8),
pull.filter(),
pull.map(function (commit) {
return renderCommit(repo, commit)
})
)
])
]))
}
function renderObject(obj) {
return '' +
obj.type + ' ' + link([obj.link], 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, rev, path) {
return readNext(function (cb) {
repo.getFile(rev, path, function (err, object) {
if (err) return cb(null, serveBlobNotFound(repo.id, err))
var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
var pathLinks = path.length === 0 ? '' :
': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
var rawFilePath = [repo.id, 'raw', rev].concat(path)
var filename = path[path.length-1]
var extension = getExtension(filename)
cb(null, renderRepoPage(repo, 'code', rev, cat([
pull.once(''),
type == 'Branch' && renderRepoLatest(repo, rev),
pull.once(' ' +
'Files' + pathLinks + ' ' +
'' + object.length + ' bytes' +
'' + link(rawFilePath, 'Raw') + ' ' +
'
' +
''),
extension in imgMimes
? pull.once(' ')
: renderObjectData(object, filename, repo),
pull.once(' ')
])))
})
})
}
function serveBlobNotFound(repoId, err) {
return serveTemplate('Blob not found', 404, pull.values([
'Blob not found ',
'Blob in repo ' + link([repoId]) + ' was not found
',
'' + escapeHTML(err.stack) + ' '
]))
}
/* Raw blob */
function serveRepoRaw(repo, branch, path) {
return readNext(function (cb) {
repo.getFile(branch, path, function (err, object) {
if (err) return cb(null, serveBuffer(404, 'Blob not found'))
var extension = getExtension(path[path.length-1])
var contentType = imgMimes[extension]
cb(null, pull(object.read, serveRaw(object.length, contentType)))
})
})
}
function serveRaw(length, contentType) {
var inBody
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 function (end, cb) {
if (inBody) return read(end, cb)
if (end) return cb(true)
cb(null, [200, headers])
inBody = true
}
}
}
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)))
})
})
}
/* Digs */
function serveRepoDigs(repo) {
return readNext(function (cb) {
getVotes(repo.id, function (err, votes) {
cb(null, renderRepoPage(repo, null, null, cat([
pull.once('Digs ' +
'Total: ' + votes.upvotes + '
'),
pull(
pull.values(Object.keys(votes.upvoters)),
paramap(function (feedId, cb) {
about.getName(feedId, function (err, name) {
if (err) return cb(err)
cb(null, link([feedId], name))
})
}, 8),
ul()
),
pull.once(' ')
])))
})
})
}
/* Issues */
function serveRepoIssues(req, repo, issueId, path) {
var numIssues = 0
var state = req._u.query.state || 'open'
return renderRepoPage(repo, 'issues', null, cat([
pull.once(
(isPublic ? '' :
'' + link([repo.id, 'issues', 'new'],
'+ New Issue ', true) +
'
') +
'Issues ' +
nav([
['?state=open', 'Open', 'open'],
['?state=closed', 'Closed', 'closed'],
['?state=all', 'All', 'all']
], state)),
pull(
issues.createFeedStream({ project: repo.id }),
pull.filter(function (issue) {
return state == 'all' ? true : (state == 'closed') == !issue.open
}),
pull.map(function (issue) {
numIssues++
var state = (issue.open ? 'open' : 'closed')
return ''
})
),
readOnce(function (cb) {
cb(null, numIssues > 0 ? '' : 'No issues
')
})
]))
}
/* New Issue */
function serveRepoNewIssue(repo, issueId, path) {
return renderRepoPage(repo, 'issues', null, pull.once(
'New Issue ' +
''))
}
/* Issue */
function serveRepoIssue(req, repo, issue, path, postId) {
var isAuthor = (myId == issue.author) || (myId == repo.feed)
var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
return renderRepoPage(repo, 'issues', null, cat([
pull.once(
renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
'Rename the issue',
'' + link([issue.id], issue.title) + ' ') +
'' + issue.id + '
' +
'' +
(issue.open
? 'Open '
: 'Closed ')),
readOnce(function (cb) {
about.getName(issue.author, function (err, authorName) {
if (err) return cb(err)
var authorLink = link([issue.author], authorName)
cb(null,
authorLink + ' opened this issue on ' + timestamp(issue.created_at) +
' ' +
markdown(issue.text, repo) +
' ')
})
}),
// render posts and edits
pull(
ssb.links({
dest: issue.id,
values: true
}),
pull.unique('key'),
addAuthorName(about),
sortMsgs(),
pull.map(function (msg) {
var authorLink = link([msg.value.author], msg.authorName)
var msgTimeLink = link([msg.key],
new Date(msg.value.timestamp).toLocaleString(), false,
'name="' + escapeHTML(msg.key) + '"')
var c = msg.value.content
if (msg.value.timestamp > newestMsg.value.timestamp)
newestMsg = msg
switch (c.type) {
case 'post':
if (c.root == issue.id) {
var changed = issues.isStatusChanged(msg, issue)
return '' +
(msg.key == postId ? '' : '') +
authorLink +
(changed == null ? '' : ' ' + (
changed ? 'reopened this issue' : 'closed this issue')) +
' · ' + msgTimeLink +
(msg.key == postId ? '
' : '') +
markdown(c.text, repo) +
' '
} else {
var text = c.text || (c.type + ' ' + msg.key)
return ''
}
case 'issue':
return '' +
authorLink + ' mentioned this issue in ' +
link([msg.key], String(c.title || msg.key).substr(0, 140)) +
' '
case 'issue-edit':
return '' +
(c.title == null ? '' :
authorLink + ' renamed this issue to ' +
escapeHTML(c.title) + ' ') +
' · ' + msgTimeLink +
' '
case 'git-update':
var mention = issues.getMention(msg, issue)
if (mention) {
var commitLink = link([repo.id, 'commit', mention.object],
mention.label || mention.object)
return '' +
authorLink + ' ' +
(mention.open ? 'reopened this issue' :
'closed this issue') +
' · ' + msgTimeLink + ' ' +
commitLink +
' '
} else if ((mention = getMention(msg, issue.id))) {
var commitLink = link(mention.object ?
[repo.id, 'commit', mention.object] : [msg.key],
mention.label || mention.object || msg.key)
return '' +
authorLink + ' mentioned this issue' +
' · ' + msgTimeLink + ' ' +
commitLink +
' '
} else {
// fallthrough
}
default:
return '' +
authorLink +
' · ' + msgTimeLink +
json(c) +
' '
}
})
),
isPublic ? pull.empty() : readOnce(renderCommentForm)
]))
function renderCommentForm(cb) {
cb(null, '')
}
}
}