Supported Ghost2Ghost Follow/Accept

ref https://linear.app/tryghost/issue/MOM-108

Apologies to my future self and maintainers if you come across this commit.

This is a bit of a mega commit because we need to cut corners somewhere and it
came down to commit atomicity or tests/code quality.

The main changes here are a bunch of tests, as well as some scaffolding for
Inbox handling of Activities and delivery of Activities. The structure is not
final at all - and we have logic split across services which isn't ideal - but
thsi will do for now as we play around and discover the structure through
building.
This commit is contained in:
Fabien O'Carroll 2024-05-15 12:23:45 +07:00 committed by Fabien 'egg' O'Carroll
parent ba1d36bcda
commit df1774d8e9
31 changed files with 1345 additions and 66 deletions

View File

@ -1 +1,2 @@
declare module '@tryghost/errors';
declare module '@tryghost/domain-events';

View File

@ -1,7 +1,7 @@
export type Settings = {
ghost_public_key: string;
ghost_private_key: string;
testing: boolean;
title: string;
};
export interface SettingsCache {

View File

@ -0,0 +1,55 @@
import assert from 'assert';
import {Activity} from './activity.entity';
import {URI} from './uri.object';
describe('Activity', function () {
describe('fromJSONLD', function () {
it('Can construct an entity from JSONLD with various id types', async function () {
const input = {
id: new URI('https://site.com/activity'),
type: 'Follow',
actor: {
id: 'https://site.com/actor'
},
object: 'https://site.com/object'
};
const activity = Activity.fromJSONLD(input);
assert(activity);
});
it('Will throw for unknown types', async function () {
const input = {
id: new URI('https://site.com/activity'),
type: 'Unknown',
actor: {
id: 'https://site.com/actor'
},
object: 'https://site.com/object'
};
assert.throws(() => {
Activity.fromJSONLD(input);
});
});
it('Will throw for missing actor,object or type', async function () {
const input = {
id: new URI('https://site.com/activity'),
type: 'Unknown',
actor: {
id: 'https://site.com/actor'
},
object: 'https://site.com/object'
};
for (const prop of ['actor', 'object', 'type']) {
const modifiedInput = Object.create(input);
delete modifiedInput[prop];
assert.throws(() => {
Activity.fromJSONLD(modifiedInput);
});
}
});
});
});

View File

@ -0,0 +1,88 @@
import {Entity} from '../../common/entity.base';
import {ActivityPub} from './types';
import {URI} from './uri.object';
type ActivityData = {
activity: URI | null;
type: ActivityPub.ActivityType;
actor: URI;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: {id: URI, [x: string]: any};
to: URI | null;
}
function getURI(input: unknown) {
if (input instanceof URI) {
return input;
}
if (typeof input === 'string') {
return new URI(input);
}
if (typeof input !== 'object' || input === null) {
throw new Error(`Could not create URI from ${JSON.stringify(input)}`);
}
if ('id' in input && typeof input.id === 'string') {
return new URI(input.id);
}
throw new Error(`Could not create URI from ${JSON.stringify(input)}`);
}
function checkKeys<T extends string>(keys: T[], obj: object): Record<T, unknown> {
for (const key of keys) {
if (!(key in obj)) {
throw new Error(`Missing key ${key}`);
}
}
return obj as Record<T, unknown>;
}
export class Activity extends Entity<ActivityData> {
get type() {
return this.attr.type;
}
getObject() {
return this.attr.object;
}
get actorId() {
return this.attr.actor;
}
get objectId() {
return this.attr.object.id;
}
get activityId() {
return this.attr.activity;
}
getJSONLD(url: URL): ActivityPub.Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: this.activityId?.getValue(url) || null,
type: this.attr.type,
actor: {
type: 'Person',
id: this.actorId.getValue(url),
username: `@index@${this.actorId.hostname}`
},
object: this.objectId.getValue(url),
to: this.attr.to?.getValue(url) || null
};
}
static fromJSONLD(json: object) {
const parsed = checkKeys(['type', 'actor', 'object'], json);
if (typeof parsed.type !== 'string' || !['Create', 'Follow', 'Accept'].includes(parsed.type)) {
throw new Error(`Unknown type ${parsed.type}`);
}
return new Activity({
activity: 'id' in json ? getURI(json.id) : null,
type: parsed.type as ActivityPub.ActivityType,
actor: getURI(parsed.actor),
object: {id: getURI(parsed.object)},
to: 'to' in json ? getURI(json.to) : null
});
}
}

View File

@ -1,12 +1,14 @@
import {BaseEvent} from '../../common/event.base';
import {Activity} from './activity.object';
import {Activity} from './activity.entity';
import {Actor} from './actor.entity';
type ActivityEventData = {
activity: Activity
activity: Activity,
actor: Actor
}
export class ActivityEvent extends BaseEvent<ActivityEventData> {
static create(activity: Activity) {
return new ActivityEvent({activity});
static create(activity: Activity, actor: Actor) {
return new ActivityEvent({activity, actor});
}
}

View File

@ -1,27 +0,0 @@
import {Actor} from './actor.entity';
import {Article} from './article.object';
import {ActivityPub} from './types';
type ActivityData = {
type: ActivityPub.ActivityType;
actor: Actor;
object: Article
}
export class Activity {
constructor(private readonly attr: ActivityData) {}
getJSONLD(url: URL): ActivityPub.Activity {
const actor = this.attr.actor.getJSONLD(url);
const object = this.attr.object.getJSONLD(url);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: null,
type: 'Create',
summary: `${actor.name} created an ${object.type.toLowerCase()}.`,
actor: actor.id,
object: object.id
};
}
}

