mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 03:14:03 +03:00
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:
parent
ba1d36bcda
commit
df1774d8e9
@ -1 +1,2 @@
|
||||
declare module '@tryghost/errors';
|
||||
declare module '@tryghost/domain-events';
|
||||
|
@ -1,7 +1,7 @@
|
||||
export type Settings = {
|
||||
ghost_public_key: string;
|
||||
ghost_private_key: string;
|
||||
testing: boolean;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface SettingsCache {
|
||||
|
55
ghost/ghost/src/core/activitypub/activity.entity.test.ts
Normal file
55
ghost/ghost/src/core/activitypub/activity.entity.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
88
ghost/ghost/src/core/activitypub/activity.entity.ts
Normal file
88
ghost/ghost/src/core/activitypub/activity.entity.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
6
ghost/ghost/src/core/activitypub/activity.repository.ts
Normal file
6
ghost/ghost/src/core/activitypub/activity.repository.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import {Activity} from './activity.entity';
|
||||
|
||||
export interface ActivityRepository {
|
||||
getOne(id: URL): Promise<Activity | null>
|
||||
save(activity: Activity): Promise<void>
|
||||
}
|
129
ghost/ghost/src/core/activitypub/activity.service.test.ts
Normal file
129
ghost/ghost/src/core/activitypub/activity.service.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
115
ghost/ghost/src/core/activitypub/activitypub.service.test.ts
Normal file
115
ghost/ghost/src/core/activitypub/activitypub.service.test.ts
Normal 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'
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
44
ghost/ghost/src/core/activitypub/activitypub.service.ts
Normal file
44
ghost/ghost/src/core/activitypub/activitypub.service.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 || []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 += '/';
|
||||
|
75
ghost/ghost/src/core/activitypub/inbox.service.test.ts
Normal file
75
ghost/ghost/src/core/activitypub/inbox.service.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
25
ghost/ghost/src/core/activitypub/inbox.service.ts
Normal file
25
ghost/ghost/src/core/activitypub/inbox.service.ts
Normal 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);
|
||||
}
|
||||
}
|
122
ghost/ghost/src/core/activitypub/jsonld.service.test.ts
Normal file
122
ghost/ghost/src/core/activitypub/jsonld.service.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
11
ghost/ghost/src/core/activitypub/uri.object.ts
Normal file
11
ghost/ghost/src/core/activitypub/uri.object.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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'])
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
50
ghost/ghost/src/listeners/activity.listener.test.ts
Normal file
50
ghost/ghost/src/listeners/activity.listener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
15
ghost/ghost/src/listeners/activity.listener.ts
Normal file
15
ghost/ghost/src/listeners/activity.listener.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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: [
|
||||
|
@ -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 {}
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user