MOW-101: hide DM from public

pull/110/head
Sven Sauleau 2023-01-16 14:16:54 +00:00
rodzic 5ce31c4d75
commit 196d6fa896
11 zmienionych plików z 301 dodań i 150 usunięć

Wyświetl plik

@ -1,4 +1,5 @@
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
@ -131,11 +132,17 @@ export async function handle(
// FIXME: download any attachment Objects
let recipients: Array<string> = []
let target = PUBLIC_GROUP
if (Array.isArray(activity.to)) {
if (Array.isArray(activity.to) && activity.to.length > 0) {
recipients = [...recipients, ...activity.to]
if (activity.to.length !== 1) {
console.warn("multiple `Activity.to` isn't supported")
}
target = activity.to[0]
}
if (Array.isArray(activity.cc)) {
if (Array.isArray(activity.cc) && activity.cc.length > 0) {
recipients = [...recipients, ...activity.cc]
}
@ -172,7 +179,7 @@ export async function handle(
const fromActor = await actors.getAndCache(getActorAsId(), db)
// Add the object in the originating actor's outbox, allowing other
// actors on this instance to see the note in their timelines.
await addObjectInOutbox(db, fromActor, obj, activity.published)
await addObjectInOutbox(db, fromActor, obj, activity.published, target)
for (let i = 0, len = recipients.length; i < len; i++) {
const handle = parseHandle(extractID(domain, recipients[i]))

Wyświetl plik

@ -1,5 +1,7 @@
export type Activity = any
export const PUBLIC_GROUP = 'https://www.w3.org/ns/activitystreams#Public'
// Generate a unique ID. Note that currently the generated URL aren't routable.
export function uri(domain: string): URL {
const id = crypto.randomUUID()

Wyświetl plik

@ -2,20 +2,27 @@ import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
export async function addObjectInOutbox(db: D1Database, actor: Actor, obj: Object, published_date?: string) {
export async function addObjectInOutbox(
db: D1Database,
actor: Actor,
obj: Object,
published_date?: string,
target: string = PUBLIC_GROUP
) {
const id = crypto.randomUUID()
let out: any = null
if (published_date !== undefined) {
out = await db
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date) VALUES(?, ?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString(), published_date)
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date, target) VALUES(?, ?, ?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString(), published_date, target)
.run()
} else {
out = await db
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString())
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, target) VALUES(?, ?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString(), target)
.run()
}
if (!out.success) {

Wyświetl plik

@ -3,10 +3,10 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
import { followersURL } from 'wildebeest/backend/src/activitypub/actors'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
import * as objects from '.'
const NOTE = 'Note'
export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
export interface Note extends objects.Object {
@ -34,7 +34,7 @@ export async function createPublicNote(
const properties = {
attributedTo: actorId,
content,
to: [PUBLIC],
to: [PUBLIC_GROUP],
cc: [followersURL(actorId)],
// FIXME: stub values
@ -50,3 +50,34 @@ export async function createPublicNote(
return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note
}
export async function createPrivateNote(
domain: string,
db: D1Database,
content: string,
actor: Actor,
targetActor: Actor,
attachment: Array<Document> = [],
extraProperties: any = {}
): Promise<Note> {
const actorId = new URL(actor.id)
const properties = {
attributedTo: actorId,
content,
to: [targetActor.id.toString()],
cc: [],
// FIXME: stub values
inReplyTo: null,
replies: null,
sensitive: false,
summary: null,
tag: [],
attachment,
...extraProperties,
}
return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note
}

Wyświetl plik

@ -2,6 +2,7 @@ import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
import { getFollowingId } from 'wildebeest/backend/src/mastodon/follow'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors/'
import { toMastodonStatusFromRow } from './status'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
export async function pregenerateTimelines(domain: string, db: D1Database, cache: KVNamespace, actor: Actor) {
const timeline = await getHomeTimeline(domain, db, actor)
@ -31,6 +32,7 @@ WHERE
objects.type = 'Note'
AND outbox_objects.actor_id IN (SELECT value FROM json_each(?))
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
AND outbox_objects.target = '${PUBLIC_GROUP}'
ORDER by outbox_objects.published_date DESC
LIMIT ?
`
@ -97,6 +99,7 @@ INNER JOIN actors ON actors.id=outbox_objects.actor_id
WHERE objects.type='Note'
AND ${localPreferenceQuery(localPreference)}
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
AND outbox_objects.target = '${PUBLIC_GROUP}'
ORDER by outbox_objects.published_date DESC
LIMIT ?1 OFFSET ?2
`

Wyświetl plik

@ -2,7 +2,7 @@ import { makeDB, isUrlValid } from './utils'
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 { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { createPublicNote, createPrivateNote } 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/'
@ -74,7 +74,7 @@ describe('ActivityPub', () => {
await sleep(10)
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor))
const res = await ap_outbox_page.handleRequest(domain, db, 'sven', userKEK)
const res = await ap_outbox_page.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
@ -83,6 +83,46 @@ describe('ActivityPub', () => {
assert.equal(data.orderedItems[0].object.content, 'my second status')
assert.equal(data.orderedItems[1].object.content, 'my first status')
})
test("doesn't show private notes to anyone", async () => {
const db = await makeDB()
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)
await addObjectInOutbox(db, actorA, note, undefined, actorB.id.toString())
{
const res = await ap_outbox_page.handleRequest(domain, db, 'a')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.orderedItems.length, 0)
}
{
const res = await ap_outbox_page.handleRequest(domain, db, 'b')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.orderedItems.length, 0)
}
})
test("should show private notes to target but doesn't yet", async () => {
const db = await makeDB()
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)
await addObjectInOutbox(db, actorA, note)
const res = await ap_outbox_page.handleRequest(domain, db, 'target')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.orderedItems.length, 0)
})
})
describe('Objects', () => {

Wyświetl plik

@ -14,136 +14,6 @@ const vapidKeys = {} as JWK
describe('ActivityPub', () => {
describe('handle Activity', () => {
test('Note to inbox stores in DB', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db
.prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id')
.first()
const properties = JSON.parse(entry.properties)
assert.equal(properties.content, 'test note')
})
test("Note adds in remote actor's outbox", async () => {
const remoteActorId = 'https://example.com/actor'
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === remoteActorId) {
return new Response(
JSON.stringify({
id: remoteActorId,
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: remoteActorId,
to: [],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first()
assert.equal(entry.actor_id, remoteActorId)
})
test('local actor sends Note with mention create notification', async () => {
const db = await makeDB()
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actorB.id.toString(),
to: [actorA.id.toString()],
cc: [],
object: {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert(entry)
assert.equal(entry.type, 'mention')
assert.equal(entry.actor_id.toString(), actorA.id.toString())
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
})
test('Note records reply', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
{
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'post',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
}
{
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
object: {
inReplyTo: 'https://example.com/note1',
id: 'https://example.com/note2',
type: 'Note',
content: 'reply',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
}
const entry = await db.prepare('SELECT * FROM actor_replies').first()
assert.equal(entry.actor_id, actor.id.toString().toString())
const obj: any = await getObjectById(db, entry.object_id)
assert(obj)
assert.equal(obj.originalObjectId, 'https://example.com/note2')
const inReplyTo: any = await getObjectById(db, entry.in_reply_to_object_id)
assert(inReplyTo)
assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1')
})
describe('Announce', () => {
test('records reblog in db', async () => {
const db = await makeDB()
@ -314,6 +184,157 @@ describe('ActivityPub', () => {
message: '`activity.object` must be of type object',
})
})
test('Note to inbox stores in DB', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db
.prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id')
.first()
const properties = JSON.parse(entry.properties)
assert.equal(properties.content, 'test note')
})
test("Note adds in remote actor's outbox", async () => {
const remoteActorId = 'https://example.com/actor'
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === remoteActorId) {
return new Response(
JSON.stringify({
id: remoteActorId,
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: remoteActorId,
to: [],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first()
assert.equal(entry.actor_id, remoteActorId)
})
test('local actor sends Note with mention create notification', async () => {
const db = await makeDB()
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actorB.id.toString(),
to: [actorA.id.toString()],
cc: [],
object: {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert(entry)
assert.equal(entry.type, 'mention')
assert.equal(entry.actor_id.toString(), actorA.id.toString())
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
})
test('Note records reply', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
{
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'post',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
}
{
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: [actor.id.toString()],
object: {
inReplyTo: 'https://example.com/note1',
id: 'https://example.com/note2',
type: 'Note',
content: 'reply',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
}
const entry = await db.prepare('SELECT * FROM actor_replies').first()
assert.equal(entry.actor_id, actor.id.toString().toString())
const obj: any = await getObjectById(db, entry.object_id)
assert(obj)
assert.equal(obj.originalObjectId, 'https://example.com/note2')
const inReplyTo: any = await getObjectById(db, entry.in_reply_to_object_id)
assert(inReplyTo)
assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1')
})
test('preserve Note sent with `to`', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actor.id.toString(),
to: ['some actor'],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
const row = await db.prepare('SELECT * FROM outbox_objects').first()
assert.equal(row.target, 'some actor')
})
})
describe('Update', () => {

Wyświetl plik

@ -13,7 +13,7 @@ import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
import { isUrlValid, makeDB, assertJSON, streamToArrayBuffer, makeQueue } from '../utils'
import * as note from 'wildebeest/backend/src/activitypub/objects/note'
import * as activities from 'wildebeest/backend/src/activitypub/activities'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { MessageType } from 'wildebeest/backend/src/types/queue'
@ -220,7 +220,7 @@ describe('Mastodon APIs', () => {
assert.equal(deliveredNote.actor, `https://${domain}/ap/users/sven`)
assert.equal(deliveredNote.object.attributedTo, `https://${domain}/ap/users/sven`)
assert.equal(deliveredNote.object.type, 'Note')
assert(deliveredNote.object.to.includes(note.PUBLIC))
assert(deliveredNote.object.to.includes(activities.PUBLIC_GROUP))
assert.equal(deliveredNote.object.cc.length, 1)
})

Wyświetl plik

@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert/strict'
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { createPublicNote, createPrivateNote } 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 } from '../utils'
@ -53,6 +53,40 @@ describe('Mastodon APIs', () => {
assert.equal(data[1].reblogs_count, 1)
})
test("home doesn't show private Notes from followed actors", async () => {
const db = await makeDB()
const actor1 = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
const actor3 = await createPerson(domain, db, userKEK, 'sven3@cloudflare.com')
// actor3 follows actor1 and actor2
await addFollowing(db, actor3, actor1, 'not needed')
await acceptFollowing(db, actor3, actor1)
await addFollowing(db, actor3, actor2, 'not needed')
await acceptFollowing(db, actor3, actor2)
// actor2 sends a DM to actor1
const note = await createPrivateNote(domain, db, 'DM', actor2, actor1)
await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString())
// actor3 shouldn't see the private note
const data = await timelines.getHomeTimeline(domain, db, actor3)
assert.equal(data.length, 0)
})
test("public doesn't show private Notes", async () => {
const db = await makeDB()
const actor1 = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
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)
await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString())
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet)
assert.equal(data.length, 0)
})
test('home returns Notes from ourself', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')

Wyświetl plik

@ -7,10 +7,11 @@ import type { ContextData } from 'wildebeest/backend/src/types/context'
import type { Env } from 'wildebeest/backend/src/types/env'
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import * as activityCreate from 'wildebeest/backend/src/activitypub/activities/create'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request, env, params }) => {
const domain = new URL(request.url).hostname
return handleRequest(domain, env.DATABASE, params.id as string, env.userKEK)
return handleRequest(domain, env.DATABASE, params.id as string)
}
const headers = {
@ -21,8 +22,7 @@ const headers = {
const DEFAULT_LIMIT = 20
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- TODO: use userKEK
export async function handleRequest(domain: string, db: D1Database, id: string, userKEK: string): Promise<Response> {
export async function handleRequest(domain: string, db: D1Database, id: string): Promise<Response> {
const handle = parseHandle(id)
if (handle.domain !== null) {
@ -42,9 +42,11 @@ export async function handleRequest(domain: string, db: D1Database, id: string,
SELECT objects.*
FROM outbox_objects
INNER JOIN objects ON objects.id = outbox_objects.object_id
WHERE outbox_objects.actor_id = ? AND objects.type = 'Note'
WHERE outbox_objects.actor_id = ?1
AND objects.type = 'Note'
AND outbox_objects.target = '${PUBLIC_GROUP}'
ORDER by outbox_objects.cdate DESC
LIMIT ?
LIMIT ?2
`
const { success, error, results } = await db.prepare(QUERY).bind(actorId.toString(), DEFAULT_LIMIT).all()

Wyświetl plik

@ -0,0 +1,4 @@
-- Migration number: 0002 2023-01-16T13:46:54.975Z
ALTER TABLE outbox_objects
ADD target TEXT NOT NULL DEFAULT 'https://www.w3.org/ns/activitystreams#Public';