feat: #13255, proper handling of upvotes shared by group actors

fixes #13320
This commit is contained in:
Julian Lam
2025-04-23 12:47:16 -04:00
parent 5c5fd3d44f
commit 74e32a170f
3 changed files with 252 additions and 55 deletions

View File

@@ -240,11 +240,11 @@ inbox.like = async (req) => {
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
return reject('Like', object, actor);
}
winston.verbose(`[activitypub/inbox/like] id ${id} via ${actor}`);
activitypub.helpers.log(`[activitypub/inbox/like] id ${id} via ${actor}`);
const result = await posts.upvote(id, actor);
activitypub.feps.announce(object.id, req.body);
@@ -253,6 +253,7 @@ inbox.like = async (req) => {
inbox.announce = async (req) => {
const { actor, object, published, to, cc } = req.body;
activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`);
let timestamp = new Date(published);
timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now();
@@ -270,53 +271,70 @@ inbox.announce = async (req) => {
cid = Array.from(cids)[0];
}
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
throw new Error('[[error:activitypub.invalid-id]]');
switch(true) {
case object.type === 'Like': {
const id = object.object.id || object.object;
const { id: localId } = await activitypub.helpers.resolveLocalId(id);
const exists = await posts.exists(localId || id);
if (exists) {
const result = await posts.upvote(localId || id, object.actor);
if (localId) {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
}
}
break;
}
pid = id;
tid = await posts.getPostField(id, 'tid');
case activitypub._constants.acceptedPostTypes.includes(object.type): {
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
reject('Announce', object, actor);
return;
}
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
pid = id;
tid = await posts.getPostField(id, 'tid');
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
}
}
// Handle case where Announce(Create(Note-ish)) is received
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
pid = object.object.id;
} else {
pid = object.id;
}
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
const assertion = await activitypub.notes.assert(0, pid, { cid, skipChecks: true });
if (!assertion) {
return;
}
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
}
// Handle case where Announce(Create(Note-ish)) is received
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
pid = object.object.id;
} else {
pid = object.id;
}
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
const assertion = await activitypub.notes.assert(0, pid, { cid });
if (!assertion) {
return;
}
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
winston.verbose(`[activitypub/inbox/announce] Parsing id ${pid}`);
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
};

View File

@@ -8,10 +8,11 @@ const Helpers = module.exports;
Helpers.mocks = {};
Helpers.mocks._baseUrl = 'https://example.org';
Helpers.mocks.person = (override = {}) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
let id = `${baseUrl}/${uuid}`;
let id = `${Helpers.mocks._baseUrl}/${uuid}`;
if (override.hasOwnProperty('id')) {
id = override.id;
}
@@ -66,9 +67,8 @@ Helpers.mocks.group = (override = {}) => {
};
Helpers.mocks.note = (override = {}) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/object/${uuid}`;
const id = `${Helpers.mocks._baseUrl}/object/${uuid}`;
const note = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
@@ -97,9 +97,8 @@ Helpers.mocks.note = (override = {}) => {
Helpers.mocks.create = (object) => {
// object is optional, will generate a public note if undefined
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/activity/${uuid}`;
const id = `${Helpers.mocks._baseUrl}/activity/${uuid}`;
object = object || Helpers.mocks.note().note;
const activity = {
@@ -118,9 +117,8 @@ Helpers.mocks.create = (object) => {
};
Helpers.mocks.accept = (actor, object) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/activity/${uuid}`;
const id = `${Helpers.mocks._baseUrl}/activity/${uuid}`;
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
@@ -132,4 +130,49 @@ Helpers.mocks.accept = (actor, object) => {
};
return { activity };
}
};
Helpers.mocks.like = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`,
type: 'Like',
actor,
object,
};
return { activity };
};
Helpers.mocks.announce = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/announce/${encodeURIComponent(object.id || object)}`,
type: 'Announce',
to: [activitypub._constants.publicAddress],
cc: [`${actor}/followers`],
actor,
object,
};
return { activity };
};

View File

