diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index 0ff80ad70a..5abfd4958d 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -609,6 +609,9 @@ $(document).ready(function () { ajaxify.go('outgoing?url=' + encodeURIComponent(href)); e.preventDefault(); } + } else { + ajaxify.go(`ap?resource=${encodeURIComponent(this.href)}`); + e.preventDefault(); } } } diff --git a/src/activitypub/index.js b/src/activitypub/index.js index d287f99e1d..59357b7e91 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -22,6 +22,10 @@ const requestCache = ttl({ max: 5000, ttl: 1000 * 60 * 5, // 5 minutes }); +const probeCache = ttl({ + max: 500, + ttl: 1000 * 60 * 60, // 1 hour +}); const ActivityPub = module.exports; @@ -443,3 +447,72 @@ ActivityPub.buildRecipients = async function (object, { pid, uid, cid }) { targets, }; }; + +ActivityPub.probe = async ({ uid, url }) => { + /** + * Checks whether a passed-in id or URL is an ActivityPub object and can be mapped to a local representation + * - `uid` is optional (links to private messages won't match without uid) + * - Returns a relative path if already available, true if not, and false otherwise. + */ + + // Known resources + const [isNote, isMessage, isActor] = await Promise.all([ + posts.exists(url), + messaging.messageExists(url), + db.isObjectField('remoteUrl:uid', url), + ]); + switch (true) { + case isNote: { + return `/post/${encodeURIComponent(url)}`; + } + + case isMessage: { + if (uid) { + const { roomId } = await messaging.getMessageFields(url, ['roomId']); + const canView = await messaging.canViewMessage(url, roomId, uid); + if (canView) { + return `/message/${encodeURIComponent(url)}`; + } + } + break; + } + + case isActor: { + const uid = await db.getObjectField('remoteUrl:uid', url); + const slug = await user.getUserField(uid, 'userslug'); + return `/user/${slug}`; + } + } + + // Cached result + if (probeCache.has(url)) { + return probeCache.get(url); + } + + // Opportunistic HEAD + const { response } = await request.head(url); + try { + const { headers } = response; + if (headers && headers.link) { + let parts = headers.link.split(';'); + parts.shift(); + parts = parts + .map(p => p.trim()) + .reduce((memo, cur) => { + cur = cur.split('='); + memo[cur[0]] = cur[1].slice(1, -1); + return memo; + }, {}); + + if (parts.rel === 'alternate' && parts.type === 'application/activity+json') { + probeCache.set(url, true); + return true; + } + } + } catch (e) { + // ... + } + + probeCache.set(url, false); + return false; +}; diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index a700f9ca48..7077e88ad6 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -12,6 +12,48 @@ const Controller = module.exports; Controller.actors = require('./actors'); Controller.topics = require('./topics'); +Controller.fetch = async (req, res, next) => { + // Given a `resource` query parameter, attempts to retrieve and parse it + if (!req.query.resource) { + return next(); + } + + let url; + try { + url = new URL(req.query.resource); + const result = await activitypub.probe({ + uid: req.uid, + url: url.href, + }); + + if (typeof result === 'string') { + return helpers.redirect(res, result); + } else if (result) { + const { id, type } = await activitypub.get('uid', req.uid || 0, url.href); + switch (true) { + case activitypub._constants.acceptedPostTypes.includes(type): { + return helpers.redirect(res, `/post/${encodeURIComponent(id)}`); + } + + case activitypub._constants.acceptableActorTypes.has(type): { + await activitypub.actors.assert(id); + const userslug = await user.getUserField(id, 'userslug'); + return helpers.redirect(res, `/user/${userslug}`); + } + + default: + return next(); + // return helpers.redirect(res, result); + } + } + + helpers.redirect(res, url.href, false); + } catch (e) { + activitypub.helpers.log(`[activitypub/fetch] Invalid URL received: ${url}`); + return next(); + } +}; + Controller.getFollowing = async (req, res) => { const { followingCount, followingRemoteCount } = await user.getUserFields(req.params.uid, ['followingCount', 'followingRemoteCount']); const totalItems = parseInt(followingCount || 0, 10) + parseInt(followingRemoteCount || 0, 10); diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js index c17e701b79..6f3f63b9f3 100644 --- a/src/controllers/helpers.js +++ b/src/controllers/helpers.js @@ -166,7 +166,7 @@ helpers.redirect = function (res, url, permanent) { // this is used by sso plugins to redirect to the auth route // { external: '/auth/sso' } or { external: 'https://domain/auth/sso' } if (url.hasOwnProperty('external')) { - const redirectUrl = encodeURI(prependRelativePath(url.external)); + const redirectUrl = prependRelativePath(url.external); if (res.locals.isAPI) { res.set('X-Redirect', redirectUrl).status(200).json({ external: redirectUrl }); } else { @@ -176,10 +176,9 @@ helpers.redirect = function (res, url, permanent) { } if (res.locals.isAPI) { - url = encodeURI(url); res.set('X-Redirect', url).status(200).json(url); } else { - res.redirect(permanent ? 308 : 307, encodeURI(prependRelativePath(url))); + res.redirect(permanent ? 308 : 307, prependRelativePath(url)); } }; diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js index dadff0bdf4..8993cb8875 100644 --- a/src/routes/activitypub.js +++ b/src/routes/activitypub.js @@ -4,9 +4,10 @@ const helpers = require('./helpers'); module.exports = function (app, middleware, controllers) { helpers.setupPageRoute(app, '/world', [middleware.activitypub.enabled], controllers.activitypub.topics.list); + helpers.setupPageRoute(app, '/ap', [middleware.activitypub.enabled], controllers.activitypub.fetch); /** - * These controllers only respond if the sender is making an json+activitypub style call (i.e. S2S-only) + * The following controllers only respond if the sender is making an json+activitypub style call (i.e. S2S-only) * * - See middleware.activitypub.assertS2S */