feat: #12822, send all outgoing links to /ap first to see if it's activitypub enabled, parse if able, otherwise redirect as normal

This commit is contained in:
Julian Lam
2024-10-23 15:25:16 -04:00
parent 5e47b19488
commit 069c8228e7
5 changed files with 122 additions and 4 deletions

View File

@@ -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();
}
}
}

View File

@@ -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;
};

View File

@@ -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);

View File

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

View File

@@ -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
*/