Skip to content

Commit

Permalink
Supported Ghost2Ghost Follow/Accept
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
allouis committed May 15, 2024
1 parent ba1d36b commit df1774d
Show file tree
Hide file tree
Showing 31 changed files with 1,345 additions and 66 deletions.
1 change: 1 addition & 0 deletions ghost/ghost/src/common/libraries.defintitions.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
declare module '@tryghost/errors';
declare module '@tryghost/domain-events';
2 changes: 1 addition & 1 deletion ghost/ghost/src/common/types/settings-cache.type.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type Settings = {
ghost_public_key: string;
ghost_private_key: string;
testing: boolean;
title: string;
};

export interface SettingsCache {
Expand Down
55 changes: 55 additions & 0 deletions ghost/ghost/src/core/activitypub/activity.entity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
});
});
88 changes: 88 additions & 0 deletions ghost/ghost/src/core/activitypub/activity.entity.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
}
10 changes: 6 additions & 4 deletions ghost/ghost/src/core/activitypub/activity.event.ts
Original file line number Diff line number Diff line change
@@ -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});
}
}
27 changes: 0 additions & 27 deletions ghost/ghost/src/core/activitypub/activity.object.ts

This file was deleted.

6 changes: 6 additions & 0 deletions ghost/ghost/src/core/activitypub/activity.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {Activity} from './activity.entity';

export interface ActivityRepository {
getOne(id: URL): Promise<Activity | null>
save(activity: Activity): Promise<void>
}
129 changes: 129 additions & 0 deletions ghost/ghost/src/core/activitypub/activity.service.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
Loading

0 comments on commit df1774d

Please sign in to comment.