diff --git a/.gitignore b/.gitignore index c2658d7..58ddee7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +.deployed-commit diff --git a/index.js b/index.js index 4a93f77..d802e61 100644 --- a/index.js +++ b/index.js @@ -24,7 +24,10 @@ var { renderShowAll, renderRssItem, wrapRss, + renderItemGrid, } = require('./render') +var getFollows = require('./lib/follows') +var createItemsHandler = require('./items') var appHash = hash([fs.readFileSync(__filename)]) @@ -112,6 +115,18 @@ exports.init = function (sbot, config) { var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot) var serveAcmeChallenge = require('ssb-acme-validator')(sbot) + var serveItems = createItemsHandler({ + sbot: sbot, + defaultOpts: defaultOpts, + getMsg: getMsg, + getAbout: getAbout, + 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) @@ -127,6 +142,7 @@ exports.init = function (sbot, config) { 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) @@ -146,6 +162,23 @@ exports.init = function (sbot, config) { 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) @@ -226,40 +259,12 @@ exports.init = function (sbot, config) { var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100) console.log('serving user feed: ' + feedId) - var following = [] - var channelSubscriptions = [] - getAbout(feedId, function (err, about) { - pull( - sbot.createUserStream({ id: feedId }), - pull.filter((msg) => { - return !msg.value || - msg.value.content.type == 'contact' || - (msg.value.content.type == 'channel' && - typeof msg.value.content.subscribed != 'undefined') - }), - pull.collect(function (err, msgs) { - msgs.forEach((msg) => { - if (msg.value.content.type == 'contact') - { - if (msg.value.content.following) - following[msg.value.content.contact] = 1 - else - delete following[msg.value.content.contact] - } - else // channel subscription - { - if (msg.value.content.subscribed) - channelSubscriptions[msg.value.content.channel] = 1 - else - delete channelSubscriptions[msg.value.content.channel] - } - }) - - serveFeeds(req, res, following, channelSubscriptions, feedId, - 'user feed ' + (about ? about.name : '')) - }) - ) + 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 : '')) + }) }) } @@ -613,32 +618,6 @@ function asLink(id) { if (refs.isLink(id)) return id } -function serveHome(req, res, query, conf) { - 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() - } - res.writeHead(200, { - 'Content-Type': 'text/html' - }) - pull( - pull.once(h('form', {method: 'get', action: ''}, - h('input', {name: 'id', placeholder: 'id', size: 60, value: q.id || ''}), ' ', - h('input', {type: 'submit', value: 'Go'}) - ).outerHTML), - wrapPage('ssb-viewer'), - toPull(res, function (err) { - if (err) console.error('[viewer]', err) - }) - ) -} - function serveRobots(req, res, conf) { var disallow = conf.disallowRobots == null ? true : conf.disallowRobots res.end('User-agent: *\n' diff --git a/items.js b/items.js new file mode 100644 index 0000000..0344eea --- /dev/null +++ b/items.js @@ -0,0 +1,175 @@ +var pull = require('pull-stream') +var paramap = require('pull-paramap') +var qs = require('querystring') +var getFollows = require('./lib/follows') + +// One merged view of every custo item the pub knows about. +// +// The selection rule is deliberately "whatever this pub follows", not a list of +// kiosk feed ids: adding a kiosk means following it from the pub, and its items +// appear here on the next cache miss. No code change, no edit to cust.ooo. +// +// Two facts about the data that this file depends on, both verified against the +// live log and both easy to get wrong: +// * `custodisco` is the STRING "true", not a boolean. +// * no custo message has ever set `content.channel`, so the channel index is +// useless here and the filter has to look at content fields directly. + +var CACHE_MS = 60 * 1000 +var PAGE_SIZE = 60 + +var IMAGE_MD = /!\[[^\]]*\]\((&[A-Za-z0-9+/]{43}=\.sha256)\)/ + +// The item's photo: a mints's first image mention, falling back to the markdown +// embed in the text for messages whose mentions array is missing or unhelpful. +function itemPhoto (c) { + if (c && Array.isArray(c.mentions)) { + for (var i = 0; i < c.mentions.length; i++) { + var m = c.mentions[i] + if (!m || typeof m.link !== 'string' || m.link[0] !== '&') continue + if (typeof m.type === 'string' && !/^image\//.test(m.type)) continue + return m.link + } + } + var md = IMAGE_MD.exec(String((c && c.text) || '')) + return md ? md[1] : null +} + +// Strip the parts of the post text that are furniture rather than description: +// the image embed itself, and the kiosk's " a #custodisco item " sign-off. +function itemCaption (c) { + return String((c && c.text) || '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\s*a\s+#custodisco\s+item\s*$/i, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function isCustodisco (msg) { + var c = msg && msg.value && msg.value.content + return !!c && typeof c === 'object' && c.custodisco === 'true' +} + +// ctx supplies the pieces that live in index.js's init closure: +// sbot, defaultOpts, getMsg, getAbout, addAuthorAbout, respond, toPull, +// renderItemGrid, wrapPage +module.exports = function createItemsHandler (ctx) { + var cache = null // { at, msgs, feeds } + + // A full log scan takes well under a second on this node (11k messages, + // 360 KB), so this cache exists to keep a page refresh from redoing it, not + // because the scan is expensive. + function load (cb) { + if (cache && Date.now() - cache.at < CACHE_MS) return cb(null, cache) + + ctx.sbot.whoami(function (err, feed) { + if (err) return cb(err) + var me = feed.id + getFollows(ctx.sbot, me, function (err, sets) { + if (err) return cb(err) + var authors = sets.following + authors[me] = true // the pub's own items count too + + pull( + ctx.sbot.createLogStream({ reverse: true }), + pull.filter(function (msg) { + return isCustodisco(msg) && authors[msg.value.author] === true + }), + pull.collect(function (err, msgs) { + if (err) return cb(err) + // Log order is arrival order, which diverges from publish order + // whenever an old feed is backfilled. Sort so "newest first" and + // the ?before cursor both mean the same thing. + msgs.sort(function (a, b) { return b.value.timestamp - a.value.timestamp }) + cache = { + at: Date.now(), + msgs: msgs, + feeds: Object.keys(authors).length + } + cb(null, cache) + }) + ) + }) + }) + } + + // A transfer carries no photo of its own, so borrow the parent item's and + // resolve the new steward's display name. Both lookups go through the LRU + // memos index.js already keeps. + function addTransferContext (msg, cb) { + var c = msg.value.content + if (c.nft !== 'give') { + msg.itemPhoto = itemPhoto(c) + msg.itemCaption = itemCaption(c) + return cb(null, msg) + } + + var pending = 2 + function done () { + if (--pending) return + cb(null, msg) + } + + if (c.root) { + ctx.getMsg(c.root, function (err, root) { + var rc = root && root.value && root.value.content + if (rc) { + msg.itemPhoto = itemPhoto(rc) + msg.itemCaption = itemCaption(rc) + } + done() + }) + } else done() + + if (c.target) { + ctx.getAbout(c.target, function (err, about) { + if (about) msg.stewardAbout = about + done() + }) + } else done() + } + + return function serveItems (req, res, query) { + var q = query ? qs.parse(query) : {} + var showAll = 'showAll' in q + var before = q.before ? Number(q.before) : null + + load(function (err, data) { + if (err) return ctx.respond(res, 500, err.stack || err) + + var msgs = data.msgs + if (before) { + msgs = msgs.filter(function (m) { return m.value.timestamp < before }) + } + var page = showAll ? msgs : msgs.slice(0, PAGE_SIZE) + var last = page[page.length - 1] + var older = (!showAll && msgs.length > page.length && last) + ? last.value.timestamp + : null + + res.writeHead(200, { 'Content-Type': 'text/html' }) + pull( + pull.values(page), + paramap(ctx.addAuthorAbout, 8), + paramap(addTransferContext, 8), + pull( + ctx.renderItemGrid(ctx.defaultOpts, { + total: data.msgs.length, + shown: page.length, + feeds: data.feeds, + older: older, + showAll: showAll + }), + ctx.wrapPage('items') + ), + ctx.toPull(res, function (err) { + if (err) console.error('[viewer]', err) + }) + ) + }) + } +} + +module.exports.itemPhoto = itemPhoto +module.exports.itemCaption = itemCaption +module.exports.isCustodisco = isCustodisco diff --git a/lib/follows.js b/lib/follows.js new file mode 100644 index 0000000..2ffee9a --- /dev/null +++ b/lib/follows.js @@ -0,0 +1,38 @@ +var pull = require('pull-stream') + +// Reduce a feed's own contact and channel-subscription messages into the sets +// they describe. createUserStream runs oldest-first, so a later unfollow +// correctly undoes an earlier follow. +// +// Both /user-feed/ and /items are built on this: "who does this feed listen +// to" is the whole selection rule, which is what lets a new kiosk show up by +// being followed rather than by being added to a list somewhere. +module.exports = function getFollows (sbot, feedId, cb) { + var following = Object.create(null) + var channelSubscriptions = Object.create(null) + + pull( + sbot.createUserStream({ id: feedId }), + pull.filter(function (msg) { + var c = msg && msg.value && msg.value.content + if (!c || typeof c !== 'object') return false // also skips private (string) content + return c.type === 'contact' || + (c.type === 'channel' && typeof c.subscribed !== 'undefined') + }), + pull.drain(function (msg) { + var c = msg.value.content + if (c.type === 'contact') { + if (!c.contact) return + if (c.following) following[c.contact] = true + else delete following[c.contact] + } else { + if (!c.channel) return + if (c.subscribed) channelSubscriptions[c.channel] = true + else delete channelSubscriptions[c.channel] + } + }, function (err) { + if (err) return cb(err) + cb(null, { following: following, channelSubscriptions: channelSubscriptions }) + }) + ) +} diff --git a/render.js b/render.js index ff1ca8a..1bf4a3e 100644 --- a/render.js +++ b/render.js @@ -17,6 +17,7 @@ exports.MdRenderer = MdRenderer exports.renderEmoji = renderEmoji exports.formatMsgs = formatMsgs exports.renderThread = renderThread +exports.renderItemGrid = renderItemGrid exports.renderAbout = renderAbout exports.renderShowAll = renderShowAll exports.renderRssItem = renderRssItem @@ -172,10 +173,21 @@ function renderRssItem(opts) { ) } -const gitHead = proc.spawnSync('git', ['rev-parse', 'HEAD'], { - encoding: 'utf8', - cwd: __dirname -}).stdout.trim() +// Which commit is actually running. Deploys are rsync, not `git pull`, so the +// checkout on the server keeps whatever HEAD it was cloned at and `git +// rev-parse` there would name a commit that has nothing to do with these files. +// bin/deploy-viewer.sh writes the real one to .deployed-commit; fall back to +// git only when running straight from a working copy. +const gitHead = (function () { + try { + var stamped = fs.readFileSync(path.join(__dirname, '.deployed-commit'), 'utf8').trim() + if (stamped) return stamped + } catch (e) { /* not a deploy, ask git */ } + return proc.spawnSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + cwd: __dirname + }).stdout.trim() +}()) const gitHeadShort = gitHead && gitHead.substr(0, 7) const commitUrl = pkg.homepage && pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead @@ -192,7 +204,7 @@ function wrapPage(id) { "
" + "" + "