From 885dc537d5343947ba74fe57dadb579cb2b6699f Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 17 Apr 2024 15:34:44 +0700 Subject: [PATCH] Added initial support for /.well-known/webfinger ref https://linear.app/tryghost/issue/MOM-26 ref https://linear.app/tryghost/issue/MOM-27 ref https://webfinger.net/spec/ WebFinger is the protocol which allows different ActivityPub implementers to find information about Actors, it's kinda like the entrypoint. Given a username like @user@site.com, we can look up the URL for the Actor at https://website.com/.well-known/webfinger?resource=acct:user@site.com This would then give us the info needed to discover the Actor's Inbox & Outbox --- ghost/core/core/boot.js | 8 ++ ghost/core/core/frontend/web/site.js | 16 ++++ .../src/common/types/settings-cache.type.ts | 9 +++ .../src/core/activitypub/actor.entity.ts | 79 +++++++++++++++++++ .../src/core/activitypub/actor.repository.ts | 8 ++ ghost/ghost/src/core/activitypub/types.ts | 28 +++++++ .../src/core/activitypub/webfinger.service.ts | 39 +++++++++ .../in-memory/actor.repository.in-memory.ts | 41 ++++++++++ .../src/nestjs/modules/admin-api.module.ts | 10 ++- 9 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 ghost/ghost/src/common/types/settings-cache.type.ts create mode 100644 ghost/ghost/src/core/activitypub/actor.entity.ts create mode 100644 ghost/ghost/src/core/activitypub/actor.repository.ts create mode 100644 ghost/ghost/src/core/activitypub/types.ts create mode 100644 ghost/ghost/src/core/activitypub/webfinger.service.ts create mode 100644 ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 7b5a1e151b..3644e39c97 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -388,6 +388,8 @@ async function initNestDependencies() { debug('Begin: initNestDependencies'); const GhostNestApp = require('@tryghost/ghost'); const providers = []; + const urlUtils = require('./shared/url-utils'); + const activityPubBaseUrl = new URL('activitypub', urlUtils.urlFor('api', {type: 'admin'}, true)); providers.push({ provide: 'logger', useValue: require('@tryghost/logging') @@ -400,6 +402,12 @@ async function initNestDependencies() { }, { provide: 'DomainEvents', useValue: require('@tryghost/domain-events') + }, { + provide: 'ActivityPubBaseURL', + useValue: activityPubBaseUrl + }, { + provide: 'SettingsCache', + useValue: require('./shared/settings-cache') }); for (const provider of providers) { GhostNestApp.addProvider(provider); diff --git a/ghost/core/core/frontend/web/site.js b/ghost/core/core/frontend/web/site.js index 347a537027..cbe9fadfa6 100644 --- a/ghost/core/core/frontend/web/site.js +++ b/ghost/core/core/frontend/web/site.js @@ -3,6 +3,7 @@ const path = require('path'); const express = require('../../shared/express'); const DomainEvents = require('@tryghost/domain-events'); const {MemberPageViewEvent} = require('@tryghost/member-events'); +const GhostNestApp = require('@tryghost/ghost'); // App requires const config = require('../../shared/config'); @@ -20,6 +21,7 @@ const siteRoutes = require('./routes'); const shared = require('../../server/web/shared'); const errorHandler = require('@tryghost/mw-error-handler'); const mw = require('./middleware'); +const labs = require('../../shared/labs'); const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`; const STATIC_MEDIA_URL_PREFIX = `/${constants.STATIC_MEDIA_URL_PREFIX}`; @@ -101,6 +103,20 @@ module.exports = function setupSiteApp(routerConfig) { // Recommendations well-known siteApp.use(mw.servePublicFile('built', '.well-known/recommendations.json', 'application/json', config.get('caching:publicAssets:maxAge'), {disableServerCache: true})); + siteApp.get('/.well-known/webfinger', async function (req, res, next) { + if (!labs.isSet('ActivityPub')) { + return next(); + } + const webfingerService = await GhostNestApp.resolve('WebFingerService'); + + try { + const result = await webfingerService.getResource(req.query.resource); + res.json(result); + } catch (err) { + next(err); + } + }); + // setup middleware for internal apps // @TODO: refactor this to be a proper app middleware hook for internal apps config.get('apps:internal').forEach((appName) => { diff --git a/ghost/ghost/src/common/types/settings-cache.type.ts b/ghost/ghost/src/common/types/settings-cache.type.ts new file mode 100644 index 0000000000..fe028d2e56 --- /dev/null +++ b/ghost/ghost/src/common/types/settings-cache.type.ts @@ -0,0 +1,9 @@ +export type Settings = { + ghost_public_key: string; + ghost_private_key: string; + testing: boolean; +}; + +export interface SettingsCache { + get(key: KeyType): Settings[KeyType]; +} diff --git a/ghost/ghost/src/core/activitypub/actor.entity.ts b/ghost/ghost/src/core/activitypub/actor.entity.ts new file mode 100644 index 0000000000..f93a28c746 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/actor.entity.ts @@ -0,0 +1,79 @@ +import ObjectID from 'bson-objectid'; +import {Entity} from '../../common/entity.base'; +import {ActivityPub} from './types'; + +type ActorData = { + username: string; + preferredUsername?: string; + publicKey: string; + privateKey: string; +}; + +type CreateActorData = ActorData & { + id? : ObjectID +}; + +function makeUrl(base: URL, props: Map): URL { + const url = new URL(base.href); + for (const [key, value] of props.entries()) { + url.searchParams.set(key, value); + } + return url; +} + +function toMap(obj: Record): Map { + return new Map(Object.entries(obj)); +} + +export class Actor extends Entity { + get username() { + return this.attr.username; + } + + getJSONLD(url: URL): ActivityPub.Actor & ActivityPub.RootObject { + const actor = makeUrl(url, toMap({ + type: 'actor', + id: this.id.toHexString() + })); + + const publicKey = makeUrl(url, toMap({ + type: 'key', + owner: this.id.toHexString() + })); + + const inbox = makeUrl(url, toMap({ + type: 'inbox', + owner: this.id.toHexString() + })); + + const outbox = makeUrl(url, toMap({ + type: 'outbox', + owner: this.id.toHexString() + })); + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Person', + id: actor.href, + inbox: inbox.href, + outbox: outbox.href, + username: this.attr.username, + preferredUsername: this.attr.preferredUsername, + publicKey: { + id: publicKey.href, + owner: actor.href, + publicKeyPem: this.attr.publicKey + } + }; + } + + static create(data: CreateActorData) { + return new Actor({ + id: data.id instanceof ObjectID ? data.id : undefined, + username: data.username, + preferredUsername: data.preferredUsername || data.username, + publicKey: data.publicKey, + privateKey: data.privateKey + }); + } +} diff --git a/ghost/ghost/src/core/activitypub/actor.repository.ts b/ghost/ghost/src/core/activitypub/actor.repository.ts new file mode 100644 index 0000000000..aadc232268 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/actor.repository.ts @@ -0,0 +1,8 @@ +import ObjectID from 'bson-objectid'; +import {Actor} from './actor.entity'; + +export interface ActorRepository { + getOne(username: string): Promise + getOne(id: ObjectID): Promise + save(actor: Actor): Promise +} diff --git a/ghost/ghost/src/core/activitypub/types.ts b/ghost/ghost/src/core/activitypub/types.ts new file mode 100644 index 0000000000..73f4fd34e5 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/types.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ActivityPub { + export type AnonymousObject = { + '@context': string | string[]; + id: null; + type: string | string[]; + }; + + export type RootObject = { + '@context': string | string []; + id: string; + type: string | string[]; + }; + + export type Object = RootObject | AnonymousObject; + + export type Actor = ActivityPub.Object & { + inbox: string; + outbox: string; + username?: string; + preferredUsername?: string; + publicKey?: { + id: string, + owner: string, + publicKeyPem: string + } + }; +} diff --git a/ghost/ghost/src/core/activitypub/webfinger.service.ts b/ghost/ghost/src/core/activitypub/webfinger.service.ts new file mode 100644 index 0000000000..b7f7c7de5d --- /dev/null +++ b/ghost/ghost/src/core/activitypub/webfinger.service.ts @@ -0,0 +1,39 @@ +import {Inject} from '@nestjs/common'; +import {ActorRepository} from './actor.repository'; + +const accountResource = /acct:(\w+)@(\w+)/; +export class WebFingerService { + constructor( + @Inject('ActorRepository') private repository: ActorRepository, + @Inject('ActivityPubBaseURL') private url: URL + ) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getResource(resource: string, rel?: string[]) { + const match = resource.match(accountResource); + if (!match) { + throw new Error('Invalid Resource'); + } + + const username = match[1]; + + const actor = await this.repository.getOne(username); + + if (!actor) { + throw new Error('not found'); + } + + const result = { + subject: resource, + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: actor.getJSONLD(this.url).id + } + ] + }; + + return result; + } +} diff --git a/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts b/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts new file mode 100644 index 0000000000..2b5f73dc72 --- /dev/null +++ b/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts @@ -0,0 +1,41 @@ +import {Actor} from '../../core/activitypub/actor.entity'; +import {ActorRepository} from '../../core/activitypub/actor.repository'; +import ObjectID from 'bson-objectid'; +import {Inject} from '@nestjs/common'; +import {SettingsCache} from '../../common/types/settings-cache.type'; + +export class ActorRepositoryInMemory implements ActorRepository { + actors: Actor[]; + + constructor(@Inject('SettingsCache') settingsCache: SettingsCache) { + this.actors = [ + Actor.create({ + id: ObjectID.createFromHexString('000000000000000000000000'), + username: 'index', + publicKey: settingsCache.get('ghost_public_key'), + privateKey: settingsCache.get('ghost_private_key') + }) + ]; + } + + private getOneByUsername(username: string) { + return this.actors.find(actor => actor.username === username) || null; + } + + private getOneById(id: ObjectID) { + return this.actors.find(actor => actor.id.equals(id)) || null; + } + + async getOne(identifier: string | ObjectID) { + if (identifier instanceof ObjectID) { + return this.getOneById(identifier); + } else { + return this.getOneByUsername(identifier); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async save(actor: Actor) { + throw new Error('Not Implemented'); + } +} diff --git a/ghost/ghost/src/nestjs/modules/admin-api.module.ts b/ghost/ghost/src/nestjs/modules/admin-api.module.ts index 533162d3ce..10fd032368 100644 --- a/ghost/ghost/src/nestjs/modules/admin-api.module.ts +++ b/ghost/ghost/src/nestjs/modules/admin-api.module.ts @@ -2,18 +2,26 @@ import {DynamicModule} from '@nestjs/common'; import {ExampleController} from '../../http/admin/controllers/example.controller'; import {ExampleService} from '../../core/example/example.service'; import {ExampleRepositoryInMemory} from '../../db/in-memory/example.repository.in-memory'; +import {ActorRepositoryInMemory} from '../../db/in-memory/actor.repository.in-memory'; +import {WebFingerService} from '../../core/activitypub/webfinger.service'; class AdminAPIModuleClass {} export const AdminAPIModule: DynamicModule = { module: AdminAPIModuleClass, controllers: [ExampleController], - exports: [ExampleService], + exports: [ExampleService, 'WebFingerService'], providers: [ ExampleService, { provide: 'ExampleRepository', useClass: ExampleRepositoryInMemory + }, { + provide: 'ActorRepository', + useClass: ActorRepositoryInMemory + }, { + provide: 'WebFingerService', + useClass: WebFingerService } ] };