import ObjectID from 'bson-objectid'; import {Knex} from 'knex'; import {IdentityTokenService} from '../identity-tokens/identity-token-service'; import fetch from 'node-fetch'; type ExpectedWebhook = { event: string; target_url: URL; api_version: string; secret: string; }; interface Logger { info(message: string): void warn(message: string): void error(message: string): void } export class ActivityPubService { private knex: Knex; private siteUrl: URL; private logging: Logger; private identityTokenService: IdentityTokenService; constructor( knex: Knex, siteUrl: URL, logging: Logger, identityTokenService: IdentityTokenService ) { this.knex = knex; this.siteUrl = siteUrl; this.logging = logging; this.identityTokenService = identityTokenService; } getExpectedWebhooks(secret: string): ExpectedWebhook[] { return [ { event: 'post.published', target_url: new URL('.ghost/activitypub/v1/webhooks/post/published', this.siteUrl), api_version: 'v5.100.0', secret }, { event: 'post.deleted', target_url: new URL('.ghost/activitypub/v1/webhooks/post/deleted', this.siteUrl), api_version: 'v5.100.0', secret }, { event: 'post.unpublished', target_url: new URL('.ghost/activitypub/v1/webhooks/post/unpublished', this.siteUrl), api_version: 'v5.100.0', secret }, { event: 'post.published.edited', target_url: new URL('.ghost/activitypub/v1/webhooks/post/updated', this.siteUrl), api_version: 'v5.100.0', secret } ]; } async checkWebhookState(expectedWebhooks: ExpectedWebhook[], integration: {id: string}) { this.logging.info(`Checking ActivityPub Webhook state`); const webhooks = await this.knex .select('*') .from('webhooks') .where('integration_id', '=', integration.id); if (webhooks.length !== expectedWebhooks.length) { this.logging.warn(`Expected ${expectedWebhooks.length} webhooks for ActivityPub`); return false; } for (const expectedWebhook of expectedWebhooks) { const foundWebhook = webhooks.find((webhook) => { return webhook.event === expectedWebhook.event && webhook.target_url === expectedWebhook.target_url.href && webhook.secret === expectedWebhook.secret; }); if (!foundWebhook) { this.logging.error(`Could not find webhook for ${expectedWebhook.event} ${expectedWebhook.target_url}`); return false; } } return true; } async getWebhookSecret(): Promise { try { const token = await this.getOwnerUserToken(); const res = await fetch(new URL('.ghost/activitypub/v1/site/', this.siteUrl), { headers: { Authorization: `Bearer ${token}` } }); const body = await res.json(); return body.webhook_secret; } catch (err: unknown) { this.logging.error(`Could not get webhook secret for ActivityPub ${err}`); return null; } } async disable() { await this.removeWebhooks(); await this.disableSite(); } async enable() { await this.initialiseWebhooks(); } async removeWebhooks() { const integration = await this.knex .select('*') .from('integrations') .where('slug', '=', 'ghost-activitypub') .andWhere('type', '=', 'internal') .first(); if (!integration) { this.logging.error('No ActivityPub integration found - cannot remove webhooks'); return; } await this.knex .del() .from('webhooks') .where('integration_id', '=', integration.id); } async initialiseWebhooks() { const integration = await this.knex .select('*') .from('integrations') .where('slug', '=', 'ghost-activitypub') .andWhere('type', '=', 'internal') .first(); if (!integration) { this.logging.error('No ActivityPub integration found - cannot initialise'); return; } const secret = await this.getWebhookSecret(); if (!secret) { this.logging.error('No webhook secret found - cannot initialise'); return; } const expectedWebhooks = this.getExpectedWebhooks(secret); const isInCorrectState = await this.checkWebhookState(expectedWebhooks, integration); if (isInCorrectState) { this.logging.info(`ActivityPub webhooks in correct state`); return; } this.logging.info(`ActivityPub webhooks in incorrect state, deleting all of them and starting fresh`); await this.knex .del() .from('webhooks') .where('integration_id', '=', integration.id); const webhooksToInsert = expectedWebhooks.map((expectedWebhook) => { return { id: (new ObjectID).toHexString(), event: expectedWebhook.event, target_url: expectedWebhook.target_url.href, api_version: expectedWebhook.api_version, name: `ActivityPub ${expectedWebhook.event} Webhook`, secret: secret, integration_id: integration.id, created_at: this.knex.raw('current_timestamp') }; }); await this.knex .insert(webhooksToInsert) .into('webhooks'); } async disableSite() { try { const token = await this.getOwnerUserToken(); await fetch(new URL('.ghost/activitypub/v1/site/', this.siteUrl), { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); } catch (err: unknown) { this.logging.error(`Could not disable ActivityPub for site: ${this.siteUrl} due to: ${err}`); } } private async getOwnerUserToken() { const ownerUser = await this.knex('users') .select('users.*') .join('roles_users', 'users.id', 'roles_users.user_id') .join('roles', 'roles.id', 'roles_users.role_id') .where('roles.name', 'Owner') .first(); return await this.identityTokenService.getTokenForUser(ownerUser.email, 'Owner'); } }