A `nft: "give"` message is a change of custody, not a thing. It has no photo of its own, so it borrowed the photo of the item it transferred - which meant the same object appeared on the grid twice, a few cards apart, distinguished only by a dimmed image and an id overlay. That reads as a duplicate, not as a hand-off. Custody is better answered one level down. Each card already links to its item's thread, where the mint and every hand-off since are in order, with the steward resolved. So the overlay was showing a truncated feed id on the grid to save a click that is worth making. Filters on nft === "mint" rather than excluding gives: every custodisco message carries one of the two - checked across all 407 - so matching mint is exact. 301 items from 3 feeds, down from 407 entries. The feed count now counts feeds that actually contributed an item rather than the size of the follow set. The pub follows itself and publishes no items, so the old number was one too high and would have drifted further as feeds get followed for other reasons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0192zBTNZKZn5svyJ5HTnYds
642 lines
19 KiB
JavaScript
642 lines
19 KiB
JavaScript
var fs = require('fs')
|
|
var http = require('http')
|
|
var qs = require('querystring')
|
|
var path = require('path')
|
|
var crypto = require('crypto')
|
|
var pull = require('pull-stream')
|
|
var paramap = require('pull-paramap')
|
|
var sort = require('ssb-sort')
|
|
var toPull = require('stream-to-pull-stream')
|
|
var memo = require('asyncmemo')
|
|
var lru = require('lrucache')
|
|
var webresolve = require('ssb-web-resolver')
|
|
var serveEmoji = require('emoji-server')()
|
|
var refs = require('ssb-ref')
|
|
var BoxStream = require('pull-box-stream')
|
|
var h = require('hyperscript')
|
|
var {
|
|
MdRenderer,
|
|
renderEmoji,
|
|
formatMsgs,
|
|
wrapPage,
|
|
renderThread,
|
|
renderAbout,
|
|
renderShowAll,
|
|
renderRssItem,
|
|
wrapRss,
|
|
renderItemGrid,
|
|
} = require('./render')
|
|
var getFollows = require('./lib/follows')
|
|
var createItemsHandler = require('./items')
|
|
|
|
var appHash = hash([fs.readFileSync(__filename)])
|
|
|
|
var urlIdRegex = /^(?:\/(([%&@]|%25|%26|%40)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3[Dd])\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
|
|
|
|
var zeros = Buffer.alloc(24); zeros.fill(0)
|
|
|
|
function hash(arr) {
|
|
return arr.reduce(function (hash, item) {
|
|
return hash.update(String(item))
|
|
}, crypto.createHash('sha256')).digest('base64')
|
|
}
|
|
|
|
exports.name = 'viewer'
|
|
exports.manifest = {}
|
|
exports.version = require('./package').version
|
|
|
|
exports.init = function (sbot, config) {
|
|
var conf = config.viewer || {}
|
|
|
|
// --- known-blob gate -----------------------------------------------------
|
|
// This node used to cache blobs on behalf of strangers (ssb-blobs `sympathy`
|
|
// defaults to 3), and serveBlob would hand any of them to anyone who knew the
|
|
// hash. sympathy is 0 now and the cache has been pruned, so everything we hold
|
|
// is referenced by a feed we replicate. This keeps that true even if a stray
|
|
// blob ever lands.
|
|
//
|
|
// The local log is small (~360KB), so a regex scan is cheap. It is refreshed
|
|
// periodically rather than kept live: being a few minutes stale can only delay
|
|
// a legitimate image, never serve one that is not ours.
|
|
var knownBlobs = null // null = not loaded yet -> fail open, never 404 everything
|
|
var logOffsetPath = path.join(config.path, 'flume', 'log.offset')
|
|
|
|
function refreshKnownBlobs() {
|
|
fs.readFile(logOffsetPath, function (err, buf) {
|
|
if (err) {
|
|
console.error('[viewer] blob gate: could not read log.offset:', err.message)
|
|
return // keep whatever set we already had
|
|
}
|
|
var found = buf.toString('binary').match(/&[A-Za-z0-9+/]{43}=\.sha256/g) || []
|
|
var next = Object.create(null)
|
|
found.forEach(function (id) { next[id] = true })
|
|
if (knownBlobs === null) {
|
|
console.log('[viewer] blob gate active:', Object.keys(next).length, 'known blobs')
|
|
}
|
|
knownBlobs = next
|
|
})
|
|
}
|
|
refreshKnownBlobs()
|
|
setInterval(refreshKnownBlobs, 5 * 60 * 1000).unref()
|
|
|
|
// serveBlob is a free function outside this closure, so hand the check over.
|
|
sbot.isKnownBlob = function (id) {
|
|
if (knownBlobs === null) return true
|
|
return !!knownBlobs[id]
|
|
}
|
|
var port = conf.port || 8807
|
|
var host = conf.host || config.host || '::'
|
|
|
|
var base = conf.base || '/'
|
|
var defaultOpts = {
|
|
base: base,
|
|
msg_base: conf.msg_base || base,
|
|
feed_base: conf.feed_base || base,
|
|
blob_base: conf.blob_base || base,
|
|
img_base: conf.img_base || base,
|
|
emoji_base: conf.emoji_base || (base + 'emoji/'),
|
|
requireOptIn: conf.require_opt_in == null ? true : conf.require_opt_in,
|
|
}
|
|
|
|
defaultOpts.marked = {
|
|
gfm: true,
|
|
mentions: true,
|
|
tables: true,
|
|
breaks: true,
|
|
pedantic: false,
|
|
sanitize: true,
|
|
smartLists: true,
|
|
smartypants: false,
|
|
emoji: renderEmoji,
|
|
renderer: new MdRenderer(defaultOpts)
|
|
}
|
|
|
|
var getMsg = memo({cache: lru(100)}, getMsgWithValue, sbot)
|
|
var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
|
|
var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
|
|
|
|
var serveItems = createItemsHandler({
|
|
sbot: sbot,
|
|
defaultOpts: defaultOpts,
|
|
addAuthorAbout: addAuthorAbout,
|
|
renderItemGrid: renderItemGrid,
|
|
wrapPage: wrapPage,
|
|
respond: respond,
|
|
toPull: toPull,
|
|
})
|
|
|
|
http.createServer(serve).listen(port, host, function () {
|
|
if (/:/.test(host)) host = '[' + host + ']'
|
|
console.log('[viewer] Listening on http://' + host + ':' + port)
|
|
})
|
|
|
|
function serve(req, res) {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
return respond(res, 405, 'Method must be GET or HEAD')
|
|
}
|
|
|
|
var m = urlIdRegex.exec(req.url)
|
|
|
|
if (m[4] === '/robots.txt') return serveRobots(req, res, conf)
|
|
if (req.url.startsWith('/static/')) return serveStatic(req, res, m[4])
|
|
if (req.url.startsWith('/emoji/')) return serveEmoji(req, res, m[4])
|
|
if (m[4] === '/items') return serveItems(req, res, m[5])
|
|
if (req.url.startsWith('/user-feed/')) return serveUserFeed(req, res, m[4])
|
|
else if (req.url.startsWith('/channel/')) return serveChannel(req, res, m[4])
|
|
else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
|
|
else if (req.url.startsWith('/web/')) return serveWeb(req, res, m[4])
|
|
|
|
if (m[2] && m[2].length === 3) {
|
|
m[1] = decodeURIComponent(m[1])
|
|
m[2] = m[1][0]
|
|
}
|
|
switch (m[2]) {
|
|
case '%': return serveId(req, res, m[1], m[3], m[5])
|
|
case '@': return serveFeed(req, res, m[1], m[3], m[5])
|
|
case '&': return serveBlob(req, res, sbot, m[1], m[5])
|
|
}
|
|
|
|
if (m[4] === '/') return serveHome(req, res, m[5])
|
|
return respond(res, 404, 'Not found')
|
|
}
|
|
|
|
// The bare id-lookup form this used to render was a dead end for anyone
|
|
// arriving without an id already in hand. Keep the ?id= redirect, then show
|
|
// the item grid instead of an empty box.
|
|
function serveHome(req, res, query) {
|
|
var q = query ? qs.parse(query) : {}
|
|
var id = asLink(q.id)
|
|
if (id) {
|
|
res.writeHead(303, {
|
|
Location: '/' + (
|
|
id[0] === '#' ? 'channel/' + id.substr(1) :
|
|
refs.isMsgId(id) ? encodeURIComponent(id) : id)
|
|
})
|
|
return res.end()
|
|
}
|
|
return serveItems(req, res, query)
|
|
}
|
|
|
|
function serveFeed(req, res, feedId, ext) {
|
|
console.log('serving feed: ' + feedId)
|
|
|
|
var showAll = req.url.endsWith('?showAll')
|
|
|
|
getAbout(feedId, function (err, about) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
|
|
function render() {
|
|
switch (ext) {
|
|
case 'rss':
|
|
return pull(
|
|
// formatMsgs(feedId, ext, defaultOpts)
|
|
renderRssItem(defaultOpts), wrapRss(about.name, defaultOpts)
|
|
)
|
|
default:
|
|
var publicWebHosting = about.publicWebHosting == null
|
|
? !defaultOpts.requireOptIn : about.publicWebHosting
|
|
var name = publicWebHosting ? about.name : feedId.substr(0, 10) + '…'
|
|
return pull(
|
|
renderAbout(defaultOpts, about,
|
|
renderShowAll(showAll, req.url)), wrapPage(name)
|
|
)
|
|
}
|
|
}
|
|
|
|
pull(
|
|
sbot.createUserStream({ id: feedId, reverse: true, limit: showAll ? -1 : (ext == 'rss' ? 25 : 10) }),
|
|
pull.filter(function (data) {
|
|
return 'object' === typeof data.value.content
|
|
}),
|
|
pull.collect(function (err, logs) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
res.writeHead(200, {
|
|
'Content-Type': ctype(ext)
|
|
})
|
|
pull(
|
|
pull.values(logs),
|
|
paramap(addAuthorAbout, 8),
|
|
paramap(addBlog, 8),
|
|
paramap(addFollowAbout, 8),
|
|
paramap(addVoteMessage, 8),
|
|
paramap(addGitLinks, 8),
|
|
paramap(addGatheringAbout, 8),
|
|
render(),
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function serveWeb (req, res, url) {
|
|
var self = this
|
|
var id = decodeURIComponent(url.substr(1))
|
|
|
|
var components = url.split('/')
|
|
if (components[0] === '') components.shift()
|
|
if (components[0] === 'web') components.shift()
|
|
components[0] = decodeURIComponent(components[0])
|
|
|
|
webresolve(sbot, components, function (err, data) {
|
|
if (err) {
|
|
return respond(res, 404, 'ERROR: ' + err)
|
|
}
|
|
return pull(
|
|
pull.once(data),
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function serveUserFeed(req, res, url) {
|
|
var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
|
|
console.log('serving user feed: ' + feedId)
|
|
|
|
getAbout(feedId, function (err, about) {
|
|
getFollows(sbot, feedId, function (err, sets) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
serveFeeds(req, res, sets.following, sets.channelSubscriptions, feedId,
|
|
'user feed ' + (about ? about.name : ''))
|
|
})
|
|
})
|
|
}
|
|
|
|
function serveFeeds(req, res, following, channelSubscriptions, feedId, name) {
|
|
var feedOpts = Object.assign({}, defaultOpts, {
|
|
renderPrivate: false,
|
|
renderSubscribe: false,
|
|
renderVote: false,
|
|
renderTalenet: false,
|
|
renderChess: false,
|
|
renderFollow: false,
|
|
renderPub: false,
|
|
renderAbout: false
|
|
})
|
|
|
|
pull(
|
|
sbot.createLogStream({ reverse: true, limit: 5000 }),
|
|
pull.filter((msg) => {
|
|
return !msg.value ||
|
|
(msg.value.author in following ||
|
|
msg.value.content.channel in channelSubscriptions)
|
|
}),
|
|
pull.take(150),
|
|
pull.collect(function (err, logs) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
res.writeHead(200, {
|
|
'Content-Type': ctype('html')
|
|
})
|
|
pull(
|
|
pull.values(logs),
|
|
paramap(addAuthorAbout, 8),
|
|
paramap(addBlog, 8),
|
|
paramap(addFollowAbout, 8),
|
|
paramap(addVoteMessage, 8),
|
|
paramap(addGitLinks, 8),
|
|
paramap(addGatheringAbout, 8),
|
|
pull(renderThread(feedOpts), wrapPage(name)),
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
}
|
|
|
|
function serveChannel(req, res, url) {
|
|
var channelId = url.substring(url.lastIndexOf('channel/')+8, 100)
|
|
console.log('serving channel: ' + channelId)
|
|
|
|
var showAll = req.url.endsWith('?showAll')
|
|
|
|
pull(
|
|
sbot.query.read({ limit: showAll ? 300 : 10, reverse: true, query: [{$filter: { value: { content: { channel: channelId }}}}]}),
|
|
pull.collect(function (err, logs) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
res.writeHead(200, {
|
|
'Content-Type': ctype('html')
|
|
})
|
|
pull(
|
|
pull.values(logs),
|
|
paramap(addAuthorAbout, 8),
|
|
paramap(addBlog, 8),
|
|
paramap(addVoteMessage, 8),
|
|
paramap(addGatheringAbout, 8),
|
|
pull(renderThread(defaultOpts, '', renderShowAll(showAll, req.url)),
|
|
wrapPage('#' + channelId)),
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
}
|
|
|
|
function serveId(req, res, id, ext, query) {
|
|
var q = query ? qs.parse(query) : {}
|
|
var includeRoot = !('noroot' in q)
|
|
var base = q.base || conf.base
|
|
var baseToken
|
|
if (!base) {
|
|
if (ext === 'js') base = baseToken = '__BASE_' + Math.random() + '_'
|
|
else base = '/'
|
|
}
|
|
var opts = {
|
|
base: base,
|
|
base_token: baseToken,
|
|
msg_base: q.msg_base || conf.msg_base || base,
|
|
feed_base: q.feed_base || conf.feed_base || base,
|
|
blob_base: q.blob_base || conf.blob_base || base,
|
|
img_base: q.img_base || conf.img_base || base,
|
|
emoji_base: q.emoji_base || conf.emoji_base || (base + 'emoji/'),
|
|
requireOptIn: defaultOpts.requireOptIn,
|
|
}
|
|
opts.marked = {
|
|
gfm: true,
|
|
mentions: true,
|
|
tables: true,
|
|
breaks: true,
|
|
pedantic: false,
|
|
sanitize: true,
|
|
smartLists: true,
|
|
smartypants: false,
|
|
emoji: renderEmoji,
|
|
renderer: new MdRenderer(opts)
|
|
}
|
|
|
|
var format = formatMsgs(id, ext, opts)
|
|
if (format === null) return respond(res, 415, 'Invalid format')
|
|
|
|
function render (links) {
|
|
var etag = hash(sort.heads(links).concat(appHash, ext, qs))
|
|
if (req.headers['if-none-match'] === etag) return respond(res, 304)
|
|
res.writeHead(200, {
|
|
'Content-Type': ctype(ext),
|
|
'etag': etag
|
|
})
|
|
pull(
|
|
pull.values(sort(links)),
|
|
paramap(addAuthorAbout, 8),
|
|
paramap(addVoteMessage, 8),
|
|
paramap(addBlog, 8),
|
|
paramap(addGatheringAbout, 8),
|
|
format,
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
}
|
|
|
|
getMsgWithValue(sbot, id, function (err, root) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
if('string' === typeof root.value.content)
|
|
return render([root])
|
|
|
|
pull(
|
|
sbot.links({dest: id, values: true, rel: 'root' }),
|
|
pull.unique('key'),
|
|
pull.collect(function (err, links) {
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
if(includeRoot)
|
|
links.unshift(root)
|
|
render(links)
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function addFollowAbout(msg, cb) {
|
|
if (msg.value.content.contact)
|
|
getAbout(msg.value.content.contact, function (err, about) {
|
|
if (err) return cb(err)
|
|
msg.value.content.contactAbout = about
|
|
cb(null, msg)
|
|
})
|
|
else
|
|
cb(null, msg)
|
|
}
|
|
|
|
function addVoteMessage(msg, cb) {
|
|
if (msg.value.content.type == 'vote' && msg.value.content.vote && msg.value.content.vote.link[0] == '%')
|
|
getMsg(msg.value.content.vote.link, function (err, linkedMsg) {
|
|
var linkedC = linkedMsg && linkedMsg.value.content
|
|
if (linkedMsg)
|
|
msg.value.content.vote.linkedText =
|
|
(typeof linkedC.contentWarning === 'string' ? '[CW: ' + linkedC.contentWarning + '] ' : '') +
|
|
linkedC.text
|
|
cb(null, msg)
|
|
})
|
|
else
|
|
cb(null, msg)
|
|
}
|
|
|
|
function addBlog(msg, cb) {
|
|
if (msg.value && msg.value.content.type == 'blog') {
|
|
pull(
|
|
sbot.blobs.get(msg.value.content.blog),
|
|
pull.collect(function(err, blob) {
|
|
msg.value.content.blogContent = blob
|
|
cb(null, msg)
|
|
})
|
|
)
|
|
} else
|
|
cb(null, msg)
|
|
}
|
|
|
|
function addAuthorAbout(msg, cb) {
|
|
getAbout(msg.value.author, function (err, about) {
|
|
if (err) return cb(err)
|
|
msg.author = about
|
|
cb(null, msg)
|
|
})
|
|
}
|
|
|
|
function addGatheringAbout(msg, cb) {
|
|
if (msg.value && msg.value.content.type === 'gathering') {
|
|
getAbout(msg.key, (err, about) => {
|
|
if (err) { cb(err) }
|
|
|
|
msg.value.content.about = about
|
|
|
|
pull(
|
|
sbot.backlinks.read({
|
|
query: [{ $filter: {
|
|
dest: msg.key,
|
|
value: { content: { type: 'about' }},
|
|
}}],
|
|
index: 'DTA'
|
|
}),
|
|
// Only grab messages about attendance
|
|
pull.filter(o => o.value.content.attendee !== undefined),
|
|
// Filter "can't attend"-messages
|
|
pull.filter(o => !o.value.content.attendee.remove),
|
|
pull.unique(o => o.value.content.attendee.link),
|
|
pull.collect((err, arr) => {
|
|
if (err) { cb(err) }
|
|
|
|
msg.value.content.numberAttending = arr.length
|
|
|
|
cb(null, msg)
|
|
})
|
|
)
|
|
})
|
|
} else {
|
|
cb(null, msg)
|
|
}
|
|
}
|
|
|
|
function addGitLinks(msg, cb) {
|
|
if (msg.value.content.type == 'git-update')
|
|
getMsg(msg.value.content.repo, function (err, gitRepo) {
|
|
if (gitRepo)
|
|
msg.value.content.repoName = gitRepo.value.content.name
|
|
cb(null, msg)
|
|
})
|
|
else if (msg.value.content.type == 'issue')
|
|
getMsg(msg.value.content.project, function (err, gitRepo) {
|
|
if (gitRepo)
|
|
msg.value.content.repoName = gitRepo.value.content.name
|
|
cb(null, msg)
|
|
})
|
|
else
|
|
cb(null, msg)
|
|
}
|
|
}
|
|
|
|
function serveBlob(req, res, sbot, id, query) {
|
|
var q = query && qs.parse(query)
|
|
var unbox = q && typeof q.unbox === 'string' && q.unbox.replace(/\s/g, '+')
|
|
var etag = id + (unbox || '')
|
|
|
|
if (req.headers['if-none-match'] === etag) return respond(res, 304)
|
|
// Only serve blobs referenced by a feed we replicate. Anything else is not
|
|
// ours to hand out, so treat it as absent rather than confirm we hold it.
|
|
if (typeof sbot.isKnownBlob === 'function' && !sbot.isKnownBlob(id)) {
|
|
return respond(res, 404, 'Not found')
|
|
}
|
|
|
|
sbot.blobs.has(id, function (err, has) {
|
|
if (err) {
|
|
if (/^invalid/.test(err.message)) return respond(res, 400, err.message)
|
|
else return respond(res, 500, err.message || err)
|
|
}
|
|
if (!has) return respond(res, 404, 'Not found')
|
|
|
|
var unboxKey
|
|
if (unbox) {
|
|
try { unboxKey = Buffer.from(unbox, 'base64') }
|
|
catch(e) { return respond(res, 400, err.message) }
|
|
if (unboxKey.length !== 32) return respond(res, 400, 'Bad blob key')
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Cache-Control': 'public, max-age=315360000, immutable',
|
|
'etag': etag
|
|
})
|
|
|
|
pull(
|
|
sbot.blobs.get(id),
|
|
unboxKey ? BoxStream.createUnboxStream(unboxKey, zeros) : null,
|
|
toPull(res, function (err) {
|
|
if (err) console.error('[viewer]', err)
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function getMsgWithValue(sbot, id, cb) {
|
|
sbot.get(id, function (err, value) {
|
|
if (err) return cb(err)
|
|
cb(null, {key: id, value: value})
|
|
})
|
|
}
|
|
|
|
function respond(res, status, message) {
|
|
res.writeHead(status)
|
|
res.end(message)
|
|
}
|
|
|
|
function ctype(name) {
|
|
switch (name && /[^.\/]*$/.exec(name)[0] || 'html') {
|
|
case 'html': return 'text/html'
|
|
case 'js': return 'text/javascript'
|
|
case 'css': return 'text/css'
|
|
case 'json': return 'application/json'
|
|
case 'rss': return 'text/xml'
|
|
}
|
|
}
|
|
|
|
function ifModified(req, lastMod) {
|
|
var ifModSince = req.headers['if-modified-since']
|
|
if (!ifModSince) return false
|
|
var d = new Date(ifModSince)
|
|
return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
|
|
}
|
|
|
|
function serveStatic(req, res, file) {
|
|
serveFile(req, res, path.join(__dirname, 'static', file))
|
|
}
|
|
|
|
function serveFile(req, res, file) {
|
|
fs.stat(file, function (err, stat) {
|
|
if (err && err.code === 'ENOENT') return respond(res, 404, 'Not found')
|
|
if (err) return respond(res, 500, err.stack || err)
|
|
if (!stat.isFile()) return respond(res, 403, 'May only load files')
|
|
if (ifModified(req, stat.mtime)) return respond(res, 304, 'Not modified')
|
|
res.writeHead(200, {
|
|
'Content-Type': ctype(file),
|
|
'Content-Length': stat.size,
|
|
'Last-Modified': stat.mtime.toGMTString()
|
|
})
|
|
fs.createReadStream(file).pipe(res)
|
|
})
|
|
}
|
|
|
|
function asChannelLink(id) {
|
|
var channel = refs.normalizeChannel(id)
|
|
if (channel) return '#' + channel
|
|
}
|
|
|
|
function asLink(id) {
|
|
if (!id || typeof id !== 'string') return null
|
|
id = id.trim()
|
|
if (id[0] === '#') return asChannelLink(id)
|
|
if (refs.isLink(id)) return id
|
|
try {
|
|
id = decodeURIComponent(id)
|
|
} catch(e) {
|
|
return null
|
|
}
|
|
if (id[0] === '#') return asChannelLink(id)
|
|
if (refs.isLink(id)) return id
|
|
}
|
|
|
|
function serveRobots(req, res, conf) {
|
|
var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
|
|
res.end('User-agent: *\n'
|
|
+ (disallow ? 'Disallow: /\n' : ''))
|
|
}
|
|
|
|
function prepend(fn, arg) {
|
|
return function (read) {
|
|
return function (abort, cb) {
|
|
if (fn && !abort) {
|
|
var _fn = fn
|
|
fn = null
|
|
return _fn(arg, function (err, value) {
|
|
if (err) return read(err, function (err) {
|
|
cb(err || true)
|
|
})
|
|
cb(null, value)
|
|
})
|
|
}
|
|
read(abort, cb)
|
|
}
|
|
}
|
|
}
|