From c09edecc343884571c9bce9f5387f2b6ce4f8f07 Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Thu, 23 Feb 2023 14:27:07 +0000 Subject: [PATCH] support direct visiblity and reject others Refs https://github.com/cloudflare/wildebeest/issues/303 --- backend/src/activitypub/objects/note.ts | 6 +- backend/test/activitypub.spec.ts | 6 +- backend/test/mastodon/statuses.spec.ts | 126 ++++++++++++++++++++++++ backend/test/mastodon/timelines.spec.ts | 6 +- functions/api/v1/statuses.ts | 33 ++++++- 5 files changed, 165 insertions(+), 12 deletions(-) diff --git a/backend/src/activitypub/objects/note.ts b/backend/src/activitypub/objects/note.ts index 75f418d..02ebdf6 100644 --- a/backend/src/activitypub/objects/note.ts +++ b/backend/src/activitypub/objects/note.ts @@ -52,12 +52,12 @@ export async function createPublicNote( return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note } -export async function createPrivateNote( +export async function createDirectNote( domain: string, db: Database, content: string, actor: Actor, - targetActor: Actor, + targetActors: Array, attachment: Array = [], extraProperties: any = {} ): Promise { @@ -66,7 +66,7 @@ export async function createPrivateNote( const properties = { attributedTo: actorId, content, - to: [targetActor.id.toString()], + to: targetActors.map((a) => a.id.toString()), cc: [], // FIXME: stub values diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts index 9162a86..435c6d4 100644 --- a/backend/test/activitypub.spec.ts +++ b/backend/test/activitypub.spec.ts @@ -3,7 +3,7 @@ import { MessageType } from 'wildebeest/backend/src/types/queue' import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' import * as actors from 'wildebeest/backend/src/activitypub/actors' -import { createPrivateNote, createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { createDirectNote, createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' import { strict as assert } from 'node:assert/strict' import { cacheObject } from 'wildebeest/backend/src/activitypub/objects/' @@ -146,7 +146,7 @@ describe('ActivityPub', () => { const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - const note = await createPrivateNote(domain, db, 'DM', actorA, actorB) + const note = await createDirectNote(domain, db, 'DM', actorA, [actorB]) await addObjectInOutbox(db, actorA, note, undefined, actorB.id.toString()) { @@ -171,7 +171,7 @@ describe('ActivityPub', () => { const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'target@cloudflare.com') - const note = await createPrivateNote(domain, db, 'DM', actorA, actorB) + const note = await createDirectNote(domain, db, 'DM', actorA, [actorB]) await addObjectInOutbox(db, actorA, note) const res = await ap_outbox_page.handleRequest(domain, db, 'target') diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index d260292..d81c05f 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -18,6 +18,7 @@ import { MessageType } from 'wildebeest/backend/src/types/queue' import { MastodonStatus } from 'wildebeest/backend/src/types' import { mastodonIdSymbol, getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import * as timelines from 'wildebeest/backend/src/mastodon/timeline' const userKEK = 'test_kek4' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -1036,5 +1037,130 @@ describe('Mastodon APIs', () => { assert.equal(res.status, 422) assertJSON(res) }) + + test('create status with direct visibility', async () => { + const db = await makeDB() + const queue = makeQueue() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor1 = await createPerson(domain, db, userKEK, 'actor1@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'actor2@cloudflare.com') + + let deliveredActivity1: any = null + let deliveredActivity2: any = null + + globalThis.fetch = async (input: RequestInfo | Request) => { + if ( + input.toString() === 'https://cloudflare.com/.well-known/webfinger?resource=acct%3Aactor1%40cloudflare.com' + ) { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: actor1.id, + }, + ], + }) + ) + } + if ( + input.toString() === 'https://cloudflare.com/.well-known/webfinger?resource=acct%3Aactor2%40cloudflare.com' + ) { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: actor2.id, + }, + ], + }) + ) + } + + // @ts-ignore + if (input.url === actor1.inbox.toString()) { + deliveredActivity1 = await (input as Request).json() + return new Response() + } + // @ts-ignore + if (input.url === actor2.inbox.toString()) { + deliveredActivity2 = await (input as Request).json() + return new Response() + } + + throw new Error('unexpected request to ' + input) + } + + const body = { + status: '@actor1 @actor2 hey', + visibility: 'direct', + } + const req = new Request('https://' + domain, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) + assert.equal(res.status, 200) + + assert(deliveredActivity1) + assert(deliveredActivity2) + delete deliveredActivity1.id + delete deliveredActivity2.id + + assert.deepEqual(deliveredActivity1, deliveredActivity2) + assert.equal(deliveredActivity1.to.length, 2) + assert.equal(deliveredActivity1.to[0], actor1.id.toString()) + assert.equal(deliveredActivity1.to[1], actor2.id.toString()) + assert.equal(deliveredActivity1.cc.length, 0) + + // ensure that the private note doesn't show up in public timeline + const timeline = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet) + assert.equal(timeline.length, 0) + }) + + test('create status with unlisted visibility', async () => { + const db = await makeDB() + const queue = makeQueue() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: 'something nice', + visibility: 'unlisted', + } + const req = new Request('https://' + domain, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) + assert.equal(res.status, 422) + assertJSON(res) + }) + + test('create status with private visibility', async () => { + const db = await makeDB() + const queue = makeQueue() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: 'something nice', + visibility: 'private', + } + const req = new Request('https://' + domain, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) + assert.equal(res.status, 422) + assertJSON(res) + }) }) }) diff --git a/backend/test/mastodon/timelines.spec.ts b/backend/test/mastodon/timelines.spec.ts index 56729db..fb789ea 100644 --- a/backend/test/mastodon/timelines.spec.ts +++ b/backend/test/mastodon/timelines.spec.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert/strict' import { createReply } from 'wildebeest/backend/test/shared.utils' import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' -import { createPublicNote, createPrivateNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { createPublicNote, createDirectNote } from 'wildebeest/backend/src/activitypub/objects/note' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' import { makeDB, assertCORS, assertJSON, makeCache } from '../utils' @@ -67,7 +67,7 @@ describe('Mastodon APIs', () => { await acceptFollowing(db, actor3, actor2) // actor2 sends a DM to actor1 - const note = await createPrivateNote(domain, db, 'DM', actor2, actor1) + const note = await createDirectNote(domain, db, 'DM', actor2, [actor1]) await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString()) // actor3 shouldn't see the private note @@ -100,7 +100,7 @@ describe('Mastodon APIs', () => { const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') // actor2 sends a DM to actor1 - const note = await createPrivateNote(domain, db, 'DM', actor2, actor1) + const note = await createDirectNote(domain, db, 'DM', actor2, [actor1]) await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString()) const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet) diff --git a/functions/api/v1/statuses.ts b/functions/api/v1/statuses.ts index a0f1f9e..3b7ba94 100644 --- a/functions/api/v1/statuses.ts +++ b/functions/api/v1/statuses.ts @@ -8,7 +8,7 @@ import * as timeline from 'wildebeest/backend/src/mastodon/timeline' import type { Queue, DeliverMessageBody } from 'wildebeest/backend/src/types/queue' import type { Document } from 'wildebeest/backend/src/activitypub/objects' import { getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' -import { createStatus, getMentions } from 'wildebeest/backend/src/mastodon/status' +import { getMentions } from 'wildebeest/backend/src/mastodon/status' import { getHashtags, insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag' import * as activities from 'wildebeest/backend/src/activitypub/activities/create' import type { Env } from 'wildebeest/backend/src/types/env' @@ -27,6 +27,8 @@ import * as idempotency from 'wildebeest/backend/src/mastodon/idempotency' import { newMention } from 'wildebeest/backend/src/activitypub/objects/mention' import { originalObjectIdSymbol } from 'wildebeest/backend/src/activitypub/objects' import { type Database, getDatabase } from 'wildebeest/backend/src/database' +import { createPublicNote, createDirectNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' type StatusCreate = { status: string @@ -118,7 +120,16 @@ export async function handleRequest( } const content = enrichStatus(body.status, mentions) - const note = await createStatus(domain, db, connectedActor, content, mediaAttachments, extraProperties) + + let note + + if (body.visibility === 'public') { + note = await createPublicNote(domain, db, content, connectedActor, mediaAttachments, extraProperties) + } else if (body.visibility === 'direct') { + note = await createDirectNote(domain, db, content, connectedActor, mentions, mediaAttachments, extraProperties) + } else { + return errors.validationError(`status with visibility: ${body.visibility}`) + } if (hashtags.length > 0) { await insertHashtags(db, note, hashtags) @@ -132,11 +143,27 @@ export async function handleRequest( const activity = activities.create(domain, connectedActor, note) await deliverFollowers(db, userKEK, connectedActor, activity, queue) + if (body.visibility === 'public') { + await addObjectInOutbox(db, connectedActor, note) + + // A public note is sent to the public group URL and cc'ed any mentioned + // actors. + for (let i = 0, len = mentions.length; i < len; i++) { + const targetActor = mentions[i] + note.cc.push(targetActor.id.toString()) + } + } else if (body.visibility === 'direct') { + // A direct note is sent to mentioned people only + for (let i = 0, len = mentions.length; i < len; i++) { + const targetActor = mentions[i] + await addObjectInOutbox(db, connectedActor, note, undefined, targetActor.id.toString()) + } + } + { // If the status is mentioning other persons, we need to delivery it to them. for (let i = 0, len = mentions.length; i < len; i++) { const targetActor = mentions[i] - note.cc.push(targetActor.id.toString()) const activity = activities.create(domain, connectedActor, note) const signingKey = await getSigningKey(userKEK, db, connectedActor) await deliverToActor(signingKey, connectedActor, targetActor, activity, domain)