0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

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
This commit is contained in:
Fabien O'Carroll 2024-04-17 15:34:44 +07:00 committed by Fabien 'egg' O'Carroll
parent 842098bd36
commit 885dc537d5
9 changed files with 237 additions and 1 deletions

View file

@ -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);

View file

@ -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) => {

View file

@ -0,0 +1,9 @@
export type Settings = {
ghost_public_key: string;
ghost_private_key: string;
testing: boolean;
};
export interface SettingsCache {
get<KeyType extends keyof Settings>(key: KeyType): Settings[KeyType];
}

View file

@ -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<string, string>): 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<string, string>): Map<string, string> {
return new Map(Object.entries(obj));
}
export class Actor extends Entity<ActorData> {
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
});
}
}

View file

@ -0,0 +1,8 @@
import ObjectID from 'bson-objectid';
import {Actor} from './actor.entity';
export interface ActorRepository {
getOne(username: string): Promise<Actor | null>
getOne(id: ObjectID): Promise<Actor | null>
save(actor: Actor): Promise<void>
}

View file

@ -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
}
};
}

View file

@ -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;
}
}

View file

@ -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');
}
}

View file

@ -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
}
]
};