Files
custo-viewer/index.js
T
travandClaude Opus 5 fc9539a618 /items shows items only, not custody transfers
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
2026-08-22 20:30:46 -04:00

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)
}
}
}