From 26fbb26cec29ebf5689e9a91fecec7793f2a26ea Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Wed, 11 Jan 2023 14:32:06 +0000 Subject: [PATCH] MOW-95: use instance config in Env instead of DB --- .github/workflows/deploy.yml | 33 ++++ backend/src/activitypub/activities/handle.ts | 18 +- backend/src/config/index.ts | 55 +----- backend/src/mastodon/notification.ts | 66 ++++--- backend/src/mastodon/subscription.ts | 9 - backend/src/types/env.ts | 6 + backend/test/activitypub.spec.ts | 22 +-- backend/test/activitypub/follow.spec.ts | 12 +- backend/test/activitypub/inbox.spec.ts | 166 ++++++++++++++---- backend/test/mastodon.spec.ts | 85 +++++---- backend/test/mastodon/accounts.spec.ts | 4 - backend/test/mastodon/notifications.spec.ts | 7 +- backend/test/start-instance.spec.ts | 48 ----- frontend/mock-db/init.ts | 8 - .../routes/(admin)/start-instance/index.tsx | 73 -------- .../routes/(admin)/start-instance/step-1.tsx | 80 --------- .../routes/(admin)/start-instance/utils.ts | 21 --- frontend/src/routes/(frontend)/layout.tsx | 29 +-- functions/ap/users/[id]/inbox.ts | 20 ++- functions/api/v1/apps.ts | 10 +- functions/api/v1/instance.ts | 29 ++- functions/api/v1/push/subscription.ts | 16 +- functions/api/v2/instance.ts | 29 +-- functions/start-instance-test-access.ts | 47 ----- functions/start-instance.ts | 56 ------ migrations/0000_initial.sql | 5 - scripts/generate-vapid-keys.mjs | 6 + tf/main.tf | 1 + 28 files changed, 381 insertions(+), 580 deletions(-) delete mode 100644 backend/test/start-instance.spec.ts delete mode 100644 frontend/src/routes/(admin)/start-instance/index.tsx delete mode 100644 frontend/src/routes/(admin)/start-instance/step-1.tsx delete mode 100644 frontend/src/routes/(admin)/start-instance/utils.ts delete mode 100644 functions/start-instance-test-access.ts delete mode 100644 functions/start-instance.ts create mode 100644 scripts/generate-vapid-keys.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 19163e7..4eda22f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -109,6 +109,39 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} if: ${{ env.tfstate_kv != '' }} + - name: download VAPID keys + uses: cloudflare/wrangler-action@2.0.0 + with: + command: kv:key get --namespace-id=${{ env.tfstate_kv }} vapid_jwk | jq . > ./tf/vapid_jwk + apiToken: ${{ secrets.CF_API_TOKEN }} + preCommands: | + echo "*** pre commands ***" + apt-get update && apt-get -y install jq + echo "******" + postCommands: | + echo "*** post commands ***" + chmod 777 ./tf/vapid_jwk + echo "******" + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + continue-on-error: true + + - name: generate VAPID keys if needed + run: | + if [ ! -s ./tf/vapid_jwk ] + then + node ./scripts/generate-vapid-keys.mjs > ./tf/vapid_jwk + echo "VAPID keys generated" + fi + + - name: store VAPID keys state + uses: cloudflare/wrangler-action@2.0.0 + with: + command: kv:key put --namespace-id=${{ env.tfstate_kv }} vapid_jwk --path=./tf/vapid_jwk + apiToken: ${{ secrets.CF_API_TOKEN }} + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + - name: Configure run: terraform plan && terraform apply -auto-approve continue-on-error: true diff --git a/backend/src/activitypub/activities/handle.ts b/backend/src/activitypub/activities/handle.ts index d0bf918..eeb372a 100644 --- a/backend/src/activitypub/activities/handle.ts +++ b/backend/src/activitypub/activities/handle.ts @@ -1,4 +1,5 @@ import * as actors from 'wildebeest/backend/src/activitypub/actors' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' import { actorURL } from 'wildebeest/backend/src/activitypub/actors' import * as objects from 'wildebeest/backend/src/activitypub/objects' @@ -79,7 +80,14 @@ export function makeGetActorAsId(activity: Activity): Function { } } -export async function handle(domain: string, activity: Activity, db: D1Database, userKEK: string) { +export async function handle( + domain: string, + activity: Activity, + db: D1Database, + userKEK: string, + adminEmail: string, + vapidKeys: JWK +) { // The `object` field of the activity is required to be an object, with an // `id` and a `type` field. const requireComplexObject = () => { @@ -183,7 +191,7 @@ export async function handle(domain: string, activity: Activity, db: D1Database, const notifId = await createNotification(db, 'mention', person, fromActor, obj) await Promise.all([ await addObjectInInbox(db, person, obj), - await sendMentionNotification(db, fromActor, person, notifId), + await sendMentionNotification(db, fromActor, person, notifId, adminEmail, vapidKeys), ]) } @@ -228,7 +236,7 @@ export async function handle(domain: string, activity: Activity, db: D1Database, // Notify the user const notifId = await insertFollowNotification(db, receiver, originalActor) - await sendFollowNotification(db, originalActor, receiver, notifId) + await sendFollowNotification(db, originalActor, receiver, notifId, adminEmail, vapidKeys) } else { console.warn(`actor ${objectId} not found`) } @@ -282,7 +290,7 @@ export async function handle(domain: string, activity: Activity, db: D1Database, // Store the reblog for counting insertReblog(db, fromActor, obj), - sendReblogNotification(db, fromActor, targetActor, notifId), + sendReblogNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys), ]) break } @@ -312,7 +320,7 @@ export async function handle(domain: string, activity: Activity, db: D1Database, insertLike(db, fromActor, obj), ]) - await sendLikeNotification(db, fromActor, targetActor, notifId) + await sendLikeNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys) break } diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1dc5d0d..8c3be08 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,53 +1,10 @@ -export type InstanceConfig = { - title?: string - email?: string - description?: string - thumbnail?: string -} +import type { Env } from 'wildebeest/backend/src/types/env' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' -const DEFAULT_THUMBNAIL = +export const DEFAULT_THUMBNAIL = 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail' -export async function configure(db: D1Database, data: InstanceConfig) { - const sql = ` - INSERT INTO instance_config - VALUES ('title', ?), - ('email', ?), - ('thumbnail', ?), - ('description', ?); - ` - - const { success, error } = await db - .prepare(sql) - .bind(data.title, data.email, DEFAULT_THUMBNAIL, data.description) - .run() - if (!success) { - throw new Error('SQL error: ' + error) - } -} - -export async function generateVAPIDKeys(db: D1Database) { - const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ - 'sign', - 'verify', - ])) as CryptoKeyPair - const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) - - const sql = ` - INSERT INTO instance_config - VALUES ('vapid_jwk', ?); - ` - - const { success, error } = await db.prepare(sql).bind(JSON.stringify(jwk)).run() - if (!success) { - throw new Error('SQL error: ' + error) - } -} - -export async function get(db: D1Database, name: string): Promise { - const row: { value: string } = await db.prepare('SELECT value FROM instance_config WHERE key = ?').bind(name).first() - if (!row) { - throw new Error(`configuration not found: ${name}`) - } - return row.value +export function getVAPIDKeys(env: Env): JWK { + const value: JWK = JSON.parse(env.VAPID_JWK) + return value } diff --git a/backend/src/mastodon/notification.ts b/backend/src/mastodon/notification.ts index a0347a4..921fa1c 100644 --- a/backend/src/mastodon/notification.ts +++ b/backend/src/mastodon/notification.ts @@ -1,4 +1,5 @@ import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import * as actors from 'wildebeest/backend/src/activitypub/actors' import { urlToHandle } from 'wildebeest/backend/src/utils/handle' import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' @@ -9,8 +10,6 @@ import { WebPushResult } from 'wildebeest/backend/src/webpush/webpushinfos' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import type { NotificationType, Notification } from 'wildebeest/backend/src/types/notification' import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription' -import { getVAPIDKeys } from 'wildebeest/backend/src/mastodon/subscription' -import * as config from 'wildebeest/backend/src/config' export async function createNotification( db: D1Database, @@ -43,9 +42,14 @@ export async function insertFollowNotification(db: D1Database, actor: Actor, fro return row.id } -export async function sendFollowNotification(db: D1Database, follower: Actor, actor: Actor, notificationId: string) { - const sub = await config.get(db, 'email') - +export async function sendFollowNotification( + db: D1Database, + follower: Actor, + actor: Actor, + notificationId: string, + adminEmail: string, + vapidKeys: JWK +) { const data = { preferred_locale: 'en', notification_type: 'follow', @@ -58,16 +62,21 @@ export async function sendFollowNotification(db: D1Database, follower: Actor, ac const message: WebPushMessage = { data: JSON.stringify(data), urgency: 'normal', - sub, + sub: adminEmail, ttl: 60 * 24 * 7, } - return sendNotification(db, actor, message) + return sendNotification(db, actor, message, vapidKeys) } -export async function sendLikeNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { - const sub = await config.get(db, 'email') - +export async function sendLikeNotification( + db: D1Database, + fromActor: Actor, + actor: Actor, + notificationId: string, + adminEmail: string, + vapidKeys: JWK +) { const data = { preferred_locale: 'en', notification_type: 'favourite', @@ -80,16 +89,21 @@ export async function sendLikeNotification(db: D1Database, fromActor: Actor, act const message: WebPushMessage = { data: JSON.stringify(data), urgency: 'normal', - sub, + sub: adminEmail, ttl: 60 * 24 * 7, } - return sendNotification(db, actor, message) + return sendNotification(db, actor, message, vapidKeys) } -export async function sendMentionNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { - const sub = await config.get(db, 'email') - +export async function sendMentionNotification( + db: D1Database, + fromActor: Actor, + actor: Actor, + notificationId: string, + adminEmail: string, + vapidKeys: JWK +) { const data = { preferred_locale: 'en', notification_type: 'mention', @@ -102,16 +116,21 @@ export async function sendMentionNotification(db: D1Database, fromActor: Actor, const message: WebPushMessage = { data: JSON.stringify(data), urgency: 'normal', - sub, + sub: adminEmail, ttl: 60 * 24 * 7, } - return sendNotification(db, actor, message) + return sendNotification(db, actor, message, vapidKeys) } -export async function sendReblogNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { - const sub = await config.get(db, 'email') - +export async function sendReblogNotification( + db: D1Database, + fromActor: Actor, + actor: Actor, + notificationId: string, + adminEmail: string, + vapidKeys: JWK +) { const data = { preferred_locale: 'en', notification_type: 'reblog', @@ -124,15 +143,14 @@ export async function sendReblogNotification(db: D1Database, fromActor: Actor, a const message: WebPushMessage = { data: JSON.stringify(data), urgency: 'normal', - sub, + sub: adminEmail, ttl: 60 * 24 * 7, } - return sendNotification(db, actor, message) + return sendNotification(db, actor, message, vapidKeys) } -async function sendNotification(db: D1Database, actor: Actor, message: WebPushMessage) { - const vapidKeys = await getVAPIDKeys(db) +async function sendNotification(db: D1Database, actor: Actor, message: WebPushMessage, vapidKeys: JWK) { const subscriptions = await getSubscriptionForAllClients(db, actor) const promises = subscriptions.map(async (subscription) => { diff --git a/backend/src/mastodon/subscription.ts b/backend/src/mastodon/subscription.ts index e8dfac0..8246c04 100644 --- a/backend/src/mastodon/subscription.ts +++ b/backend/src/mastodon/subscription.ts @@ -124,15 +124,6 @@ function subscriptionFromRow(row: any): Subscription { } } -export async function getVAPIDKeys(db: D1Database): Promise { - const row: any = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first() - if (!row) { - throw new Error('missing VAPID keys') - } - const value: JWK = JSON.parse(row.value) - return value -} - export function VAPIDPublicKey(keys: JWK): string { return b64ToUrlEncoded(exportPublicKeyPair(keys)) } diff --git a/backend/src/types/env.ts b/backend/src/types/env.ts index dfc8411..f5a1d59 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -10,4 +10,10 @@ export interface Env { DOMAIN: string ACCESS_AUD: string ACCESS_AUTH_DOMAIN: string + + // Configuration for the instance + INSTANCE_TITLE: string + ADMIN_EMAIL: string + INSTANCE_DESCR: string + VAPID_JWK: string } diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts index e06e580..6f61c6b 100644 --- a/backend/test/activitypub.spec.ts +++ b/backend/test/activitypub.spec.ts @@ -1,7 +1,7 @@ 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 { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' 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' @@ -13,8 +13,10 @@ import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox' 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 domain = 'cloudflare.com' +const adminEmail = 'admin@example.com' describe('ActivityPub', () => { test('fetch non-existant user by id', async () => { @@ -74,7 +76,7 @@ describe('ActivityPub', () => { }, } - await activityHandler.handle(domain, activity, db, userKEK) + 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=?`) @@ -96,7 +98,7 @@ describe('ActivityPub', () => { object: 'a', } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK), { + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) @@ -114,7 +116,7 @@ describe('ActivityPub', () => { object: 'a', } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK), { + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) @@ -131,7 +133,7 @@ describe('ActivityPub', () => { object: 'a', } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK), { + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: '`activity.object` must be of type object', }) }) @@ -150,7 +152,7 @@ describe('ActivityPub', () => { }, } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK), { + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: 'object https://example.com/note2 does not exist', }) }) @@ -174,7 +176,7 @@ describe('ActivityPub', () => { object: object, } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK), { + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { message: 'actorid mismatch when updating object', }) }) @@ -204,7 +206,7 @@ describe('ActivityPub', () => { object: newObject, } - await activityHandler.handle(domain, activity, db, userKEK) + 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) @@ -276,8 +278,6 @@ describe('ActivityPub', () => { } const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { @@ -287,7 +287,7 @@ describe('ActivityPub', () => { cc: [], object: objectId, } - await activityHandler.handle(domain, activity, db, userKEK) + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first() assert(object) diff --git a/backend/test/activitypub/follow.spec.ts b/backend/test/activitypub/follow.spec.ts index 4be69c1..443d97f 100644 --- a/backend/test/activitypub/follow.spec.ts +++ b/backend/test/activitypub/follow.spec.ts @@ -1,5 +1,5 @@ import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' -import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import * as ap_followers_page from 'wildebeest/functions/ap/users/[id]/followers/page' import * as ap_following_page from 'wildebeest/functions/ap/users/[id]/following/page' import * as ap_followers from 'wildebeest/functions/ap/users/[id]/followers' @@ -11,6 +11,8 @@ import { createPerson } from 'wildebeest/backend/src/activitypub/actors' const userKEK = 'test_kek10' const domain = 'cloudflare.com' +const adminEmail = 'admin@example.com' +const vapidKeys = {} as JWK describe('ActivityPub', () => { describe('Follow', () => { @@ -34,8 +36,6 @@ describe('ActivityPub', () => { test('Receive follow with Accept reply', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') @@ -46,7 +46,7 @@ describe('ActivityPub', () => { object: actor.id.toString(), } - await activityHandler.handle(domain, activity, db, userKEK) + 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=?`) @@ -132,8 +132,6 @@ describe('ActivityPub', () => { test('creates a notification', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') @@ -144,7 +142,7 @@ describe('ActivityPub', () => { object: actor.id, } - await activityHandler.handle(domain, activity, db, userKEK) + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) const entry = await db.prepare('SELECT * FROM actor_notifications').first() assert.equal(entry.type, 'follow') diff --git a/backend/test/activitypub/inbox.spec.ts b/backend/test/activitypub/inbox.spec.ts index 7a096d6..13a7687 100644 --- a/backend/test/activitypub/inbox.spec.ts +++ b/backend/test/activitypub/inbox.spec.ts @@ -1,5 +1,5 @@ import { makeDB } from '../utils' -import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config' +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' @@ -8,6 +8,8 @@ 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() {}, @@ -20,14 +22,22 @@ describe('ActivityPub', () => { const db = await makeDB() const activity: any = {} - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + 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() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { @@ -41,7 +51,17 @@ describe('ActivityPub', () => { content: 'test note', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + 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 @@ -81,7 +101,17 @@ describe('ActivityPub', () => { content: 'test note', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + 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() @@ -90,8 +120,6 @@ describe('ActivityPub', () => { test('local actor sends Note with mention create notification', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -106,7 +134,17 @@ describe('ActivityPub', () => { content: 'test note', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -132,8 +170,6 @@ describe('ActivityPub', () => { } const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const activity: any = { @@ -147,7 +183,17 @@ describe('ActivityPub', () => { content: 'test note', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -156,8 +202,6 @@ describe('ActivityPub', () => { test('send Note records reply', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') { @@ -171,7 +215,17 @@ describe('ActivityPub', () => { content: 'post', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + const res = await ap_inbox.handleRequest( + domain, + db, + kv_cache, + 'sven', + activity, + userKEK, + waitUntil, + adminEmail, + vapidKeys + ) assert.equal(res.status, 200) } @@ -187,7 +241,17 @@ describe('ActivityPub', () => { content: 'reply', }, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + const res = await ap_inbox.handleRequest( + domain, + db, + kv_cache, + 'sven', + activity, + userKEK, + waitUntil, + adminEmail, + vapidKeys + ) assert.equal(res.status, 200) } @@ -206,8 +270,6 @@ describe('ActivityPub', () => { describe('Announce', () => { test('records reblog in db', async () => { const db = await makeDB() - await generateVAPIDKeys(db) - await configure(db, { title: 'title', description: 'a', email: 'email' }) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -218,7 +280,17 @@ describe('ActivityPub', () => { actor: actorB.id, object: note.id, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -228,8 +300,6 @@ describe('ActivityPub', () => { test('creates notification', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -240,7 +310,17 @@ describe('ActivityPub', () => { actor: actorB.id, object: note.id, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -254,8 +334,6 @@ describe('ActivityPub', () => { describe('Like', () => { test('records like in db', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -266,7 +344,17 @@ describe('ActivityPub', () => { actor: actorB.id, object: note.id, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -276,8 +364,6 @@ describe('ActivityPub', () => { test('creates notification', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -288,7 +374,17 @@ describe('ActivityPub', () => { actor: actorB.id, object: note.id, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() @@ -299,8 +395,6 @@ describe('ActivityPub', () => { test('records like in db', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') @@ -311,7 +405,17 @@ describe('ActivityPub', () => { actor: actorB.id, object: note.id, } - const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + 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() diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index 564b406..9adfbd5 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -1,4 +1,6 @@ import { strict as assert } from 'node:assert/strict' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' +import type { Env } from 'wildebeest/backend/src/types/env' import * as v1_instance from 'wildebeest/functions/api/v1/instance' import * as v2_instance from 'wildebeest/functions/api/v2/instance' import * as apps from 'wildebeest/functions/api/v1/apps' @@ -9,24 +11,29 @@ import { makeDB, assertCORS, assertJSON, assertCache, createTestClient } from '. import { createPerson } from 'wildebeest/backend/src/activitypub/actors' import { createSubscription } from '../src/mastodon/subscription' import * as subscription from 'wildebeest/functions/api/v1/push/subscription' -import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' const userKEK = 'test_kek' const domain = 'cloudflare.com' +async function generateVAPIDKeys(): Promise { + const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + const jwk = (await crypto.subtle.exportKey('jwk', keyPair.privateKey)) as JWK + return jwk +} + describe('Mastodon APIs', () => { describe('instance', () => { test('return the instance infos v1', async () => { - const db = await makeDB() - const data = { - title: 'title', - uri: 'uri', - email: 'email', - description: 'description', - } - await configure(db, data) + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: 'b', + INSTANCE_DESCR: 'c', + } as Env - const res = await v1_instance.handleRequest(domain, db) + const res = await v1_instance.handleRequest(domain, env) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) @@ -35,55 +42,60 @@ describe('Mastodon APIs', () => { const data = await res.json() assert.equal(data.rules.length, 0) assert.equal(data.uri, domain) + assert.equal(data.title, 'a') + assert.equal(data.email, 'b') + assert.equal(data.description, 'c') } }) test('adds a short_description if missing v1', async () => { - const db = await makeDB() - const data = { - title: 'title', - uri: 'uri', - email: 'email', - description: 'description', - } - await configure(db, data) + const env = { + INSTANCE_DESCR: 'c', + } as Env - const res = await v1_instance.handleRequest(domain, db) + const res = await v1_instance.handleRequest(domain, env) assert.equal(res.status, 200) { const data = await res.json() - assert.equal(data.short_description, 'description') + assert.equal(data.short_description, 'c') } }) test('return the instance infos v2', async () => { const db = await makeDB() - const data = { - title: 'title', - uri: 'uri', - email: 'email', - description: 'description', - } - await configure(db, data) - const res = await v2_instance.handleRequest(domain, db) + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: 'b', + INSTANCE_DESCR: 'c', + } as Env + const res = await v2_instance.handleRequest(domain, db, env) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) + + { + const data = await res.json() + assert.equal(data.rules.length, 0) + assert.equal(data.domain, domain) + assert.equal(data.title, 'a') + assert.equal(data.contact.email, 'b') + assert.equal(data.description, 'c') + } }) }) describe('apps', () => { test('return the app infos', async () => { const db = await makeDB() - await generateVAPIDKeys(db) + const vapidKeys = await generateVAPIDKeys() const request = new Request('https://example.com', { method: 'POST', body: '{"redirect_uris":"mastodon://joinmastodon.org/oauth","website":"https://app.joinmastodon.org/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}', }) - const res = await apps.handleRequest(db, request) + const res = await apps.handleRequest(db, request, vapidKeys) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) @@ -100,11 +112,14 @@ describe('Mastodon APIs', () => { }) test('returns 404 for GET request', async () => { + const vapidKeys = await generateVAPIDKeys() const request = new Request('https://example.com') const ctx: any = { next: () => new Response(), data: null, - env: {}, + env: { + VAPID_JWK: JSON.stringify(vapidKeys), + }, request, } @@ -169,8 +184,8 @@ describe('Mastodon APIs', () => { test('create subscription', async () => { const db = await makeDB() const client = await createTestClient(db) - await generateVAPIDKeys(db) const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const vapidKeys = await generateVAPIDKeys() const data: any = { subscription: { @@ -190,7 +205,7 @@ describe('Mastodon APIs', () => { body: JSON.stringify(data), }) - const res = await subscription.handlePostRequest(db, req, connectedActor, client.id) + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) assert.equal(res.status, 200) const row: any = await db.prepare('SELECT * FROM subscriptions').first() @@ -202,8 +217,8 @@ describe('Mastodon APIs', () => { test('create subscriptions only creates one', async () => { const db = await makeDB() const client = await createTestClient(db) - await generateVAPIDKeys(db) const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const vapidKeys = await generateVAPIDKeys() const data: any = { subscription: { @@ -225,7 +240,7 @@ describe('Mastodon APIs', () => { body: JSON.stringify(data), }) - const res = await subscription.handlePostRequest(db, req, connectedActor, client.id) + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) assert.equal(res.status, 200) const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first() diff --git a/backend/test/mastodon/accounts.spec.ts b/backend/test/mastodon/accounts.spec.ts index 821ba08..aaba5d9 100644 --- a/backend/test/mastodon/accounts.spec.ts +++ b/backend/test/mastodon/accounts.spec.ts @@ -1,5 +1,4 @@ import { strict as assert } from 'node:assert/strict' -import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' import * as accounts_following from 'wildebeest/functions/api/v1/accounts/[id]/following' @@ -418,8 +417,6 @@ describe('Mastodon APIs', () => { test('get remote actor statuses', async () => { const db = await makeDB() - await configure(db, { title: 'title', description: 'a', email: 'email' }) - await generateVAPIDKeys(db) const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') @@ -508,7 +505,6 @@ describe('Mastodon APIs', () => { test('get remote actor statuses ignoring object that fail to download', async () => { const db = await makeDB() - await generateVAPIDKeys(db) const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') await createPublicNote(domain, db, 'my localnote status', actor) diff --git a/backend/test/mastodon/notifications.spec.ts b/backend/test/mastodon/notifications.spec.ts index 93955eb..ad656fd 100644 --- a/backend/test/mastodon/notifications.spec.ts +++ b/backend/test/mastodon/notifications.spec.ts @@ -1,4 +1,5 @@ import * as notifications_get from 'wildebeest/functions/api/v1/notifications/[id]' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' import { createNotification, insertFollowNotification } from 'wildebeest/backend/src/mastodon/notification' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' @@ -7,13 +8,13 @@ import { makeDB, assertJSON, createTestClient } from '../utils' import { strict as assert } from 'node:assert/strict' import { sendLikeNotification } from 'wildebeest/backend/src/mastodon/notification' import { createSubscription } from 'wildebeest/backend/src/mastodon/subscription' -import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config' import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops' import { getNotifications } from 'wildebeest/backend/src/mastodon/notification' const userKEK = 'test_kek15' const domain = 'cloudflare.com' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const vapidKeys = {} as JWK function parseCryptoKey(s: string): any { const parts = s.split(';') @@ -92,8 +93,6 @@ describe('Mastodon APIs', () => { test('send like notification', async () => { const db = await makeDB() - await generateVAPIDKeys(db) - await configure(db, { title: 'title', description: 'a', email: 'email' }) const clientKeys = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ 'sign', @@ -140,7 +139,7 @@ describe('Mastodon APIs', () => { }) const fromActor = await createPerson(domain, db, userKEK, 'from@cloudflare.com') - await sendLikeNotification(db, fromActor, actor, 'notifid') + await sendLikeNotification(db, fromActor, actor, 'notifid', 'admin@example.com', vapidKeys) }) }) }) diff --git a/backend/test/start-instance.spec.ts b/backend/test/start-instance.spec.ts deleted file mode 100644 index b4e4e0e..0000000 --- a/backend/test/start-instance.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as startInstance from 'wildebeest/functions/start-instance' -import { TEST_JWT, ACCESS_CERTS } from './test-data' -import { strict as assert } from 'node:assert/strict' -import { makeDB } from './utils' - -const accessDomain = 'access.com' -const accessAud = 'abcd' - -describe('Wildebeest', () => { - globalThis.fetch = async (input: RequestInfo) => { - if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { - return new Response(JSON.stringify(ACCESS_CERTS)) - } - if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { - return new Response( - JSON.stringify({ - email: 'some@cloudflare.com', - }) - ) - } - throw new Error('unexpected request to ' + input) - } - - test('start instance should generate a VAPID key and store a JWK', async () => { - const db = await makeDB() - - const body = JSON.stringify({ - title: 'title', - description: 'description', - email: 'email', - }) - - const headers = { - cookie: 'CF_Authorization=' + TEST_JWT, - } - - const req = new Request('https://example.com', { method: 'POST', body, headers }) - const res = await startInstance.handlePostRequest(req, db, accessDomain, accessAud) - assert.equal(res.status, 201) - - const { value } = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first() - const jwk = JSON.parse(value) - - assert.equal(jwk.key_ops.length, 1) - assert.equal(jwk.key_ops[0], 'sign') - assert.equal(jwk.crv, 'P-256') - }) -}) diff --git a/frontend/mock-db/init.ts b/frontend/mock-db/init.ts index c570246..92c9504 100644 --- a/frontend/mock-db/init.ts +++ b/frontend/mock-db/init.ts @@ -3,20 +3,12 @@ import * as statusesAPI from 'wildebeest/functions/api/v1/statuses' import { statuses } from 'wildebeest/frontend/src/dummyData' import type { MastodonStatus } from 'wildebeest/frontend/src/types' import type { MastodonAccount } from 'wildebeest/backend/src/types' -import { configure } from 'wildebeest/backend/src/config' const kek = 'test-kek' /** * Run helper commands to initialize the database with actors, statuses, etc. */ export async function init(domain: string, db: D1Database) { - configure(db, { - title: 'Wildebeest', - email: '', - description: 'Wildebeest dev instance', - thumbnail: '/assets/wildebeest-logo.png', - }) - for (const status of statuses as MastodonStatus[]) { const actor = await getOrCreatePerson(domain, db, status.account.username) await createStatus(db, actor, status.content) diff --git a/frontend/src/routes/(admin)/start-instance/index.tsx b/frontend/src/routes/(admin)/start-instance/index.tsx deleted file mode 100644 index 6ec16aa..0000000 --- a/frontend/src/routes/(admin)/start-instance/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { $, component$, useStore, useClientEffect$, useSignal } from '@builder.io/qwik' -import { DocumentHead } from '@builder.io/qwik-city' -import { WildebeestLogo } from '~/components/MastodonLogo' -import { useDomain } from '~/utils/useDomain' -import Step1 from './step-1' -import { type InstanceConfig, testInstance } from './utils' - -export default component$(() => { - const domain = useDomain() - - const loading = useSignal(true) - const instanceConfigured = useSignal(false) - - const instanceConfig = useStore({ - title: `${domain} Wildebeest`, - email: `admin@${domain}`, - description: 'My personal Wildebeest instance (powered by Cloudflare)', - }) - - useClientEffect$(async () => { - if (await testInstance()) { - instanceConfigured.value = true - } - loading.value = false - }) - - const getStepToShow = () => { - if (loading.value) return 'loading' - if (!instanceConfigured.value) return 'step-1' - return 'all-good' - } - - const stepToShow = getStepToShow() - - const setLoading = $((value: boolean) => { - loading.value = value - }) - - const setInstanceConfigured = $((value: boolean) => { - instanceConfigured.value = value - }) - - return ( -
-

- -

- {stepToShow.startsWith('step-') && ( -
-

Welcome to Wildebeest...

-

Your instance hasn't been configured yet.

-
- )} - {stepToShow === 'loading' &&

Loading...

} - {stepToShow === 'step-1' && ( - - )} - {stepToShow === 'all-good' &&

All good, your instance is ready.

} -
- ) -}) - -export const head: DocumentHead = () => { - return { - title: 'Wildebeest Start Instance', - meta: [ - { - name: 'description', - content: 'Wildebeest Instance Setup page', - }, - ], - } -} diff --git a/frontend/src/routes/(admin)/start-instance/step-1.tsx b/frontend/src/routes/(admin)/start-instance/step-1.tsx deleted file mode 100644 index 13f1a3e..0000000 --- a/frontend/src/routes/(admin)/start-instance/step-1.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { component$, QRL } from '@builder.io/qwik' -import { configure, type InstanceConfig, testInstance } from './utils' - -interface Props { - instanceConfig: InstanceConfig - setLoading: QRL<(loading: boolean) => void> - setInstanceConfigured: QRL<(configured: boolean) => void> -} - -export default component$(({ instanceConfig, setLoading, setInstanceConfigured }) => { - return ( - <> -

Configure your instance

- -
- -
- (instanceConfig.title = (ev.target as HTMLInputElement).value)} - /> -
-
- -
- -
- (instanceConfig.email = (ev.target as HTMLInputElement).value)} - /> -
-
- -
- -
- (instanceConfig.description = (ev.target as HTMLInputElement).value)} - /> -
-
- - - - ) -}) diff --git a/frontend/src/routes/(admin)/start-instance/utils.ts b/frontend/src/routes/(admin)/start-instance/utils.ts deleted file mode 100644 index 707edf1..0000000 --- a/frontend/src/routes/(admin)/start-instance/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type InstanceConfig = { - title?: string - email?: string - description?: string -} - -export async function configure(data: InstanceConfig) { - const res = await fetch('/start-instance', { - method: 'POST', - body: JSON.stringify(data), - }) - if (!res.ok) { - throw new Error('/start-instance returned: ' + res.status) - } -} - -export async function testInstance(): Promise { - const res = await fetch('/api/v1/instance') - const data = await res.json<{ title?: string }>() - return !!data.title -} diff --git a/frontend/src/routes/(frontend)/layout.tsx b/frontend/src/routes/(frontend)/layout.tsx index 3952c32..078c99c 100644 --- a/frontend/src/routes/(frontend)/layout.tsx +++ b/frontend/src/routes/(frontend)/layout.tsx @@ -1,4 +1,5 @@ import { component$, Slot, useContextProvider } from '@builder.io/qwik' +import type { Env } from 'wildebeest/backend/src/types/env' import { DocumentHead, loader$ } from '@builder.io/qwik-city' import * as instance from 'wildebeest/functions/api/v1/instance' import type { InstanceConfig } from 'wildebeest/backend/src/types/configs' @@ -8,18 +9,24 @@ import { WildebeestLogo } from '~/components/MastodonLogo' import { getCommitHash } from '~/utils/getCommitHash' import { InstanceConfigContext } from '~/utils/instanceConfig' -export const instanceLoader = loader$<{ DATABASE: D1Database }, Promise>( - async ({ platform, redirect }) => { - const response = await instance.handleRequest('', platform.DATABASE) - const results = await response.text() - const json = JSON.parse(results) as InstanceConfig - if (!json.title) { - // If there is no title set then we have not configured the instance - throw redirect(302, '/start-instance') - } - return json +export const instanceLoader = loader$< + { DATABASE: D1Database; INSTANCE_TITLE: string; INSTANCE_DESCR: string; ADMIN_EMAIL: string }, + Promise +>(async ({ platform, redirect }) => { + const env = { + INSTANCE_DESCR: platform.INSTANCE_DESCR, + INSTANCE_TITLE: platform.INSTANCE_TITLE, + ADMIN_EMAIL: platform.ADMIN_EMAIL, + } as Env + const response = await instance.handleRequest('', platform.DATABASE, env) + const results = await response.text() + const json = JSON.parse(results) as InstanceConfig + if (!json.title) { + // If there is no title set then we have not configured the instance + throw redirect(302, '/start-instance') } -) + return json +}) export default component$(() => { useContextProvider(InstanceConfigContext, instanceLoader.use().value) diff --git a/functions/ap/users/[id]/inbox.ts b/functions/ap/users/[id]/inbox.ts index d9b8acf..4e8a0b8 100644 --- a/functions/ap/users/[id]/inbox.ts +++ b/functions/ap/users/[id]/inbox.ts @@ -1,4 +1,5 @@ import { parseHandle } from 'wildebeest/backend/src/utils/parse' +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' @@ -9,6 +10,7 @@ import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigj import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage' import * as timeline from 'wildebeest/backend/src/mastodon/timeline' import * as notification from 'wildebeest/backend/src/mastodon/notification' +import { getVAPIDKeys } from 'wildebeest/backend/src/config' export const onRequest: PagesFunction = async ({ params, request, env, waitUntil }) => { const parsedSignature = parseRequest(request) @@ -29,7 +31,17 @@ export const onRequest: PagesFunction = async ({ params, request, env, const activity: Activity = JSON.parse(body) const domain = new URL(request.url).hostname - return handleRequest(domain, env.DATABASE, env.KV_CACHE, params.id as string, activity, env.userKEK, waitUntil) + return handleRequest( + domain, + env.DATABASE, + env.KV_CACHE, + params.id as string, + activity, + env.userKEK, + waitUntil, + env.ADMIN_EMAIL, + getVAPIDKeys(env) + ) } export async function handleRequest( @@ -39,7 +51,9 @@ export async function handleRequest( id: string, activity: Activity, userKEK: string, - waitUntil: (p: Promise) => void + waitUntil: (p: Promise) => void, + adminEmail: string, + vapidKeys: JWK ): Promise { const handle = parseHandle(id) @@ -53,7 +67,7 @@ export async function handleRequest( return new Response('', { status: 404 }) } - await activityHandler.handle(domain, activity, db, userKEK) + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) // Assuming we received new posts or a like, pregenerate the user's timelines // and notifications. diff --git a/functions/api/v1/apps.ts b/functions/api/v1/apps.ts index de7e186..40821d6 100644 --- a/functions/api/v1/apps.ts +++ b/functions/api/v1/apps.ts @@ -1,7 +1,9 @@ import { ContextData } from 'wildebeest/backend/src/types/context' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import { Env } from 'wildebeest/backend/src/types/env' import { createClient } from 'wildebeest/backend/src/mastodon/client' -import { getVAPIDKeys, VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' +import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' +import { getVAPIDKeys } from 'wildebeest/backend/src/config' type AppsPost = { redirect_uris: string @@ -11,10 +13,10 @@ type AppsPost = { } export const onRequest: PagesFunction = async ({ request, env }) => { - return handleRequest(env.DATABASE, request) + return handleRequest(env.DATABASE, request, getVAPIDKeys(env)) } -export async function handleRequest(db: D1Database, request: Request) { +export async function handleRequest(db: D1Database, request: Request, vapidKeys: JWK) { if (request.method !== 'POST') { return new Response('', { status: 400 }) } @@ -22,7 +24,7 @@ export async function handleRequest(db: D1Database, request: Request) { const body = await request.json() const client = await createClient(db, body.client_name, body.redirect_uris, body.website, body.scopes) - const vapidKey = VAPIDPublicKey(await getVAPIDKeys(db)) + const vapidKey = VAPIDPublicKey(vapidKeys) const res = { name: body.client_name, diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index 23d33d0..d42a143 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -1,46 +1,37 @@ import type { Env } from 'wildebeest/backend/src/types/env' +import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' const INSTANCE_VERSION = '4.0.2' export const onRequest: PagesFunction = async ({ env, request }) => { const domain = new URL(request.url).hostname - return handleRequest(domain, env.DATABASE) + return handleRequest(domain, env) } -export async function handleRequest(domain: string, db: D1Database) { +export async function handleRequest(domain: string, env: Env) { const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'content-type, authorization', 'content-type': 'application/json; charset=utf-8', } - const query = ` - SELECT * FROM instance_config WHERE key IN ('title', 'description', 'email', 'short_description', 'thumbnail') - ` - const { results, error, success } = await db.prepare(query).all() - if (!success) { - throw new Error('SQL error: ' + error) - } - const res: any = {} - if (results) { - for (let i = 0, len = results.length; i < len; i++) { - const row: any = results[i] - res[row.key] = row.value - } - } + + res.thumbnail = DEFAULT_THUMBNAIL // Registration is disabled because unsupported by Wildebeest. Users // should go through the login flow and authenticate with Access. // The documentation is incorrect and registrations is a boolean. res.registrations = false + res.version = INSTANCE_VERSION res.rules = [] res.uri = domain + res.title = env.INSTANCE_TITLE + res.email = env.ADMIN_EMAIL + res.description = env.INSTANCE_DESCR - if (!res.short_description) { - res.short_description = res.description - } + res.short_description = res.description return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/push/subscription.ts b/functions/api/v1/push/subscription.ts index 8dc85fa..826d53b 100644 --- a/functions/api/v1/push/subscription.ts +++ b/functions/api/v1/push/subscription.ts @@ -1,18 +1,20 @@ import { getClientById } from 'wildebeest/backend/src/mastodon/client' +import { getVAPIDKeys } from 'wildebeest/backend/src/config' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import { createSubscription, getSubscription } from 'wildebeest/backend/src/mastodon/subscription' import type { CreateRequest } from 'wildebeest/backend/src/mastodon/subscription' import { ContextData } from 'wildebeest/backend/src/types/context' import { Env } from 'wildebeest/backend/src/types/env' import * as errors from 'wildebeest/backend/src/errors' -import { getVAPIDKeys, VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' +import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' export const onRequestGet: PagesFunction = async ({ request, env, data }) => { return handleGetRequest(env.DATABASE, request, data.connectedActor, data.clientId) } export const onRequestPost: PagesFunction = async ({ request, env, data }) => { - return handlePostRequest(env.DATABASE, request, data.connectedActor, data.clientId) + return handlePostRequest(env.DATABASE, request, data.connectedActor, data.clientId, getVAPIDKeys(env)) } const headers = { @@ -52,7 +54,13 @@ export async function handleGetRequest(db: D1Database, request: Request, connect return new Response(JSON.stringify(res), { headers }) } -export async function handlePostRequest(db: D1Database, request: Request, connectedActor: Actor, clientId: string) { +export async function handlePostRequest( + db: D1Database, + request: Request, + connectedActor: Actor, + clientId: string, + vapidKeys: JWK +) { const client = await getClientById(db, clientId) if (client === null) { return errors.clientUnknown() @@ -66,7 +74,7 @@ export async function handlePostRequest(db: D1Database, request: Request, connec subscription = await createSubscription(db, connectedActor, client, data) } - const vapidKey = VAPIDPublicKey(await getVAPIDKeys(db)) + const vapidKey = VAPIDPublicKey(vapidKeys) const res = { id: 4, diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index 138385f..6e75426 100644 --- a/functions/api/v2/instance.ts +++ b/functions/api/v2/instance.ts @@ -1,44 +1,29 @@ import type { Env } from 'wildebeest/backend/src/types/env' +import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' import type { InstanceConfigV2 } from 'wildebeest/backend/src/types/configs' const INSTANCE_VERSION = '4.0.2' export const onRequest: PagesFunction = async ({ env, request }) => { const domain = new URL(request.url).hostname - return handleRequest(domain, env.DATABASE) + return handleRequest(domain, env.DATABASE, env) } -export async function handleRequest(domain: string, db: D1Database) { +export async function handleRequest(domain: string, db: D1Database, env: Env) { const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'content-type, authorization', 'content-type': 'application/json; charset=utf-8', } - const query = ` - SELECT * FROM instance_config WHERE key IN ('title', 'description', 'email', 'short_description', 'thumbnail') - ` - const { results, error, success } = await db.prepare(query).all() - if (!success) { - throw new Error('SQL error: ' + error) - } - - const config: any = {} - if (results) { - for (let i = 0, len = results.length; i < len; i++) { - const row: any = results[i] - config[row.key] = row.value - } - } - const res: InstanceConfigV2 = { domain, - title: config.title, + title: env.INSTANCE_TITLE, version: INSTANCE_VERSION, source_url: 'https://github.com/cloudflare/wildebeest', - description: config.description, + description: env.INSTANCE_DESCR, thumbnail: { - url: config.thumbnail, + url: DEFAULT_THUMBNAIL, }, languages: ['en'], registrations: { @@ -47,7 +32,7 @@ export async function handleRequest(domain: string, db: D1Database) { enabled: false, }, contact: { - email: config.email, + email: env.ADMIN_EMAIL, }, rules: [], } diff --git a/functions/start-instance-test-access.ts b/functions/start-instance-test-access.ts deleted file mode 100644 index c2c182a..0000000 --- a/functions/start-instance-test-access.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Env } from 'wildebeest/backend/src/types/env' -import * as access from 'wildebeest/backend/src/access' -import * as errors from 'wildebeest/backend/src/errors' -import { parse } from 'cookie' -import type { ContextData } from 'wildebeest/backend/src/types/context' - -export const onRequestGet: PagesFunction = async ({ request, env }) => { - return handleGetRequest(env, request) -} - -// Route to test if Access has been configured properly -export async function handleGetRequest(env: Env, request: Request): Promise { - const db = env.DATABASE - const query = ` - SELECT * FROM instance_config WHERE key IN ('accessDomain', 'accessAud') - ` - const { results, error, success } = await db.prepare(query).all() - if (!success) { - throw new Error('SQL error: ' + error) - } - - const data: any = {} - if (results) { - for (let i = 0, len = results.length; i < len; i++) { - const row: any = results[i] - data[row.key] = row.value - } - } - - const cookie = parse(request.headers.get('Cookie') || '') - const jwt = cookie['CF_Authorization'] - if (!jwt) { - return errors.notAuthorized('missing authorization') - } - - const domain = env.ACCESS_AUTH_DOMAIN - - const validator = access.generateValidator({ jwt, domain, aud: env.ACCESS_AUD }) - await validator(request) - - const identity = await access.getIdentity({ jwt, domain }) - if (!identity) { - return errors.notAuthorized('failed to load identity') - } - - return new Response(JSON.stringify({ email: identity.email })) -} diff --git a/functions/start-instance.ts b/functions/start-instance.ts deleted file mode 100644 index e9172f6..0000000 --- a/functions/start-instance.ts +++ /dev/null @@ -1,56 +0,0 @@ -// First screen to configure and start the instance -import type { Env } from 'wildebeest/backend/src/types/env' -import * as errors from 'wildebeest/backend/src/errors' -import * as access from 'wildebeest/backend/src/access' -import { parse } from 'cookie' -import type { InstanceConfig } from 'wildebeest/backend/src/config' -import * as config from 'wildebeest/backend/src/config' - -export const onRequestPost: PagesFunction = async ({ request, env }) => { - return handlePostRequest(request, env.DATABASE, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD) -} - -export const onRequestGet: PagesFunction = async (ctx) => { - const { request, env } = ctx - const cookie = parse(request.headers.get('Cookie') || '') - const jwt = cookie['CF_Authorization'] - if (!jwt) { - const url = access.generateLoginURL({ - redirectURL: new URL('/start-instance', 'https://' + env.DOMAIN), - domain: env.ACCESS_AUTH_DOMAIN, - aud: env.ACCESS_AUD, - }) - return Response.redirect(url) - } - - const frontend = await import('../frontend/server/entry.cloudflare-pages') - return frontend.onRequest(ctx) -} - -export async function handlePostRequest( - request: Request, - db: D1Database, - accessDomain: string, - accessAud: string -): Promise { - const data = await request.json() - - const cookie = parse(request.headers.get('Cookie') || '') - const jwt = cookie['CF_Authorization'] - if (!jwt) { - return new Response('', { status: 401 }) - } - - const validator = access.generateValidator({ jwt, domain: accessDomain, aud: accessAud }) - await validator(request) - - const identity = await access.getIdentity({ jwt, domain: accessDomain }) - if (!identity) { - return errors.notAuthorized('failed to load identity') - } - - await config.configure(db, data) - await config.generateVAPIDKeys(db) - - return new Response('', { status: 201 }) -} diff --git a/migrations/0000_initial.sql b/migrations/0000_initial.sql index 9d1f724..2c040a7 100644 --- a/migrations/0000_initial.sql +++ b/migrations/0000_initial.sql @@ -139,11 +139,6 @@ CREATE TABLE IF NOT EXISTS subscriptions ( FOREIGN KEY(client_id) REFERENCES clients(id) ); -CREATE TABLE IF NOT EXISTS instance_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5 ( type, name, diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs new file mode 100644 index 0000000..ce543d0 --- /dev/null +++ b/scripts/generate-vapid-keys.mjs @@ -0,0 +1,6 @@ +import { webcrypto } from 'node:crypto' + +const key = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); +const serverKey = await webcrypto.subtle.exportKey("jwk", key.privateKey); + +console.log(JSON.stringify(serverKey)); diff --git a/tf/main.tf b/tf/main.tf index 608b1b8..5d310e8 100644 --- a/tf/main.tf +++ b/tf/main.tf @@ -98,6 +98,7 @@ resource "cloudflare_pages_project" "wildebeest_pages_project" { INSTANCE_TITLE = var.wd_instance_title ADMIN_EMAIL = var.wd_admin_email INSTANCE_DESCR = var.wd_instance_description + VAPID_JWK = sensitive(file("${path.module}/vapid_jwk")) } kv_namespaces = { KV_CACHE = sensitive(cloudflare_workers_kv_namespace.wildebeest_cache.id)