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')
// 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, 'tree', url])
return url
}
marked.setOptions({
gfm: true,
mentions: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false,
renderer: blockRenderer
})
// hack to make git link mentions work
new marked.InlineLexer(1, marked.defaults).rules.mention =
/^(\s)?([@%&][A-Za-z0-9\._\-+=\/]*[A-Za-z0-9_\-+=\/]|[0-9a-f]{40})/
function markdown(text, repo, cb) {
if (!text) return ''
if (typeof text != 'string') text = String(text)
return marked(text, {repo: repo}, cb)
}
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 isArray(arr) {
return Object.prototype.toString.call(arr) == '[object Array]'
}
function encodeLink(url) {
if (!isArray(url)) url = [url]
return '/' + url.map(encodeURIComponent).join('/')
}
function link(parts, text, raw, props) {
if (text == null) text = parts[parts.length-1]
if (!raw) text = escapeHTML(text)
return '' + text + ' '
}
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 ul(props) {
return function (read) {
return cat([
pull.once(''),
pull(
read,
pull.map(function (li) {
return '' + li + ' '
})
),
pull.once(' ')
])
}
}
function renderNameForm(enabled, id, name, action, inputId, title, header) {
if (!inputId) inputId = action
return ''
}
function renderPostForm(repo, placeholder, rows) {
return ' ' +
' ' +
'' +
'Write ' +
'Preview ' +
'
' +
' ' +
'' +
'' +
'
' +
'
' +
''
}
function wrap(tag) {
return function (read) {
return cat([
pull.once('<' + tag + '>'),
read,
pull.once('' + tag + '>')
])
}
}
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(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)
}
var hasOwnProp = Object.prototype.hasOwnProperty
function getContentType(filename) {
var ext = filename.split('.').pop()
return hasOwnProp.call(contentTypes, ext)
? contentTypes[ext]
: 'text/plain; charset=utf-8'
}
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 issueCommentScript = '(' + function () {
var $ = document.getElementById.bind(document)
$('preview-tab-link').onclick = function (e) {
with (new XMLHttpRequest()) {
open('POST', '', true)
onload = function() {
$('preview-tab').innerHTML = responseText
}
send('action=markdown&repo=' + $('repo-id').value + '&text=' +
encodeURIComponent($('post-text').value))
}
}
}.toString() + ')()'
var msgTypes = {
'git-repo': true,
'git-update': true,
'issue': true
}
var refLabels = {
heads: 'Branches',
tags: 'Tags'
}
var imgMimes = {
png: 'image/png',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
gif: 'image/gif',
tif: 'image/tiff',
svg: 'image/svg+xml',
bmp: 'image/bmp'
}
var markdownFilenameRegex = /\.md$|\/.markdown$/i
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 dirs = u.pathname.slice(1).split(/\/+/).map(tryDecodeURIComponent)
var dir = dirs[0]
if (req.method == 'POST') {
if (isPublic)
return servePlainError(405, 'POST not allowed on public site')
return readNext(function (cb) {
readReqJSON(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 'vote':
var voteValue = +data.vote || 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))
})
return
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))
// TODO: add ref mentions
var msg = schemas.post(data.text, data.id, data.branch || data.id)
if (data.open != null)
Issues.schemas.opens(msg, data.id)
if (data.close != null)
Issues.schemas.closes(msg, data.id)
return ssb.publish(msg, function (err) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(req.url))
})
case 'new-issue':
return issues.new({
project: dir,
title: data.title,
text: data.text
}, function (err, issue) {
if (err) return cb(null, serveError(err))
cb(null, serveRedirect(encodeLink(issue.id)))
})
case 'markdown':
return cb(null, serveMarkdown(data.text, {id: data.repo}))
default:
cb(null, servePlainError(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(dir, dirs.slice(1))
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; charset=utf-8'
}],
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 serveMarkdown(text, repo) {
var html = markdown(text, repo)
return pull.values([
[200, {
'Content-Length': Buffer.byteLength(html),
'Content-Type': 'text/html; charset=utf-8'
}],
html
])
}
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)
)
}
/* Feed */
function renderFeed(feedId) {
var opts = {
reverse: true,
id: feedId
}
return pull(
feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
pull.filter(function (msg) {
return msg.value.content.type in msgTypes &&
msg.value.timestamp < Date.now()
}),
pull.take(20),
addAuthorName(about),
pull.asyncMap(renderFeedItem)
)
}
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() {
return serveTemplate('git ssb')(renderFeed())
}
function serveUserPage(feedId, dirs) {
switch (dirs[0]) {
case undefined:
case '':
case 'activity':
return serveUserActivity(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 + '
' +
'' +
link([feedId], 'Activity', true,
page == 'activity' ? ' class="active"' : '') +
link([feedId, 'repos'], 'Repos', true,
page == 'repos' ? ' class="active"' : '') +
' ')
})
}),
body,
]))
}
function serveUserActivity(feedId) {
return renderUserPage(feedId, 'activity', renderFeed(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))
})
})
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))
}
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)
case 'raw':
return serveRepoRaw(repo, branch, filePath)
case 'digs':
return serveRepoDigs(repo)
case 'issues':
switch (path[1]) {
case '':
case undefined:
return serveRepoIssues(repo, branch, filePath)
case 'new':
if (filePath.length == 0)
return serveRepoNewIssue(repo)
}
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 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())
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(
'' +
'' +
renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
'Rename the repo',
'
' + link([repo.feed], authorName) + ' / ' +
link([repo.id], repoName) + ' ') +
'' + link([repo.id], 'Code') +
link([repo.id, 'activity'], 'Activity') +
link([repo.id, 'commits', branch || ''], 'Commits') +
link([repo.id, 'issues'], 'Issues') +
gitLink +
' '),
body
])))
})
})
}
function serveRepoTree(repo, rev, path) {
var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
return renderRepoPage(repo, 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, 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, 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 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, 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) + ' '),
markdownFilenameRegex.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, markdown(buf.toString(), repo))
}))
})
: 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, 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 = req._u.query.raw != null
// 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 ? '' :
'') +
'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], 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 = filename.split('.').pop()
cb(null, renderRepoPage(repo, rev, cat([
pull.once(''),
type == 'Branch' && renderRepoLatest(repo, rev),
pull.once(' ' +
'Files' + pathLinks + ' ' +
'' + object.length + ' bytes' +
'' + link(rawFilePath, 'Raw') + ' ' +
'
' +
''),
extension in imgMimes
? pull.once(' ')
: markdownFilenameRegex.test(filename)
? readOnce(function (cb) {
pull(object.read, pull.collect(function (err, bufs) {
if (err) return cb(err)
var buf = Buffer.concat(bufs, object.length)
cb(null, markdown(buf.toString('utf8'), repo))
}))
})
: pull(object.read, escapeHTMLStream(), wrap('pre')),
pull.once(' ')
])))
})
})
}
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) + ' '
]))
}
/* Raw blob */
function serveRepoRaw(repo, branch, path) {
return readNext(function (cb) {
repo.getFile(branch, path, function (err, object) {
if (err) return cb(null, servePlainError(404, 'Blob not found'))
var extension = path[path.length-1].split('.').pop()
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, '', cat([
pull.once('Digs ' +
'Total: ' + votes.upvotes + '
'),
pull(
pull.values(Object.keys(votes.upvoters)),
pull.asyncMap(function (feedId, cb) {
about.getName(feedId, function (err, name) {
if (err) return cb(err)
cb(null, link([feedId], name))
})
}),
ul()
),
pull.once(' ')
])))
})
})
}
/* Issues */
function serveRepoIssues(repo, issueId, path) {
var numIssues = 0
return renderRepoPage(repo, '', cat([
pull.once(
(isPublic ? '' :
'' + link([repo.id, 'issues', 'new'],
'+ New Issue ', true) +
'
') +
'Issues '),
pull(
issues.createFeedStream({ project: repo.id }),
pull.map(function (issue) {
numIssues++
return ''
})
),
readOnce(function (cb) {
cb(null, numIssues > 0 ? '' : 'No issues
')
})
]))
}
/* New Issue */
function serveRepoNewIssue(repo, issueId, path) {
return renderRepoPage(repo, '', pull.once(
'New Issue ' +
''))
}
/* Issue */
function serveRepoIssue(req, repo, issue, path) {
var isAuthor = (myId == issue.author) || (myId == repo.feed)
var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
return renderRepoPage(repo, null, cat([
pull.once(
renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
'Rename the issue',
'' + 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),
pull.map(function (msg) {
var authorLink = link([msg.value.author], msg.authorName)
var msgTimeLink = link([msg.key],
new Date(msg.value.timestamp).toLocaleString())
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 '' +
authorLink +
(changed == null ? '' : ' ' + (
changed ? 'reopened this issue' : 'closed this issue')) +
' · ' + msgTimeLink +
markdown(c.text, repo) +
' '
} else {
var text = c.text || (c.type + ' ' + msg.key)
return '' +
authorLink + ' mentioned this issue in ' +
link([msg.key], String(text).substr(0, 140)) +
' '
}
case 'issue-edit':
return '' +
(c.title == null ? '' :
authorLink + ' renamed this issue to ' +
escapeHTML(c.title) + ' ') +
' · ' + msgTimeLink +
' '
default:
return '' +
authorLink +
' · ' + msgTimeLink +
json(c) +
' '
}
})
),
isPublic ? pull.empty() : readOnce(renderCommentForm)
]))
function renderCommentForm(cb) {
cb(null, '')
}
}
}