View File

@ -0,0 +1,6 @@
import {Activity} from './activity.entity';
export interface ActivityRepository {
getOne(id: URL): Promise<Activity | null>
save(activity: Activity): Promise<void>
}

View File

@ -0,0 +1,129 @@
import ObjectID from 'bson-objectid';
import {ActivityService} from './activity.service';
import {Actor} from './actor.entity';
import assert from 'assert';
describe('ActivityService', function () {
describe('#createArticleForPost', function () {
it('Adds a Create activity for an Article Object to the default actors Outbox', async function () {
const actor = Actor.create({username: 'testing'});
const mockActorRepository = {
async getOne() {
return actor;
},
async save() {}
};
const mockPostRepository = {
async getOne(id: ObjectID) {
return {
id: id,
title: 'Testing',
slug: 'testing',
html: '<p> Testing stuff.. </p>',
visibility: 'public'
};
}
};
const service = new ActivityService(
mockActorRepository,
mockPostRepository
);
const postId = new ObjectID();
await service.createArticleForPost(postId);
const found = actor.outbox.find(activity => activity.type === 'Create');
assert.ok(found);
});
it('Does not add a Create activity for non public posts', async function () {
const actor = Actor.create({username: 'testing'});
const mockActorRepository = {
async getOne() {
return actor;
},
async save() {}
};
const mockPostRepository = {
async getOne(id: ObjectID) {
return {
id: id,
title: 'Testing',
slug: 'testing',
html: '<p> Testing stuff.. </p>',
visibility: 'private'
};
}
};
const service = new ActivityService(
mockActorRepository,
mockPostRepository
);
const postId = new ObjectID();
await service.createArticleForPost(postId);
const found = actor.outbox.find(activity => activity.type === 'Create');
assert.ok(!found);
});
it('Throws when post is not found', async function () {
const actor = Actor.create({username: 'testing'});
const mockActorRepository = {
async getOne() {
return actor;
},
async save() {}
};
const mockPostRepository = {
async getOne() {
return null;
}
};
const service = new ActivityService(
mockActorRepository,
mockPostRepository
);
const postId = new ObjectID();
await assert.rejects(async () => {
await service.createArticleForPost(postId);
}, /Post not found/);
});
it('Throws when actor is not found', async function () {
const mockActorRepository = {
async getOne() {
return null;
},
async save() {}
};
const mockPostRepository = {
async getOne(id: ObjectID) {
return {
id: id,
title: 'Testing',
slug: 'testing',
html: '<p> Testing stuff.. </p>',
visibility: 'private'
};
}
};
const service = new ActivityService(
mockActorRepository,
mockPostRepository
);
const postId = new ObjectID();
await assert.rejects(async () => {
await service.createArticleForPost(postId);
}, /Actor not found/);
});
});
});

View File

@ -0,0 +1,115 @@
import assert from 'assert';
import {ActivityPubService} from './activitypub.service';
import {ActorRepository} from './actor.repository';
import {WebFingerService} from './webfinger.service';
import {Actor} from './actor.entity';
import Sinon from 'sinon';
import {URI} from './uri.object';
describe('ActivityPubService', function () {
describe('#follow', function () {
it('Throws if it cannot find the default actor', async function () {
const mockWebFingerService: WebFingerService = {
async finger() {
return {};
}
} as unknown as WebFingerService;
const mockActorRepository = {
async getOne() {
return null;
}
} as unknown as ActorRepository;
const service = new ActivityPubService(
mockWebFingerService,
mockActorRepository
);
await assert.rejects(async () => {
await service.follow('@egg@ghost.org');
}, /Could not find default actor/);
});
it('Follows the actor and saves', async function () {
const mockWebFingerService: WebFingerService = {
finger: Sinon.stub().resolves({
id: 'https://example.com/user-to-follow'
})
} as unknown as WebFingerService;
const actor = Actor.create({username: 'testing'});
const mockActorRepository = {
getOne: Sinon.stub().resolves(actor),
save: Sinon.stub().resolves()
};
const service = new ActivityPubService(
mockWebFingerService,
mockActorRepository
);
const followStub = Sinon.stub(actor, 'follow');
await service.follow('@egg@ghost.org');
assert(followStub.calledWithMatch({
id: new URI('https://example.com/user-to-follow'),
username: '@egg@ghost.org'
}));
assert(mockActorRepository.save.calledWith(actor));
});
});
describe('#getFollowing', function () {
it('Throws if the default actor is not found', async function () {
const mockWebFingerService: WebFingerService = {
finger: Sinon.stub().resolves({
id: 'https://example.com/user-to-follow'
})
} as unknown as WebFingerService;
const mockActorRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().resolves()
};
const service = new ActivityPubService(
mockWebFingerService,
mockActorRepository
);
await assert.rejects(async () => {
await service.getFollowing();
}, /Could not find default actor/);
});
it('Returns a list of the default actors following', async function () {
const mockWebFingerService: WebFingerService = {
finger: Sinon.stub().resolves({
id: 'https://example.com/user-to-follow'
})
} as unknown as WebFingerService;
const actor = Actor.create({
username: 'testing',
following: [{
id: new URI('https://site.com/user'),
username: '@person@site.com'
}]
});
const mockActorRepository = {
getOne: Sinon.stub().resolves(actor),
save: Sinon.stub().resolves()
};
const service = new ActivityPubService(
mockWebFingerService,
mockActorRepository
);
const result = await service.getFollowing();
assert.deepEqual(result, [{
id: 'https://site.com/user',
username: '@person@site.com'
}]);
});
});
});

