kopia lustrzana https://github.com/cloudflare/wildebeest
commit
130fc2f4a3
|
@ -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:
|
||||
|
|
|
@ -2,6 +2,7 @@ export interface Env {
|
|||
DATABASE: D1Database
|
||||
KV_CACHE: KVNamespace
|
||||
userKEK: string
|
||||
QUEUE: Queue
|
||||
|
||||
CF_ACCOUNT_ID: 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 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 { 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<Env, any> = async ({ params, request, env, waitUntil }) => {
|
||||
export const onRequest: PagesFunction<Env, any> = 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<Env, any> = 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<MessageBody>,
|
||||
userKEK: string,
|
||||
waitUntil: (p: Promise<any>) => void,
|
||||
adminEmail: string,
|
||||
vapidKeys: JWK
|
||||
): Promise<Response> {
|
||||
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 })
|
||||
}
|
||||
|
|
|
@ -63,6 +63,9 @@ export async function handleRequest(
|
|||
}
|
||||
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'content-type, authorization',
|
||||
'Access-Control-Allow-Methods': 'POST',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
}
|
||||
return new Response(JSON.stringify(res), { headers })
|
||||
|
|
Ładowanie…
Reference in New Issue