@@ -208,7 +208,7 @@ describe('Notes', () => {
const unread = await topics.getTotalUnread(uid);
assert.strictEqual(unread, 0);
})
});
});
});
@@ -328,7 +328,7 @@ describe('Notes', () => {
it('should federate out an activity with object of type "Note"', () => {
assert(activity.object && activity.object.type);
assert.strictEqual(activity.object.type, 'Note');
})
});
});
});
@@ -411,6 +411,142 @@ describe('Notes', () => {
});
});
describe.only('Inbox handling', () => {
describe('helper self-check', () => {
it('should generate a Like activity', () => {
const object = utils.generateUUID();
const { id: actor } = helpers.mocks.person();
const { activity } = helpers.mocks.like({
object,
actor,
});
assert.deepStrictEqual(activity, {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${helpers.mocks._baseUrl}/like/${encodeURIComponent(object)}`,
type: 'Like',
actor,
object,
});
});
it('should generate an Announce activity wrapping a Like activity', () => {
const object = utils.generateUUID();
const { id: actor } = helpers.mocks.person();
const { activity: like } = helpers.mocks.like({
object,
actor,
});
const { id: gActor } = helpers.mocks.group();
const { activity } = helpers.mocks.announce({
actor: gActor,
object: like,
});
assert.deepStrictEqual(activity, {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${helpers.mocks._baseUrl}/announce/${encodeURIComponent(like.id)}`,
type: 'Announce',
to: [ 'https://www.w3.org/ns/activitystreams#Public' ],
cc: [
`${gActor}/followers`,
],
actor: gActor,
object: like,
});
});
});
describe('Announce', () => {
let cid;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
});
describe('(Note)', () => {
it('should create a new topic in cid -1 if category not addressed', async () => {
const { note } = helpers.mocks.note();
await activitypub.actors.assert([note.attributedTo]);
const { activity } = helpers.mocks.announce({
object: note,
});
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid);
const beforeCount = await db.sortedSetCard(`cid:-1:tids`);
await activitypub.inbox.announce({ body: activity });
const count = await db.sortedSetCard(`cid:-1:tids`);
assert.strictEqual(count, beforeCount + 1);
});
it('should create a new topic in local category', async () => {
const { note } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
});
await activitypub.actors.assert([note.attributedTo]);
const { activity } = helpers.mocks.announce({
object: note,
});
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await db.sortedSetAdd(`followersRemote:${activity.actor}`, Date.now(), uid);
const beforeCount = await db.sortedSetCard(`cid:${cid}:tids`);
await activitypub.inbox.announce({ body: activity });
const count = await db.sortedSetCard(`cid:${cid}:tids`);
assert.strictEqual(count, beforeCount + 1);
});
});
describe('(Like)', () => {
it('should upvote a local post', async () => {
const uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
const { postData } = await topics.post({
cid,
uid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
const { activity: like } = helpers.mocks.like({
object: `${nconf.get('url')}/post/${postData.pid}`,
});
const { activity } = helpers.mocks.announce({
object: like,
});
let { upvotes } = await posts.getPostFields(postData.pid, 'upvotes');
assert.strictEqual(upvotes, 0);
await activitypub.inbox.announce({ body: activity });
({ upvotes } = await posts.getPostFields(postData.pid, 'upvotes'));
assert.strictEqual(upvotes, 1);
});
it('should upvote an asserted remote post', async () => {
const { id } = helpers.mocks.note();
await activitypub.notes.assert(0, [id], { skipChecks: true });
const { activity: like } = helpers.mocks.like({
object: id,
});
const { activity } = helpers.mocks.announce({
object: like,
});
let { upvotes } = await posts.getPostFields(id, 'upvotes');
assert.strictEqual(upvotes, 0);
await activitypub.inbox.announce({ body: activity });
({ upvotes } = await posts.getPostFields(id, 'upvotes'));
assert.strictEqual(upvotes, 1);
});
});
});
});
describe('Inbox Synchronization', () => {
let cid;
let uid;