View File

@ -0,0 +1,44 @@
import {Inject} from '@nestjs/common';
import {ActorRepository} from './actor.repository';
import {WebFingerService} from './webfinger.service';
import {URI} from './uri.object';
export class ActivityPubService {
constructor(
private readonly webfinger: WebFingerService,
@Inject('ActorRepository') private readonly actors: ActorRepository
) {}
async follow(username: string): Promise<void> {
const json = await this.webfinger.finger(username);
const actor = await this.actors.getOne('index');
if (!actor) {
throw new Error('Could not find default actor');
}
const actorToFollow = {
id: new URI(json.id),
username
};
actor.follow(actorToFollow);
await this.actors.save(actor);
}
async getFollowing(): Promise<{id: string, username?: string}[]> {
const actor = await this.actors.getOne('index');
if (!actor) {
throw new Error('Could not find default actor');
}
return actor.following.map((x) => {
return {
id: x.id.href,
username: x.username
};
});
}
}

View File

@ -2,23 +2,58 @@ import crypto from 'node:crypto';
import {Actor} from './actor.entity';
import {HTTPSignature} from './http-signature.service';
import assert from 'node:assert';
import {URI} from './uri.object';
import {ActivityEvent} from './activity.event';
import {Activity} from './activity.entity';
import {Article} from './article.object';
import ObjectID from 'bson-objectid';
describe('Actor', function () {
describe('getters', function () {
describe('displayName', function () {
it('Uses displayName, but falls back to username', function () {
const hasDisplayName = Actor.create({
username: 'username',
displayName: 'displayName'
});
const doesnaeHaveDisplayName = Actor.create({
username: 'username'
});
assert.equal(hasDisplayName.displayName, 'displayName');
assert.equal(doesnaeHaveDisplayName.displayName, 'username');
});
});
});
describe('#createArticle', function () {
it('Adds an activity to the outbox', function () {
const actor = Actor.create({username: 'username'});
const article = Article.fromPost({
id: new ObjectID(),
title: 'Post Title',
slug: 'post-slug',
html: '<p>Hello world</p>',
visibility: 'public'
});
actor.createArticle(article);
const found = actor.outbox.find((value) => {
return value.type === 'Create';
});
assert.ok(found);
});
});
describe('#sign', function () {
it('returns a request with a valid Signature header', async function () {
const keypair = crypto.generateKeyPairSync('rsa', {
modulusLength: 512
});
const baseUrl = new URL('https://example.com/ap');
const actor = Actor.create({
username: 'Testing',
outbox: [],
publicKey: keypair.publicKey
.export({type: 'pkcs1', format: 'pem'})
.toString(),
privateKey: keypair.privateKey
.export({type: 'pkcs1', format: 'pem'})
.toString()
username: 'Testing'
});
const url = new URL('https://some-server.com/users/username/inbox');
@ -54,4 +89,104 @@ describe('Actor', function () {
assert.equal(actual, expected, 'The signature should have been valid');
});
});
describe('#follow', function () {
it('Creates a Follow activity', async function () {
const actor = Actor.create({username: 'TestingFollow'});
const actorToFollow = {
id: new URI('https://activitypub.server/actor'),
username: '@user@domain'
};
actor.follow(actorToFollow);
Actor.getActivitiesToSave(actor, function (activities) {
const followActivity = activities.find(activity => activity.type === 'Follow');
assert.equal(followActivity?.objectId.href, actorToFollow.id.href);
});
Actor.getEventsToDispatch(actor, function (events) {
const followActivityEvent: ActivityEvent = (events.find(event => (event as ActivityEvent).data.activity?.type === 'Follow') as ActivityEvent);
assert.equal(followActivityEvent.data.activity.objectId.href, actorToFollow.id.href);
});
});
});
describe('#postToInbox', function () {
it('Handles Follow activities', async function () {
const actor = Actor.create({username: 'TestingPostToInbox'});
const newFollower = new URI('https://activitypub.server/actor');
const followActivity = new Activity({
activity: new URI(`https://activitypub.server/activity`),
type: 'Follow',
actor: newFollower,
object: {id: actor.actorId},
to: actor.actorId
});
await actor.postToInbox(followActivity);
assert(actor.followers.find(follower => follower.id.href === newFollower.href));
});
it('Throws if the Follow activity is anonymous', async function () {
const actor = Actor.create({username: 'TestingPostToInbox'});
const newFollower = new URI('https://activitypub.server/actor');
const followActivity = new Activity({
activity: null,
type: 'Follow',
actor: newFollower,
object: {id: actor.actorId},
to: actor.actorId
});
let error: unknown = null;
try {
await actor.postToInbox(followActivity);
} catch (err) {
error = err;
}
assert.ok(error);
});
it('Handles Accept activities', async function () {
const actor = Actor.create({username: 'TestingPostToInbox'});
const newFollower = new URI('https://activitypub.server/actor');
const activity = new Activity({
activity: null,
type: 'Accept',
actor: newFollower,
object: {
id: newFollower
},
to: actor.actorId
});
await actor.postToInbox(activity);
assert(actor.following.find(follower => follower.id.href === newFollower.href));
});
});
describe('#toJSONLD', function () {
it('Returns valid JSONLD', async function () {
const actor = Actor.create({username: 'TestingJSONLD'});
const baseUrl = new URL('https://example.com');
const jsonld = actor.getJSONLD(baseUrl);
assert.ok(jsonld);
});
});
});

View File

@ -2,16 +2,21 @@ import crypto from 'crypto';
import ObjectID from 'bson-objectid';
import {Entity} from '../../common/entity.base';
import {ActivityPub} from './types';
import {Activity} from './activity.object';
import {Activity} from './activity.entity';
import {Article} from './article.object';
import {ActivityEvent} from './activity.event';
import {HTTPSignature} from './http-signature.service';
import {URI} from './uri.object';
type ActorData = {
username: string;
displayName?: string;
publicKey: string;
privateKey: string;
outbox: Activity[];
inbox: Activity[];
following: {id: URI, username?: string;}[];
followers: {id: URI;}[];
};
type CreateActorData = ActorData & {
@ -23,16 +28,83 @@ export class Actor extends Entity<ActorData> {
return this.attr.username;
}
get displayName() {
if (this.attr.displayName) {
return this.attr.displayName;
}
return this.username;
}
get outbox() {
return this.attr.outbox;
}
get following() {
return this.attr.following;
}
get followers() {
return this.attr.followers;
}
get actorId() {
return new URI(`actor/${this.id.toHexString()}`);
}
async sign(request: Request, baseUrl: URL): Promise<Request> {
const keyId = new URL(this.getJSONLD(baseUrl).publicKey.id);
const key = crypto.createPrivateKey(this.attr.privateKey);
return HTTPSignature.sign(request, keyId, key);
}
public readonly publicAccount = true;
async postToInbox(activity: Activity) {
this.attr.inbox.unshift(activity);
if (activity.type === 'Follow') {
if (this.publicAccount) {
await this.acceptFollow(activity);
return;
}
}
if (activity.type === 'Accept') {
// TODO: Check that the Accept is for a real Follow activity
this.attr.following.push(activity.getObject());
}
}
async follow(actor: {id: URI, username: string;}) {
const activity = new Activity({
activity: new URI(`activity/${(new ObjectID).toHexString()}`),
type: 'Follow',
actor: this.actorId,
object: actor,
to: actor.id
});
this.doActivity(activity);
}
async acceptFollow(activity: Activity) {
if (!activity.activityId) {
throw new Error('Cannot accept Follow of anonymous activity');
}
this.attr.followers.push({id: activity.actorId});
const accept = new Activity({
activity: new URI(`activity/${(new ObjectID).toHexString()}`),
type: 'Accept',
to: activity.actorId,
actor: this.actorId,
object: {id: activity.activityId}
});
this.doActivity(accept);
}
private doActivity(activity: Activity) {
this.attr.outbox.push(activity);
this.activities.push(activity);
this.addEvent(ActivityEvent.create(activity, this));
}
private activities: Activity[] = [];
static getActivitiesToSave(actor: Actor, fn: (activities: Activity[]) => void) {
@ -43,13 +115,13 @@ export class Actor extends Entity<ActorData> {
createArticle(article: Article) {
const activity = new Activity({
activity: new URI(`activity/${new ObjectID().toHexString()}`),
to: new URI(`https://www.w3.org/ns/activitystreams#Public`),
type: 'Create',
actor: this,
object: article
actor: this.actorId,
object: {id: article.objectId}
});
this.attr.outbox.push(activity);
this.activities.push(activity);
this.addEvent(ActivityEvent.create(activity));
this.doActivity(activity);
}
getJSONLD(url: URL): ActivityPub.Actor & ActivityPub.RootObject {
@ -95,7 +167,7 @@ export class Actor extends Entity<ActorData> {
],
type: 'Person',
id: actor.href,
name: 'Display Name', // Full name
name: this.displayName, // Full name
preferredUsername: this.username, // Username
summary: 'The bio for the actor', // Bio
url: actor.href, // Profile URL
@ -125,13 +197,32 @@ export class Actor extends Entity<ActorData> {
};
}
static create(data: CreateActorData) {
static create(data: Partial<CreateActorData> & {username: string;}) {
let publicKey = data.publicKey;
let privateKey = data.privateKey;
if (!publicKey || !privateKey) {
const keypair = crypto.generateKeyPairSync('rsa', {
modulusLength: 512
});
publicKey = keypair.publicKey
.export({type: 'pkcs1', format: 'pem'})
.toString();
privateKey = keypair.privateKey
.export({type: 'pkcs1', format: 'pem'})
.toString();
}
return new Actor({
id: data.id instanceof ObjectID ? data.id : undefined,
username: data.username,
publicKey: data.publicKey,
privateKey: data.privateKey,
outbox: data.outbox
displayName: data.displayName,
publicKey: publicKey,
privateKey: privateKey,
outbox: data.outbox || [],
inbox: data.inbox || [],
followers: data.followers || [],
following: data.following || []
});
}
}

View File

@ -1,6 +1,7 @@
import ObjectID from 'bson-objectid';
import {ActivityPub} from './types';
import {Post} from './post.repository';
import {URI} from './uri.object';
type ArticleData = {
id: ObjectID
@ -12,6 +13,10 @@ type ArticleData = {
export class Article {
constructor(private readonly attr: ArticleData) {}
get objectId() {
return new URI(`article/${this.attr.id.toHexString()}`);
}
getJSONLD(url: URL): ActivityPub.Article & ActivityPub.RootObject {
if (!url.href.endsWith('/')) {
url.href += '/';

View File

@ -0,0 +1,75 @@
import Sinon from 'sinon';
import {InboxService} from './inbox.service';
import assert from 'assert';
import ObjectID from 'bson-objectid';
import {Activity} from './activity.entity';
import {URI} from './uri.object';
import {Actor} from './actor.entity';
describe('InboxService', function () {
describe('#post', function () {
it('Throws if it cannot find the actor', async function () {
const mockActorRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().rejects()
};
const mockActivityRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().rejects()
};
const service = new InboxService(
mockActorRepository,
mockActivityRepository
);
const owner = new ObjectID();
const activity = new Activity({
type: 'Follow',
activity: null,
object: {
id: new URI('https://whatever.com')
},
actor: new URI('https://blak.com'),
to: new URI('https://whatever.com')
});
await assert.rejects(async () => {
await service.post(owner, activity);
}, /Not Found/);
});
it('Posts the activity to the actors inbox, saves the actor & the activity', async function () {
const actor = Actor.create({username: 'username'});
const mockActorRepository = {
getOne: Sinon.stub().resolves(actor),
save: Sinon.stub().resolves()
};
const mockActivityRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().resolves()
};
const service = new InboxService(
mockActorRepository,
mockActivityRepository
);
const postToInboxStub = Sinon.stub(actor, 'postToInbox');
const owner = new ObjectID();
const activity = new Activity({
type: 'Follow',
activity: null,
object: {
id: new URI('https://whatever.com')
},
actor: new URI('https://blak.com'),
to: new URI('https://whatever.com')
});
await service.post(owner, activity);
assert(postToInboxStub.calledWith(activity));
assert(mockActorRepository.save.calledWith(actor));
assert(mockActivityRepository.save.calledWith(activity));
});
});
});

View File

@ -0,0 +1,25 @@
import {Inject} from '@nestjs/common';
import {Activity} from './activity.entity';
import {ActorRepository} from './actor.repository';
import {ActivityRepository} from './activity.repository';
import ObjectID from 'bson-objectid';
export class InboxService {
constructor(
@Inject('ActorRepository') private readonly actors: ActorRepository,
@Inject('ActivityRepository') private readonly activities: ActivityRepository
) {}
async post(owner: ObjectID, activity: Activity) {
const actor = await this.actors.getOne(owner);
if (!actor) {
throw new Error('Not Found');
}
await actor.postToInbox(activity);
await this.actors.save(actor);
await this.activities.save(activity);
}
}

View File

@ -0,0 +1,122 @@
import Sinon from 'sinon';
import {Actor} from './actor.entity';
import {JSONLDService} from './jsonld.service';
import assert from 'assert';
import ObjectID from 'bson-objectid';
describe('JSONLDService', function () {
describe('#getActor', function () {
it('Returns JSONLD representation of Actor', async function () {
const actor = Actor.create({username: 'freddy'});
const mockActorRepository = {
getOne: Sinon.stub().resolves(actor),
save: Sinon.stub().rejects()
};
const mockPostRepository = {
getOne: Sinon.stub().resolves(null)
};
const url = new URL('https://example.com');
const service = new JSONLDService(
mockActorRepository,
mockPostRepository,
url
);
const result = await service.getActor(actor.id);
assert(result);
assert.equal(result.type, 'Person');
});
});
describe('#getOutbox', function () {
it('returns JSONLD representation of Outbox', async function () {
const actor = Actor.create({username: 'freddy'});
const mockActorRepository = {
getOne: Sinon.stub().resolves(actor),
save: Sinon.stub().rejects()
};
const mockPostRepository = {
getOne: Sinon.stub().resolves(null)
};
const url = new URL('https://example.com');
const service = new JSONLDService(
mockActorRepository,
mockPostRepository,
url
);
const result = await service.getOutbox(actor.id);
assert(result);
assert.equal(result.type, 'OrderedCollection');
});
it('returns null if actor not found', async function () {
const actor = Actor.create({username: 'freddy'});
const mockActorRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().rejects()
};
const mockPostRepository = {
getOne: Sinon.stub().resolves(null)
};
const url = new URL('https://example.com');
const service = new JSONLDService(
mockActorRepository,
mockPostRepository,
url
);
const result = await service.getOutbox(actor.id);
assert.equal(result, null);
});
});
describe('#getArticle', function () {
it('Throws if no post found', async function () {
const mockActorRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().rejects()
};
const mockPostRepository = {
getOne: Sinon.stub().resolves(null)
};
const url = new URL('https://example.com');
const service = new JSONLDService(
mockActorRepository,
mockPostRepository,
url
);
await assert.rejects(async () => {
await service.getArticle(new ObjectID());
});
});
it('Throws if post not public', async function () {
const mockActorRepository = {
getOne: Sinon.stub().resolves(null),
save: Sinon.stub().rejects()
};
const mockPostRepository = {
getOne: Sinon.stub().resolves({
visibility: 'private'
})
};
const url = new URL('https://example.com');
const service = new JSONLDService(
mockActorRepository,
mockPostRepository,
url
);
await assert.rejects(async () => {
await service.getArticle(new ObjectID());
});
});
});
});

View File

@ -12,7 +12,12 @@ export namespace ActivityPub {
type: string | string[];
};
export type Object = RootObject | AnonymousObject;
export type SubObject = {
id: string;
type: string | string[];
};
export type Object = RootObject | AnonymousObject | SubObject;
export type Actor = ActivityPub.Object & {
inbox: string;
@ -35,7 +40,10 @@ export namespace ActivityPub {
id: string,
owner: string,
publicKeyPem: string
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any
};
export type Article = ActivityPub.Object & {
@ -57,12 +65,12 @@ export namespace ActivityPub {
width?: number
};
export type ActivityType = 'Create' | 'Update' | 'Delete';
export type ActivityType = 'Create' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Reject' | 'Undo';
export type Activity = ActivityPub.Object & {
type: ActivityType;
summary: string;
actor: Link | Actor;
actor: Link | Actor | ActivityPub.Object;
object: Link | ActivityPub.Object;
to: Link | Actor | null;
}
}

View File

@ -0,0 +1,11 @@
export class URI extends URL {
static readonly BASE_URL = new URL('https://example.com');
constructor(url: string | URI, base?: string | URI) {
super(url, base || URI.BASE_URL);
}
getValue(url: URL) {
return this.href.replace(URI.BASE_URL.href, url.href);
}
}

View File

@ -64,6 +64,7 @@ export class WebFingerService {
throw new Error('Subject does not match - not jumping thru hoops');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const self = json.links.find((link: any) => link.rel === 'self');
const selfRes = await fetch(self.href, {
@ -74,6 +75,7 @@ export class WebFingerService {
const data = await selfRes.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return data as any;
}
}

View File

@ -0,0 +1,18 @@
import {Activity} from '../../core/activitypub/activity.entity';
import {ActivityRepository} from '../../core/activitypub/activity.repository';
export class ActivityRepositoryInMemory implements ActivityRepository {
private activities: Activity[] = [];
async getOne(id: URL) {
const found = this.activities.find(entity => entity.activityId?.href === id.href);
if (!found) {
return null;
}
return found;
}
async save(activity: Activity) {
this.activities.push(activity);
}
}

View File

@ -21,9 +21,10 @@ export class ActorRepositoryInMemory implements ActorRepository {
Actor.create({
id: ObjectID.createFromHexString('deadbeefdeadbeefdeadbeef'),
username: 'index',
displayName: settingsCache.get('title'),
publicKey: settingsCache.get('ghost_public_key'),
privateKey: settingsCache.get('ghost_private_key'),
outbox: []
following: []
})
];
this.domainEvents = domainEvents;

View File

@ -0,0 +1,36 @@
import Sinon from 'sinon';
import {ActivityPubController} from './activitypub.controller';
import assert from 'assert';
import {ActivityPubService} from '../../../core/activitypub/activitypub.service';
describe('ActivityPubController', function () {
describe('#follow', function () {
it('Calls follow on the ActivityPubService and returns an empty object', async function () {
const mockActivityPubService = {
follow: Sinon.stub().resolves(),
getFollowing: Sinon.stub().resolves([])
} as unknown as ActivityPubService;
const controller = new ActivityPubController(mockActivityPubService);
await controller.follow('@egg@ghost.org');
assert((mockActivityPubService.follow as Sinon.SinonStub).calledWith('@egg@ghost.org'));
});
});
describe('#getFollowing', function () {
it('Calls getFollowing on the ActivityPubService and returns the result', async function () {
const mockActivityPubService = {
follow: Sinon.stub().resolves(),
getFollowing: Sinon.stub().resolves([])
} as unknown as ActivityPubService;
const controller = new ActivityPubController(mockActivityPubService);
const result = await controller.getFollowing();
assert((mockActivityPubService.getFollowing as Sinon.SinonStub).called);
const returnValue = await (mockActivityPubService.getFollowing as Sinon.SinonStub).returnValues[0];
assert.equal(result, returnValue);
});
});
});

View File

@ -0,0 +1,39 @@
import {
Controller,
Get,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import {Roles} from '../../../common/decorators/permissions.decorator';
import {LocationHeaderInterceptor} from '../../../nestjs/interceptors/location-header.interceptor';
import {AdminAPIAuthentication} from '../../../nestjs/guards/admin-api-authentication.guard';
import {PermissionsGuard} from '../../../nestjs/guards/permissions.guard';
import {ActivityPubService} from '../../../core/activitypub/activitypub.service';
@UseInterceptors(LocationHeaderInterceptor)
@UseGuards(AdminAPIAuthentication, PermissionsGuard)
@Controller('activitypub')
export class ActivityPubController {
constructor(private readonly activitypub: ActivityPubService) {}
@Roles([
'Owner'
])
@Post('follow/:username')
async follow(@Param('username') username: string): Promise<object> {
await this.activitypub.follow(username);
return {};
}
@Roles([
'Owner'
])
@Get('following')
async getFollowing(): Promise<{id: string, username?: string;}[]> {
const followers = await this.activitypub.getFollowing();
return followers;
}
}

View File

@ -0,0 +1,120 @@
import request from 'supertest';
import {Test} from '@nestjs/testing';
import {WebFingerService} from '../../../core/activitypub/webfinger.service';
import {ActivityRepositoryInMemory} from '../../../db/in-memory/activity.repository.in-memory';
import {ActivityPubController} from './activitypub.controller';
import {JSONLDService} from '../../../core/activitypub/jsonld.service';
import {InboxService} from '../../../core/activitypub/inbox.service';
import {ActivityPubService} from '../../../core/activitypub/activitypub.service';
import {ActorRepositoryInMemory} from '../../../db/in-memory/actor.repository.in-memory';
import {ActivityService} from '../../../core/activitypub/activity.service';
import {HTTPSignature} from '../../../core/activitypub/http-signature.service';
import {ActivityListener} from '../../../listeners/activity.listener';
import {TheWorld} from '../../../core/activitypub/tell-the-world.service';
import DomainEvents from '@tryghost/domain-events';
import {NestApplication} from '@nestjs/core';
import ObjectID from 'bson-objectid';
describe('ActivityPubController', function () {
let app: NestApplication;
before(async function () {
const moduleRef = await Test.createTestingModule({
controllers: [ActivityPubController],
providers: [
{
provide: 'ActivityPubBaseURL',
useValue: new URL('https://example.com')
},
{
provide: 'SettingsCache',
useValue: {
get(_key: string) {
return 'value';
}
}
},
{
provide: 'DomainEvents',
useValue: DomainEvents
},
{
provide: 'ActorRepository',
useClass: ActorRepositoryInMemory
},
{
provide: 'ActivityService',
useClass: ActivityService
},
{
provide: 'PostRepository',
useValue: {
async getOne(id: ObjectID) {
return {
id,
title: 'Testing',
slug: 'testing',
html: '<p> testing </p>',
visibility: 'public'
};
}
}
},
{
provide: 'ActivityRepository',
useClass: ActivityRepositoryInMemory
},
WebFingerService,
JSONLDService,
HTTPSignature,
ActivityService,
InboxService,
ActivityListener,
ActivityPubService,
TheWorld
]
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
after(async function () {
await app.close();
});
it('Can handle requests to get the actor', async function () {
await request(app.getHttpServer())
.get('/activitypub/actor/deadbeefdeadbeefdeadbeef')
.expect(200);
});
it('Can handle requests to get the outbox', async function () {
await request(app.getHttpServer())
.get('/activitypub/outbox/deadbeefdeadbeefdeadbeef')
.expect(200);
});
it('Can handle requests to get the following', async function () {
await request(app.getHttpServer())
.get('/activitypub/following/deadbeefdeadbeefdeadbeef')
.expect(200);
});
it('Can handle requests to get an article', async function () {
await request(app.getHttpServer())
.get('/activitypub/article/deadbeefdeadbeefdeadbeef')
.expect(200);
});
describe('/inbox/:id', function () {
it('Errors with invalid requests', async function () {
await request(app.getHttpServer())
.post('/activitypub/inbox/deadbeefdeadbeefdeadbeef')
.send({
type: 'Follow',
actor: 'https://site.com/actor',
object: 'https://site.com/object'
})
.expect(500);
});
});
});

View File

@ -1,12 +1,18 @@
import {Controller, Get, Header, Param} from '@nestjs/common';
import {Controller, Get, Header, Param, Post, RawBody, Headers as NestHeaders, Req, Body} from '@nestjs/common';
import {Roles} from '../../../common/decorators/permissions.decorator';
import ObjectID from 'bson-objectid';
import {JSONLDService} from '../../../core/activitypub/jsonld.service';
import {HTTPSignature} from '../../../core/activitypub/http-signature.service';
import {InboxService} from '../../../core/activitypub/inbox.service';
import {Activity} from '../../../core/activitypub/activity.entity';
import {ActivityPubService} from '../../../core/activitypub/activitypub.service';
@Controller('activitypub')
export class ActivityPubController {
constructor(
private readonly service: JSONLDService
private readonly service: JSONLDService,
private readonly inboxService: InboxService,
private readonly activitypub: ActivityPubService
) {}
@Header('Cache-Control', 'no-store')
@ -20,6 +26,33 @@ export class ActivityPubController {
return this.service.getActor(ObjectID.createFromHexString(id));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Post('inbox/:owner')
async handleActivity(
@Param('owner') owner: unknown,
@Body() body: unknown,
@RawBody() rawbody: Buffer,
@NestHeaders() headers: Record<string, string>,
@Req() req: Request
) {
if (typeof owner !== 'string') {
throw new Error('Bad Request');
}
if (typeof body !== 'object' || body === null) {
throw new Error('Bad Request');
}
if (!('id' in body) || !('type' in body) || !('actor' in body) || !('object' in body)) {
throw new Error('Bad Request');
}
const verified = await HTTPSignature.validate(req.method, req.url, new Headers(headers), rawbody);
if (!verified) {
throw new Error('Not Authorized');
}
this.inboxService.post(ObjectID.createFromHexString(owner), Activity.fromJSONLD(body));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@ -31,6 +64,17 @@ export class ActivityPubController {
return this.service.getOutbox(ObjectID.createFromHexString(owner));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('following/:owner')
async getFollowing(@Param('owner') owner: unknown) {
if (typeof owner !== 'string') {
throw new Error('Bad Request');
}
return this.activitypub.getFollowing();
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])

View File

@ -0,0 +1,38 @@
import request from 'supertest';
import {Test} from '@nestjs/testing';
import {WebFingerService} from '../../../core/activitypub/webfinger.service';
import {WebFingerController} from './webfinger.controller';
import {ActivityRepositoryInMemory} from '../../../db/in-memory/activity.repository.in-memory';
describe('WebFingerController', function () {
it('Responds to HTTP requests for .well-known/webfinger correctly', async function () {
const moduleRef = await Test.createTestingModule({
controllers: [WebFingerController],
providers: [
WebFingerService,
{
provide: 'ActivityPubBaseURL',
useValue: new URL('https://example.com')
},
{
provide: 'ActorRepository',
useClass: ActivityRepositoryInMemory
}
]
})
.overrideProvider(WebFingerService)
.useValue({
async getResource() {}
})
.compile();
const app = moduleRef.createNestApplication();
await app.init();
request(app.getHttpServer())
.get('/.well-known/webfinger?resource=acct:egg@ghost.org')
.expect(200);
await app.close();
});
});

View File

@ -0,0 +1,50 @@
import assert from 'assert';
import {Activity} from '../core/activitypub/activity.entity';
import {ActivityEvent} from '../core/activitypub/activity.event';
import {Actor} from '../core/activitypub/actor.entity';
import {TheWorld} from '../core/activitypub/tell-the-world.service';
import {URI} from '../core/activitypub/uri.object';
import {ActivityListener} from './activity.listener';
describe('ActivityListener', function () {
describe('#dispatchActivity', function () {
it('uses the service to deliver the activity', function () {
let called = false;
const calledWith: [unknown, unknown][] = [];
class MockTheWorld extends TheWorld {
async deliverActivity(activity: Activity, actor: Actor): Promise<void> {
called = true;
calledWith.push([activity, actor]);
}
}
const listener = new ActivityListener(new MockTheWorld(new URL('https://example.com')));
const actor = Actor.create({
username: 'Testing'
});
const toFollow = new URI('https://example.com/user');
const activity = new Activity({
type: 'Follow',
activity: null,
actor: actor.actorId,
object: {
id: toFollow
},
to: toFollow
});
const event = new ActivityEvent({
activity,
actor
});
listener.dispatchActivity(event);
assert.equal(called, true);
assert.equal(calledWith[0][0], activity);
assert.equal(calledWith[0][1], actor);
});
});
});

View File

@ -0,0 +1,15 @@
import {Inject} from '@nestjs/common';
import {OnEvent} from '../common/decorators/handle-event.decorator';
import {ActivityEvent} from '../core/activitypub/activity.event';
import {TheWorld} from '../core/activitypub/tell-the-world.service';
export class ActivityListener {
constructor(
@Inject(TheWorld) private readonly service: TheWorld
) {}
@OnEvent(ActivityEvent)
async dispatchActivity(event: ActivityEvent) {
await this.service.deliverActivity(event.data.activity, event.data.actor);
}
}

View File

@ -1,4 +1,4 @@
import {ArgumentsHost, Catch, ExceptionFilter} from '@nestjs/common';
import {ArgumentsHost, Catch, ExceptionFilter, Inject} from '@nestjs/common';
import {Response} from 'express';
interface GhostError extends Error {
@ -15,10 +15,14 @@ interface GhostError extends Error {
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(@Inject('logger') private logger: Console) {}
catch(error: GhostError, host: ArgumentsHost) {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
this.logger.error(error);
response.status(error.statusCode || 500);
response.json({
errors: [

View File

@ -1,4 +1,4 @@
import {Module} from '@nestjs/common';
import {Global, Module} from '@nestjs/common';
import {ActorRepositoryInMemory} from '../../db/in-memory/actor.repository.in-memory';
import {ActivityPubController} from '../../http/frontend/controllers/activitypub.controller';
import {WebFingerService} from '../../core/activitypub/webfinger.service';
@ -6,7 +6,14 @@ import {JSONLDService} from '../../core/activitypub/jsonld.service';
import {WebFingerController} from '../../http/frontend/controllers/webfinger.controller';
import {ActivityService} from '../../core/activitypub/activity.service';
import {KnexPostRepository} from '../../db/knex/post.repository.knex';
import {HTTPSignature} from '../../core/activitypub/http-signature.service';
import {InboxService} from '../../core/activitypub/inbox.service';
import {ActivityRepositoryInMemory} from '../../db/in-memory/activity.repository.in-memory';
import {ActivityListener} from '../../listeners/activity.listener';
import {TheWorld} from '../../core/activitypub/tell-the-world.service';
import {ActivityPubService} from '../../core/activitypub/activitypub.service';
@Global()
@Module({
controllers: [ActivityPubController, WebFingerController],
exports: [],
@ -23,8 +30,18 @@ import {KnexPostRepository} from '../../db/knex/post.repository.knex';
provide: 'PostRepository',
useClass: KnexPostRepository
},
{
provide: 'ActivityRepository',
useClass: ActivityRepositoryInMemory
},
WebFingerService,
JSONLDService
JSONLDService,
HTTPSignature,
ActivityService,
InboxService,
ActivityListener,
ActivityPubService,
TheWorld
]
})
export class ActivityPubModule {}

View File

@ -2,15 +2,25 @@ import {Module} 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 {ActivityPubController} from '../../http/admin/controllers/activitypub.controller';
import {ActivityPubService} from '../../core/activitypub/activitypub.service';
import {WebFingerService} from '../../core/activitypub/webfinger.service';
import {ActorRepositoryInMemory} from '../../db/in-memory/actor.repository.in-memory';
@Module({
controllers: [ExampleController],
controllers: [ExampleController, ActivityPubController],
exports: [ExampleService],
providers: [
ExampleService,
ActivityPubService,
WebFingerService,
{
provide: 'ExampleRepository',
useClass: ExampleRepositoryInMemory
},
{
provide: 'ActorRepository',
useClass: ActorRepositoryInMemory
}
]
})