diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js new file mode 100644 index 0000000000..79bfd73603 --- /dev/null +++ b/src/activitypub/notes.js @@ -0,0 +1,82 @@ +'use strict'; + +const winston = require('winston'); + +const db = require('../database'); +const posts = require('../posts'); + +const activitypub = module.parent.exports; +const Notes = module.exports; + +// todo: when asserted, notes aren't added to a global sorted set +// also, db.exists call is probably expensive +Notes.assert = async (uid, input) => { + // Ensures that each note has been saved to the database + await Promise.all(input.map(async (item) => { + const id = activitypub.helpers.isUri(item) ? item : item.id; + const key = `post:${id}`; + const exists = await db.exists(key); + winston.verbose(`[activitypub/notes.assert] Asserting note id ${id}`); + + let postData; + if (!exists) { + winston.verbose(`[activitypub/notes.assert] Not found, saving note to database`); + const object = activitypub.helpers.isUri(item) ? await activitypub.get(uid, item) : item; + postData = await activitypub.mocks.post(object); + if (postData) { + await db.setObject(key, postData); + } + } + })); +}; + +Notes.getParentChain = async (uid, input) => { + // Traverse upwards via `inReplyTo` until you find the root-level Note + const id = activitypub.helpers.isUri(input) ? input : input.id; + + const chain = new Set(); + const traverse = async (uid, id) => { + const exists = await db.exists(`post:${id}`); + if (exists) { + const { toPid, timestamp } = await posts.getPostFields(id, ['toPid', 'timestamp']); + chain.add({ id, timestamp }); + if (toPid) { + await traverse(uid, toPid); + } + } else { + let object = await activitypub.get(uid, id); + object = await activitypub.mocks.post(object); + if (object) { + chain.add({ id, timestamp: object.timestamp }); + if (object.hasOwnProperty('toPid') && object.toPid) { + await traverse(uid, object.toPid); + } + } + } + }; + + await traverse(uid, id); + return chain; +}; + +Notes.assertTopic = async (uid, id) => { + // Given the id of any post, traverses up (and soon, down) to cache the entire threaded context + const chain = Array.from(await Notes.getParentChain(uid, id)); + const tid = chain[chain.length - 1].id; + + const sorted = chain.sort((a, b) => a.timestamp - b.timestamp); + const [ids, timestamps] = [ + sorted.map(n => n.id), + sorted.map(n => n.timestamp), + ]; + + await db.sortedSetAdd(`topicRemote:${tid}`, timestamps, ids); + await Notes.assert(uid, chain); + + return tid; +}; + +Notes.getTopicPosts = async (tid, uid, start, stop) => { + const pids = await db.getSortedSetRange(`topicRemote:${tid}`, start, stop); + return await posts.getPostsByPids(pids, uid); +}; diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 201216b365..41721ac28d 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -10,6 +10,7 @@ const helpers = require('../helpers'); const Controller = module.exports; Controller.profiles = require('./profiles'); +Controller.topics = require('./topics'); Controller.getActor = async (req, res) => { // todo: view:users priv gate diff --git a/src/controllers/activitypub/topics.js b/src/controllers/activitypub/topics.js new file mode 100644 index 0000000000..212844462f --- /dev/null +++ b/src/controllers/activitypub/topics.js @@ -0,0 +1,81 @@ +'use strict'; + +const user = require('../../user'); +const topics = require('../../topics'); + +const { notes } = require('../../activitypub'); +// const helpers = require('../helpers'); +// const pagination = require('../../pagination'); + +const controller = module.exports; + +controller.get = async function (req, res, next) { + const tid = await notes.assertTopic(req.uid, req.query.resource); + + let postIndex = parseInt(req.params.post_index, 10) || 1; + const [ + // userPrivileges, + settings, + // topicData, + ] = await Promise.all([ + // privileges.topics.get(tid, req.uid), + user.getSettings(req.uid), + // topics.getTopicData(tid), + ]); + + const topicData = { + tid, + postCount: 6, + category: {}, // todo + }; + + let currentPage = parseInt(req.query.page, 10) || 1; + const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); + const invalidPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount)); + if ( + !topicData || + // userPrivileges.disabled || + invalidPagination// || + // (topicData.scheduled && !userPrivileges.view_scheduled) + ) { + return next(); + } + + if (!req.query.page) { + currentPage = calculatePageFromIndex(postIndex, settings); + } + if (settings.usePagination && req.query.page) { + const top = ((currentPage - 1) * settings.postsPerPage) + 1; + const bottom = top + settings.postsPerPage; + if (!req.params.post_index || (postIndex < top || postIndex > bottom)) { + postIndex = top; + } + } + const { start, stop } = calculateStartStop(currentPage, postIndex, settings); + + topicData.posts = await notes.getTopicPosts(tid, req.uid, start, stop); + topicData.posts = await topics.addPostData(topicData.posts, req.uid); + + res.render('topic', topicData); +}; + +// todo: expose from topic controller? +function calculatePageFromIndex(postIndex, settings) { + return 1 + Math.floor((postIndex - 1) / settings.postsPerPage); +} + +// todo: expose from topic controller? +function calculateStartStop(page, postIndex, settings) { + let startSkip = 0; + + if (!settings.usePagination) { + if (postIndex > 1) { + page = 1; + } + startSkip = Math.max(0, postIndex - Math.ceil(settings.postsPerPage / 2)); + } + + const start = ((page - 1) * settings.postsPerPage) + startSkip; + const stop = start + settings.postsPerPage - 1; + return { start: Math.max(0, start), stop: Math.max(0, stop) }; +} diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 16e1cad3a7..02544c7409 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -9,11 +9,14 @@ const topics = require('../topics'); const categories = require('../categories'); const posts = require('../posts'); const privileges = require('../privileges'); +const activitypub = require('../activitypub'); const helpers = require('./helpers'); const pagination = require('../pagination'); const utils = require('../utils'); const analytics = require('../analytics'); +const activitypubController = require('./activitypub'); + const topicsController = module.exports; const url = nconf.get('url'); @@ -21,6 +24,10 @@ const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); topicsController.get = async function getTopic(req, res, next) { + if (req.params.topic_id === 'remote' && activitypub.helpers.isUri(req.query.resource)) { + return activitypubController.topics.get(req, res, next); + } + const tid = req.params.topic_id; if ( (req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') || diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js index 8737d7baca..1c0e0a672d 100644 --- a/src/routes/activitypub.js +++ b/src/routes/activitypub.js @@ -1,5 +1,9 @@ 'use strict'; +/** + * These controllers only respond if the sender is making an json+activitypub style call (i.e. S2S-only) + */ + module.exports = function (app, middleware, controllers) { const middlewares = [middleware.proceedOnActivityPub, middleware.exposeUid];