diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cdcf274..f2a49a7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -181,6 +181,55 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + - name: retrieve Wildebeest cache KV namespace + uses: cloudflare/wrangler-action@2.0.0 + with: + command: kv:namespace list | jq -r '.[] | select( .title == "wildebeest-${{ env.OWNER_LOWER }}-cache" ) | .id' | awk '{print "cache_kv="$1}' >> $GITHUB_ENV + apiToken: ${{ secrets.CF_API_TOKEN }} + preCommands: | + echo "*** pre commands ***" + apt-get update && apt-get -y install jq + echo "******" + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + + - name: Publish consumer + uses: cloudflare/wrangler-action@2.0.0 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + preCommands: | + echo "*** pre commands ***" + echo -e "[[d1_databases]]\nbinding=\"DATABASE\"\ndatabase_name=\"wildebeest-${{ env.OWNER_LOWER }}\"\ndatabase_id=\"${{ env.d1_id }}\"\n" >> consumer/wrangler.toml + + echo -e "[[kv_namespaces]]\n" >> consumer/wrangler.toml + echo -e "binding=\"KV_CACHE\"\n" >> consumer/wrangler.toml + echo -e "id=\"${{ env.cache_kv }}\"\n" >> consumer/wrangler.toml + + echo -e "[vars]\n" >> consumer/wrangler.toml + echo -e "DOMAIN=\"${{ vars.CF_DEPLOY_DOMAIN }}\"\n" >> consumer/wrangler.toml + echo -e "ADMIN_EMAIL=\"${{ vars.ADMIN_EMAIL }}\"\n" >> consumer/wrangler.toml + echo "******" + command: publish --config consumer/wrangler.toml + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + + - name: add Queue producer to Pages + run: | + curl https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/pages/projects/wildebeest-${{ env.OWNER_LOWER }} \ + -XPATCH \ + -H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}' \ + -d ' { + "deployment_configs": { + "production": { + "queue_producers": { + "QUEUE": { + "name": "wildebeest" + } + } + } + } + }' > /dev/null + - name: Publish uses: cloudflare/wrangler-action@2.0.0 with: @@ -191,7 +240,7 @@ jobs: yarn build cp -rv ./frontend/dist/* . # remove folder that aren't needed in Pages before we upload - rm -rf ./tf ./scripts ./.github ./.npm + rm -rf ./tf ./scripts ./.github ./.npm ./consumer echo "******" command: pages publish --project-name=wildebeest-${{ env.OWNER_LOWER }} . env: diff --git a/backend/src/types/env.ts b/backend/src/types/env.ts index 2ea52db..9a77112 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -2,6 +2,7 @@ export interface Env { DATABASE: D1Database KV_CACHE: KVNamespace userKEK: string + QUEUE: Queue CF_ACCOUNT_ID: string CF_API_TOKEN: string diff --git a/backend/src/types/queue.ts b/backend/src/types/queue.ts new file mode 100644 index 0000000..e4c5072 --- /dev/null +++ b/backend/src/types/queue.ts @@ -0,0 +1,15 @@ +import type { Activity } from 'wildebeest/backend/src/activitypub/activities' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' + +export type MessageType = 'activity' + +export type MessageBody = { + type: MessageType + actorId: string + content: Activity + + // Send secrets as part of the message because it's too complicated + // to bind them to the consumer worker. + userKEK: string + vapidKeys: JWK +} diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts index 6f61c6b..2142149 100644 --- a/backend/test/activitypub.spec.ts +++ b/backend/test/activitypub.spec.ts @@ -1,22 +1,19 @@ import { makeDB, isUrlValid } from './utils' import type { JWK } from 'wildebeest/backend/src/webpush/jwk' -import { addFollowing } from 'wildebeest/backend/src/mastodon/follow' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' -import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' import { 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/' - import * as ap_users from 'wildebeest/functions/ap/users/[id]' import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox' +import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox' import * as ap_outbox_page from 'wildebeest/functions/ap/users/[id]/outbox/page' const userKEK = 'test_kek5' -const vapidKeys = {} as JWK const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const vapidKeys = {} as JWK const domain = 'cloudflare.com' -const adminEmail = 'admin@example.com' describe('ActivityPub', () => { test('fetch non-existant user by id', async () => { @@ -52,168 +49,6 @@ describe('ActivityPub', () => { assert.equal(data.publicKey.publicKeyPem, pubKey) }) - describe('Accept', () => { - beforeEach(() => { - globalThis.fetch = async (input: RequestInfo) => { - throw new Error('unexpected request to ' + input) - } - }) - - test('Accept follow request stores in db', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor, actor2, 'not needed') - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Accept', - actor: { id: 'https://' + domain + '/ap/users/sven2' }, - object: { - type: 'Follow', - actor: actor.id, - object: 'https://' + domain + '/ap/users/sven2', - }, - } - - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const row = await db - .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) - .bind(actor.id.toString()) - .first() - assert(row) - assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') - assert.equal(row.state, 'accepted') - }) - - test('Object must be an object', async () => { - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Accept', - actor: 'https://example.com/actor', - object: 'a', - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: '`activity.object` must be of type object', - }) - }) - }) - - describe('Create', () => { - test('Object must be an object', async () => { - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Create', - actor: 'https://example.com/actor', - object: 'a', - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: '`activity.object` must be of type object', - }) - }) - }) - - describe('Update', () => { - test('Object must be an object', async () => { - const db = await makeDB() - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: 'https://example.com/actor', - object: 'a', - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: '`activity.object` must be of type object', - }) - }) - - test('Object must exist', async () => { - const db = await makeDB() - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: 'https://example.com/actor', - object: { - id: 'https://example.com/note2', - type: 'Note', - content: 'test note', - }, - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: 'object https://example.com/note2 does not exist', - }) - }) - - test('Object must have the same origin', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const object = { - id: 'https://example.com/note2', - type: 'Note', - content: 'test note', - } - - const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) - assert.notEqual(obj, null, 'could not create object') - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: 'https://example.com/actor', - object: object, - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: 'actorid mismatch when updating object', - }) - }) - - test('Object is updated', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const object = { - id: 'https://example.com/note2', - type: 'Note', - content: 'test note', - } - - const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) - assert.notEqual(obj, null, 'could not create object') - - const newObject = { - id: 'https://example.com/note2', - type: 'Note', - content: 'new test note', - } - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: actor.id, - object: newObject, - } - - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const updatedObject = await db.prepare('SELECT * FROM objects WHERE original_object_id=?').bind(object.id).first() - assert(updatedObject) - assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) - }) - }) - describe('Outbox', () => { test('return outbox', async () => { const db = await makeDB() @@ -249,60 +84,6 @@ describe('ActivityPub', () => { }) }) - describe('Announce', () => { - test('Announce objects are stored and added to the remote actors outbox', async () => { - const remoteActorId = 'https://example.com/actor' - const objectId = 'https://example.com/some-object' - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === remoteActorId) { - return new Response( - JSON.stringify({ - id: remoteActorId, - icon: { url: 'img.com' }, - type: 'Person', - }) - ) - } - - if (input.toString() === objectId) { - return new Response( - JSON.stringify({ - id: objectId, - type: 'Note', - content: 'foo', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const activity: any = { - type: 'Announce', - actor: remoteActorId, - to: [], - cc: [], - object: objectId, - } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first() - assert(object) - assert.equal(object.type, 'Note') - assert.equal(object.original_actor_id, remoteActorId) - - const outbox_object = await db - .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') - .bind(remoteActorId) - .first() - assert(outbox_object) - assert.equal(outbox_object.actor_id, remoteActorId) - }) - }) - describe('Objects', () => { test('cacheObject deduplicates object', async () => { const db = await makeDB() @@ -334,4 +115,42 @@ describe('ActivityPub', () => { assert.equal(result.count, 1) }) }) + + describe('Inbox', () => { + test('send Note to non existant user', async () => { + const db = await makeDB() + + const queue = { + async send() {}, + } + + const activity: any = {} + const res = await ap_inbox.handleRequest(domain, db, 'sven', activity, queue, userKEK, vapidKeys) + assert.equal(res.status, 404) + }) + + test('send activity sends message in queue', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + let msg: any = null + + const queue = { + async send(v: any) { + msg = v + }, + } + + const activity: any = { + type: 'some activity', + } + const res = await ap_inbox.handleRequest(domain, db, 'sven', activity, queue, userKEK, vapidKeys) + assert.equal(res.status, 200) + + assert(msg) + assert.equal(msg.type, 'activity') + assert.equal(msg.actorId, actor.id.toString()) + assert.equal(msg.content.type, 'some activity') + }) + }) }) diff --git a/backend/test/activitypub/handle.spec.ts b/backend/test/activitypub/handle.spec.ts new file mode 100644 index 0000000..238d8c3 --- /dev/null +++ b/backend/test/activitypub/handle.spec.ts @@ -0,0 +1,468 @@ +import { makeDB } from '../utils' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' +import { strict as assert } from 'node:assert/strict' +import { cacheObject, getObjectById } from 'wildebeest/backend/src/activitypub/objects/' +import { addFollowing } from 'wildebeest/backend/src/mastodon/follow' +import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' + +const adminEmail = 'admin@example.com' +const domain = 'cloudflare.com' +const userKEK = 'test_kek15' +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() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Announce', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_reblogs').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + + test('creates 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 note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Announce', + actor: actorB.id, + object: note.id, + } + 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, 'reblog') + assert.equal(entry.actor_id.toString(), actorA.id.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) + }) + }) + + describe('Like', () => { + test('records like in db', 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 createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + + test('creates 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 note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_notifications').first() + assert.equal(entry.type, 'favourite') + assert.equal(entry.actor_id.toString(), actorA.id.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) + }) + + test('records like in db', 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 createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + }) + + describe('Accept', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + throw new Error('unexpected request to ' + input) + } + }) + + test('Accept follow request stores in db', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor, actor2, 'not needed') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: { id: 'https://' + domain + '/ap/users/sven2' }, + object: { + type: 'Follow', + actor: actor.id, + object: 'https://' + domain + '/ap/users/sven2', + }, + } + + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const row = await db + .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first() + assert(row) + assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') + assert.equal(row.state, 'accepted') + }) + + test('Object must be an object', async () => { + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: '`activity.object` must be of type object', + }) + }) + }) + + describe('Create', () => { + test('Object must be an object', async () => { + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: '`activity.object` must be of type object', + }) + }) + }) + + describe('Update', () => { + test('Object must be an object', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: '`activity.object` must be of type object', + }) + }) + + test('Object must exist', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + }, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: 'object https://example.com/note2 does not exist', + }) + }) + + test('Object must have the same origin', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: object, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: 'actorid mismatch when updating object', + }) + }) + + test('Object is updated', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const newObject = { + id: 'https://example.com/note2', + type: 'Note', + content: 'new test note', + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: actor.id, + object: newObject, + } + + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const updatedObject = await db + .prepare('SELECT * FROM objects WHERE original_object_id=?') + .bind(object.id) + .first() + assert(updatedObject) + assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) + }) + }) + + describe('Announce', () => { + test('Announce objects are stored and added to the remote actors outbox', async () => { + const remoteActorId = 'https://example.com/actor' + const objectId = 'https://example.com/some-object' + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === remoteActorId) { + return new Response( + JSON.stringify({ + id: remoteActorId, + icon: { url: 'img.com' }, + type: 'Person', + }) + ) + } + + if (input.toString() === objectId) { + return new Response( + JSON.stringify({ + id: objectId, + type: 'Note', + content: 'foo', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity: any = { + type: 'Announce', + actor: remoteActorId, + to: [], + cc: [], + object: objectId, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first() + assert(object) + assert.equal(object.type, 'Note') + assert.equal(object.original_actor_id, remoteActorId) + + const outbox_object = await db + .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') + .bind(remoteActorId) + .first() + assert(outbox_object) + assert.equal(outbox_object.actor_id, remoteActorId) + }) + }) + }) +}) diff --git a/backend/test/activitypub/inbox.spec.ts b/backend/test/activitypub/inbox.spec.ts deleted file mode 100644 index 13a7687..0000000 --- a/backend/test/activitypub/inbox.spec.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { makeDB } from '../utils' -import type { JWK } from 'wildebeest/backend/src/webpush/jwk' -import * as objects from 'wildebeest/backend/src/activitypub/objects' -import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' -import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox' -import { createPerson } from 'wildebeest/backend/src/activitypub/actors' -import { strict as assert } from 'node:assert/strict' - -const userKEK = 'test_kek9' -const domain = 'cloudflare.com' -const adminEmail = 'admin@example.com' -const vapidKeys = {} as JWK - -const kv_cache: any = { - async put() {}, -} - -const waitUntil = async (p: Promise) => await p - -describe('ActivityPub', () => { - test('send Note to non existant user', async () => { - const db = await makeDB() - - const activity: any = {} - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'sven', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 404) - }) - - test('send 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', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'sven', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - 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("send 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', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'sven', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - 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', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_notifications').first() - 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('remote actor sends Note with mention create notification and download actor', async () => { - const actorB = 'https://remote.com/actorb' - - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === actorB) { - return new Response( - JSON.stringify({ - id: actorB, - type: 'Person', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - - const activity: any = { - type: 'Create', - actor: actorB, - to: [actorA.id.toString()], - cc: [], - object: { - id: 'https://example.com/note3', - type: 'Note', - content: 'test note', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actors WHERE id=?').bind(actorB).first() - assert.equal(entry.id, actorB) - }) - - test('send 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', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'sven', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - } - - { - 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', - }, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'sven', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - } - - const entry = await db.prepare('SELECT * FROM actor_replies').first() - assert.equal(entry.actor_id, actor.id.toString().toString()) - - const obj: any = await objects.getObjectById(db, entry.object_id) - assert(obj) - assert.equal(obj.originalObjectId, 'https://example.com/note2') - - const inReplyTo: any = await objects.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() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - - const note = await createPublicNote(domain, db, 'my first status', actorA) - - const activity: any = { - type: 'Announce', - actor: actorB.id, - object: note.id, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_reblogs').first() - assert.equal(entry.actor_id.toString(), actorB.id.toString()) - assert.equal(entry.object_id.toString(), note.id.toString()) - }) - - test('creates 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 note = await createPublicNote(domain, db, 'my first status', actorA) - - const activity: any = { - type: 'Announce', - actor: actorB.id, - object: note.id, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_notifications').first() - assert(entry) - assert.equal(entry.type, 'reblog') - assert.equal(entry.actor_id.toString(), actorA.id.toString()) - assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) - }) - }) - - describe('Like', () => { - test('records like in db', 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 createPublicNote(domain, db, 'my first status', actorA) - - const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_favourites').first() - assert.equal(entry.actor_id.toString(), actorB.id.toString()) - assert.equal(entry.object_id.toString(), note.id.toString()) - }) - - test('creates 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 note = await createPublicNote(domain, db, 'my first status', actorA) - - const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_notifications').first() - assert.equal(entry.type, 'favourite') - assert.equal(entry.actor_id.toString(), actorA.id.toString()) - assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) - }) - - test('records like in db', 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 createPublicNote(domain, db, 'my first status', actorA) - - const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, - } - const res = await ap_inbox.handleRequest( - domain, - db, - kv_cache, - 'a', - activity, - userKEK, - waitUntil, - adminEmail, - vapidKeys - ) - assert.equal(res.status, 200) - - const entry = await db.prepare('SELECT * FROM actor_favourites').first() - assert.equal(entry.actor_id.toString(), actorB.id.toString()) - assert.equal(entry.object_id.toString(), note.id.toString()) - }) - }) -}) diff --git a/consumer/package.json b/consumer/package.json new file mode 100644 index 0000000..7a41094 --- /dev/null +++ b/consumer/package.json @@ -0,0 +1,10 @@ +{ + "name": "consumer", + "version": "0.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20221111.1", + "typescript": "^4.9.4", + "wrangler": "2.7.1" + }, + "private": true +} diff --git a/consumer/src/index.ts b/consumer/src/index.ts new file mode 100644 index 0000000..4c1bd91 --- /dev/null +++ b/consumer/src/index.ts @@ -0,0 +1,58 @@ +import type { MessageBody } from 'wildebeest/backend/src/types/queue' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import * as timeline from 'wildebeest/backend/src/mastodon/timeline' +import * as notification from 'wildebeest/backend/src/mastodon/notification' +import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' +import type { Activity } from 'wildebeest/backend/src/activitypub/activities' + +type Env = { + DATABASE: D1Database + DOMAIN: string + ADMIN_EMAIL: string + KV_CACHE: KVNamespace +} + +export default { + async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) { + for (const message of batch.messages) { + try { + const actor = await actors.getPersonById(env.DATABASE, new URL(message.body.actorId)) + if (actor === null) { + console.warn(`actor ${message.body.actorId} is missing`) + return + } + + switch (message.body.type) { + case 'activity': { + await handleActivityMessage(env, actor, message.body) + break + } + default: + throw new Error('unsupported message type: ' + message.body.type) + } + } catch (err: any) { + console.error(err.stack) + // TODO: add sentry + } + } + }, +} + +async function handleActivityMessage(env: Env, actor: Actor, message: MessageBody) { + const domain = env.DOMAIN + const db = env.DATABASE + const adminEmail = env.ADMIN_EMAIL + const cache = env.KV_CACHE + const activity = message.content + + await activityHandler.handle(domain, activity, db, message.userKEK, adminEmail, message.vapidKeys) + + // Assuming we received new posts or a like, pregenerate the user's timelines + // and notifications. + await Promise.all([ + timeline.pregenerateTimelines(domain, db, cache, actor), + notification.pregenerateNotifications(db, cache, actor), + ]) +} diff --git a/consumer/tsconfig.json b/consumer/tsconfig.json new file mode 100644 index 0000000..a69c19a --- /dev/null +++ b/consumer/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "lib": [ + "es2021" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + "jsx": "react" /* Specify what JSX code is generated. */, + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "es2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": { + "wildebeest/*": ["../*"] + }, + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "types": [ + "@cloudflare/workers-types", + "jest" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + "resolveJsonModule": true /* Enable importing .json files */, + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, + "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + "noEmit": true /* Disable emitting files from a compilation. */, + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/consumer/wrangler.toml b/consumer/wrangler.toml new file mode 100644 index 0000000..4d5ffaf --- /dev/null +++ b/consumer/wrangler.toml @@ -0,0 +1,9 @@ +name = "wildebeest-consumer" +compatibility_date = "2023-01-12" +main = "./src/index.ts" + +[[queues.consumers]] + queue = "wildebeest" + max_batch_size = 10 + max_batch_timeout = 30 + max_retries = 10 diff --git a/functions/ap/users/[id]/inbox.ts b/functions/ap/users/[id]/inbox.ts index 4e8a0b8..ec90ba6 100644 --- a/functions/ap/users/[id]/inbox.ts +++ b/functions/ap/users/[id]/inbox.ts @@ -1,18 +1,16 @@ import { parseHandle } from 'wildebeest/backend/src/utils/parse' +import { getVAPIDKeys } from 'wildebeest/backend/src/config' import type { JWK } from 'wildebeest/backend/src/webpush/jwk' -import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' -import type { Env } from 'wildebeest/backend/src/types/env' import * as actors from 'wildebeest/backend/src/activitypub/actors' -import type { Activity } from 'wildebeest/backend/src/activitypub/activities' import { actorURL } from 'wildebeest/backend/src/activitypub/actors' +import type { Env } from 'wildebeest/backend/src/types/env' +import type { MessageBody } from 'wildebeest/backend/src/types/queue' +import type { Activity } from 'wildebeest/backend/src/activitypub/activities' import { parseRequest } from 'wildebeest/backend/src/utils/httpsigjs/parser' import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigjs/verifier' import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage' -import * as timeline from 'wildebeest/backend/src/mastodon/timeline' -import * as notification from 'wildebeest/backend/src/mastodon/notification' -import { getVAPIDKeys } from 'wildebeest/backend/src/config' -export const onRequest: PagesFunction = async ({ params, request, env, waitUntil }) => { +export const onRequest: PagesFunction = async ({ params, request, env }) => { const parsedSignature = parseRequest(request) const pubKey = await fetchKey(parsedSignature) const valid = await verifySignature(parsedSignature, pubKey) @@ -31,28 +29,16 @@ export const onRequest: PagesFunction = async ({ params, request, env, const activity: Activity = JSON.parse(body) const domain = new URL(request.url).hostname - return handleRequest( - domain, - env.DATABASE, - env.KV_CACHE, - params.id as string, - activity, - env.userKEK, - waitUntil, - env.ADMIN_EMAIL, - getVAPIDKeys(env) - ) + return handleRequest(domain, env.DATABASE, params.id as string, activity, env.QUEUE, env.userKEK, getVAPIDKeys(env)) } export async function handleRequest( domain: string, db: D1Database, - cache: KVNamespace, id: string, activity: Activity, + queue: Queue, userKEK: string, - waitUntil: (p: Promise) => void, - adminEmail: string, vapidKeys: JWK ): Promise { const handle = parseHandle(id) @@ -60,23 +46,20 @@ export async function handleRequest( if (handle.domain !== null && handle.domain !== domain) { return new Response('', { status: 403 }) } - const actorId = actorURL(domain, handle.localPart) + const actor = await actors.getPersonById(db, actorId) if (actor === null) { return new Response('', { status: 404 }) } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - // Assuming we received new posts or a like, pregenerate the user's timelines - // and notifications. - waitUntil( - Promise.all([ - timeline.pregenerateTimelines(domain, db, cache, actor), - notification.pregenerateNotifications(db, cache, actor), - ]) - ) + await queue.send({ + type: 'activity', + actorId: actor.id.toString(), + content: activity, + userKEK, + vapidKeys, + }) return new Response('', { status: 200 }) } diff --git a/functions/api/v2/media.ts b/functions/api/v2/media.ts index cfc1419..b01ab69 100644 --- a/functions/api/v2/media.ts +++ b/functions/api/v2/media.ts @@ -66,7 +66,7 @@ export async function handleRequest( 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'content-type, authorization', 'Access-Control-Allow-Methods': 'POST', - 'content-type': 'application/json', + 'content-type': 'application/json; charset=utf-8', } return new Response(JSON.stringify(res), { headers }) }