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:
parent
842098bd36
commit
885dc537d5
9 changed files with 237 additions and 1 deletions
|
@ -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);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
9
ghost/ghost/src/common/types/settings-cache.type.ts
Normal file
9
ghost/ghost/src/common/types/settings-cache.type.ts
Normal 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];
|
||||
}
|
79
ghost/ghost/src/core/activitypub/actor.entity.ts
Normal file
79
ghost/ghost/src/core/activitypub/actor.entity.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
8
ghost/ghost/src/core/activitypub/actor.repository.ts
Normal file
8
ghost/ghost/src/core/activitypub/actor.repository.ts
Normal 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>
|
||||
}
|
28
ghost/ghost/src/core/activitypub/types.ts
Normal file
28
ghost/ghost/src/core/activitypub/types.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
39
ghost/ghost/src/core/activitypub/webfinger.service.ts
Normal file
39
ghost/ghost/src/core/activitypub/webfinger.service.ts
Normal 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;
|
||||
}
|
||||
}
|
41
ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts
Normal file
41
ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue