kopia lustrzana https://github.com/cloudflare/wildebeest
MOW-75: handle inbox via a Queue
rodzic
699c2491f3
commit
7513a88c91
|
@ -181,6 +181,55 @@ jobs:
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
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
|
- name: Publish
|
||||||
uses: cloudflare/wrangler-action@2.0.0
|
uses: cloudflare/wrangler-action@2.0.0
|
||||||
with:
|
with:
|
||||||
|
@ -191,7 +240,7 @@ jobs:
|
||||||
yarn build
|
yarn build
|
||||||
cp -rv ./frontend/dist/* .
|
cp -rv ./frontend/dist/* .
|
||||||
# remove folder that aren't needed in Pages before we upload
|
# 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 "******"
|
echo "******"
|
||||||
command: pages publish --project-name=wildebeest-${{ env.OWNER_LOWER }} .
|
command: pages publish --project-name=wildebeest-${{ env.OWNER_LOWER }} .
|
||||||
env:
|
env:
|
||||||
|
|
|
@ -2,6 +2,7 @@ export interface Env {
|
||||||
DATABASE: D1Database
|
DATABASE: D1Database
|
||||||
KV_CACHE: KVNamespace
|
KV_CACHE: KVNamespace
|
||||||
userKEK: string
|
userKEK: string
|
||||||
|
QUEUE: Queue
|
||||||
|
|
||||||
CF_ACCOUNT_ID: string
|
CF_ACCOUNT_ID: string
|
||||||
CF_API_TOKEN: string
|
CF_API_TOKEN: string
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -1,22 +1,19 @@
|
||||||
import { makeDB, isUrlValid } from './utils'
|
import { makeDB, isUrlValid } from './utils'
|
||||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
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 { 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 { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||||
import { strict as assert } from 'node:assert/strict'
|
import { strict as assert } from 'node:assert/strict'
|
||||||
import { cacheObject } from 'wildebeest/backend/src/activitypub/objects/'
|
import { cacheObject } from 'wildebeest/backend/src/activitypub/objects/'
|
||||||
|
|
||||||
import * as ap_users from 'wildebeest/functions/ap/users/[id]'
|
import * as ap_users from 'wildebeest/functions/ap/users/[id]'
|
||||||
import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox'
|
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'
|
import * as ap_outbox_page from 'wildebeest/functions/ap/users/[id]/outbox/page'
|
||||||
|
|
||||||
const userKEK = 'test_kek5'
|
const userKEK = 'test_kek5'
|
||||||
const vapidKeys = {} as JWK
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
const vapidKeys = {} as JWK
|
||||||
const domain = 'cloudflare.com'
|
const domain = 'cloudflare.com'
|
||||||
const adminEmail = 'admin@example.com'
|
|
||||||
|
|
||||||
describe('ActivityPub', () => {
|
describe('ActivityPub', () => {
|
||||||
test('fetch non-existant user by id', async () => {
|
test('fetch non-existant user by id', async () => {
|
||||||
|
@ -52,168 +49,6 @@ describe('ActivityPub', () => {
|
||||||
assert.equal(data.publicKey.publicKeyPem, pubKey)
|
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', () => {
|
describe('Outbox', () => {
|
||||||
test('return outbox', async () => {
|
test('return outbox', async () => {
|
||||||
const db = await makeDB()
|
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', () => {
|
describe('Objects', () => {
|
||||||
test('cacheObject deduplicates object', async () => {
|
test('cacheObject deduplicates object', async () => {
|
||||||
const db = await makeDB()
|
const db = await makeDB()
|
||||||
|
@ -334,4 +115,42 @@ describe('ActivityPub', () => {
|
||||||
assert.equal(result.count, 1)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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<void>) => 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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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<MessageBody>, 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),
|
||||||
|
])
|
||||||
|
}
|
|
@ -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 `<reference>`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. */
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -1,18 +1,16 @@
|
||||||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
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 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 * 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 { 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 { parseRequest } from 'wildebeest/backend/src/utils/httpsigjs/parser'
|
||||||
import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigjs/verifier'
|
import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigjs/verifier'
|
||||||
import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage'
|
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<Env, any> = async ({ params, request, env, waitUntil }) => {
|
export const onRequest: PagesFunction<Env, any> = async ({ params, request, env }) => {
|
||||||
const parsedSignature = parseRequest(request)
|
const parsedSignature = parseRequest(request)
|
||||||
const pubKey = await fetchKey(parsedSignature)
|
const pubKey = await fetchKey(parsedSignature)
|
||||||
const valid = await verifySignature(parsedSignature, pubKey)
|
const valid = await verifySignature(parsedSignature, pubKey)
|
||||||
|
@ -31,28 +29,16 @@ export const onRequest: PagesFunction<Env, any> = async ({ params, request, env,
|
||||||
|
|
||||||
const activity: Activity = JSON.parse(body)
|
const activity: Activity = JSON.parse(body)
|
||||||
const domain = new URL(request.url).hostname
|
const domain = new URL(request.url).hostname
|
||||||
return handleRequest(
|
return handleRequest(domain, env.DATABASE, params.id as string, activity, env.QUEUE, env.userKEK, getVAPIDKeys(env))
|
||||||
domain,
|
|
||||||
env.DATABASE,
|
|
||||||
env.KV_CACHE,
|
|
||||||
params.id as string,
|
|
||||||
activity,
|
|
||||||
env.userKEK,
|
|
||||||
waitUntil,
|
|
||||||
env.ADMIN_EMAIL,
|
|
||||||
getVAPIDKeys(env)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRequest(
|
export async function handleRequest(
|
||||||
domain: string,
|
domain: string,
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
cache: KVNamespace,
|
|
||||||
id: string,
|
id: string,
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
queue: Queue<MessageBody>,
|
||||||
userKEK: string,
|
userKEK: string,
|
||||||
waitUntil: (p: Promise<any>) => void,
|
|
||||||
adminEmail: string,
|
|
||||||
vapidKeys: JWK
|
vapidKeys: JWK
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const handle = parseHandle(id)
|
const handle = parseHandle(id)
|
||||||
|
@ -60,23 +46,20 @@ export async function handleRequest(
|
||||||
if (handle.domain !== null && handle.domain !== domain) {
|
if (handle.domain !== null && handle.domain !== domain) {
|
||||||
return new Response('', { status: 403 })
|
return new Response('', { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const actorId = actorURL(domain, handle.localPart)
|
const actorId = actorURL(domain, handle.localPart)
|
||||||
|
|
||||||
const actor = await actors.getPersonById(db, actorId)
|
const actor = await actors.getPersonById(db, actorId)
|
||||||
if (actor === null) {
|
if (actor === null) {
|
||||||
return new Response('', { status: 404 })
|
return new Response('', { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
await queue.send({
|
||||||
|
type: 'activity',
|
||||||
// Assuming we received new posts or a like, pregenerate the user's timelines
|
actorId: actor.id.toString(),
|
||||||
// and notifications.
|
content: activity,
|
||||||
waitUntil(
|
userKEK,
|
||||||
Promise.all([
|
vapidKeys,
|
||||||
timeline.pregenerateTimelines(domain, db, cache, actor),
|
})
|
||||||
notification.pregenerateNotifications(db, cache, actor),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
|
||||||
return new Response('', { status: 200 })
|
return new Response('', { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@ export async function handleRequest(
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers': 'content-type, authorization',
|
'Access-Control-Allow-Headers': 'content-type, authorization',
|
||||||
'Access-Control-Allow-Methods': 'POST',
|
'Access-Control-Allow-Methods': 'POST',
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json; charset=utf-8',
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify(res), { headers })
|
return new Response(JSON.stringify(res), { headers })
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue