Removed ActivityPub backend from Ghost (#20410)

We are no longer going to run any ActivityPub logic inside of Ghost,
instead we're moving to a separate service, so all of this code is now
redundant.
This commit is contained in:
Fabien 'egg' O'Carroll 2024-06-19 15:36:36 +07:00 committed by GitHub
parent 9e1a70eed7
commit 5acdafc0e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3 additions and 2842 deletions

View File

@ -388,8 +388,6 @@ async function initNestDependencies() {
debug('Begin: initNestDependencies'); debug('Begin: initNestDependencies');
const GhostNestApp = require('@tryghost/ghost'); const GhostNestApp = require('@tryghost/ghost');
const providers = []; const providers = [];
const urlUtils = require('./shared/url-utils');
const activityPubBaseUrl = new URL('activitypub', urlUtils.urlFor('home', true));
providers.push({ providers.push({
provide: 'logger', provide: 'logger',
useValue: require('@tryghost/logging') useValue: require('@tryghost/logging')
@ -402,9 +400,6 @@ async function initNestDependencies() {
}, { }, {
provide: 'DomainEvents', provide: 'DomainEvents',
useValue: require('@tryghost/domain-events') useValue: require('@tryghost/domain-events')
}, {
provide: 'ActivityPubBaseURL',
useValue: activityPubBaseUrl
}, { }, {
provide: 'SettingsCache', provide: 'SettingsCache',
useValue: require('./shared/settings-cache') useValue: require('./shared/settings-cache')

View File

@ -3,7 +3,6 @@ const path = require('path');
const express = require('../../shared/express'); const express = require('../../shared/express');
const DomainEvents = require('@tryghost/domain-events'); const DomainEvents = require('@tryghost/domain-events');
const {MemberPageViewEvent} = require('@tryghost/member-events'); const {MemberPageViewEvent} = require('@tryghost/member-events');
const GhostNestApp = require('@tryghost/ghost');
// App requires // App requires
const config = require('../../shared/config'); const config = require('../../shared/config');
@ -21,8 +20,6 @@ const siteRoutes = require('./routes');
const shared = require('../../server/web/shared'); const shared = require('../../server/web/shared');
const errorHandler = require('@tryghost/mw-error-handler'); const errorHandler = require('@tryghost/mw-error-handler');
const mw = require('./middleware'); const mw = require('./middleware');
const labs = require('../../shared/labs');
const bodyParser = require('body-parser');
const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`; const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`;
const STATIC_MEDIA_URL_PREFIX = `/${constants.STATIC_MEDIA_URL_PREFIX}`; const STATIC_MEDIA_URL_PREFIX = `/${constants.STATIC_MEDIA_URL_PREFIX}`;
@ -51,36 +48,6 @@ module.exports = function setupSiteApp(routerConfig) {
// enable CORS headers (allows admin client to hit front-end when configured on separate URLs) // enable CORS headers (allows admin client to hit front-end when configured on separate URLs)
siteApp.use(mw.cors); siteApp.use(mw.cors);
const jsonParser = bodyParser.json({
type: ['application/activity+json', 'application/ld+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'],
// TODO: The @RawBody decorator in nest isn't working without this atm...
verify: function (req, res, buf) {
req.rawBody = buf;
}
});
siteApp.use(async function nestBodyParser(req, res, next) {
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) {
jsonParser(req, res, next);
return;
}
return next();
});
siteApp.use(async function nestApp(req, res, next) {
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) {
const originalExpressApp = req.app;
const app = await GhostNestApp.getApp();
const instance = app.getHttpAdapter().getInstance();
instance(req, res, function (err) {
req.app = originalExpressApp;
next(err);
});
return;
}
return next();
});
siteApp.use(offersService.middleware); siteApp.use(offersService.middleware);
siteApp.use(linkRedirects.service.handleRequest); siteApp.use(linkRedirects.service.handleRequest);

View File

@ -39,7 +39,7 @@ module.exports = function setupApiApp() {
apiApp.use(routes()); apiApp.use(routes());
apiApp.use(async function nestApp(req, res, next) { apiApp.use(async function nestApp(req, res, next) {
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) { if (labs.isSet('NestPlayground')) {
const originalExpressApp = req.app; const originalExpressApp = req.app;
const app = await GhostNestApp.getApp(); const app = await GhostNestApp.getApp();

View File

@ -1,89 +0,0 @@
import assert from 'assert';
import {Activity} from './activity.entity';
import {URI} from './uri.object';
import {Article} from './article.object';
import ObjectID from 'bson-objectid';
import {Actor} from './actor.entity';
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);
});
}
});
it('Can correctly reconstruct', function () {
const actor = Actor.create({username: 'testing'});
const article = Article.fromPost({
id: new ObjectID(),
title: 'My Title',
slug: 'my-title',
html: '<p> big boi contents </p>',
excerpt: 'lil contents',
authors: ['Jeremy Paxman'],
url: new URI('blah'),
publishedAt: new Date(),
featuredImage: null,
visibility: 'public'
});
const activity = new Activity({
type: 'Create',
activity: null,
actor: actor,
object: article,
to: new URI('bloo')
});
const baseUrl = new URL('https://ghost.org');
const input = activity.getJSONLD(baseUrl);
const created = Activity.fromJSONLD(input);
assert.deepEqual(created.getJSONLD(baseUrl), input);
});
});
});

View File

@ -1,127 +0,0 @@
import {Entity} from '../../common/entity.base';
import {Actor} from './actor.entity';
import {Article} from './article.object';
import {ActivityPub} from './types';
import {URI} from './uri.object';
type ActivityData = {
activity: URI | null;
type: ActivityPub.ActivityType;
actor: {
id: URI;
type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
} | Actor;
object: {
id: URI;
type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
} | Article;
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(url: URL) {
if (this.attr.object instanceof Article) {
return this.attr.object.getJSONLD(url);
}
return this.attr.object;
}
getActor(url: URL) {
if (this.attr.actor instanceof Actor) {
return this.attr.actor.getJSONLD(url);
}
return this.attr.actor;
}
get actorId() {
if (this.attr.actor instanceof Actor) {
return this.attr.actor.actorId;
}
return this.attr.actor.id;
}
get objectId() {
if (this.attr.object instanceof Article) {
return this.attr.object.objectId;
}
return this.attr.object.id;
}
get activityId() {
return this.attr.activity;
}
getJSONLD(url: URL): ActivityPub.Activity {
const object = this.getObject(url);
const actor = this.getActor(url);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: this.activityId?.getValue(url) || null,
type: this.attr.type,
actor: {
...actor,
id: this.actorId.getValue(url)
},
object: {
...object,
id: 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 && typeof json.id === 'string' ? getURI(json.id) : null,
type: parsed.type as ActivityPub.ActivityType,
actor: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(parsed.actor as any),
id: getURI(parsed.actor)
},
object: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(parsed.object as any),
id: getURI(parsed.object)
},
to: 'to' in json ? getURI(json.to) : null
});
}
}

View File

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

View File

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

View File

@ -1,145 +0,0 @@
import ObjectID from 'bson-objectid';
import {ActivityService} from './activity.service';
import {Actor} from './actor.entity';
import assert from 'assert';
import {URI} from './uri.object';
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',
authors: ['Mr Bean'],
publishedAt: new Date(),
featuredImage: null,
excerpt: 'Small text',
url: new URI('blah')
};
}
};
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',
authors: ['Mr Bean'],
publishedAt: new Date(),
featuredImage: null,
excerpt: 'Small text',
url: new URI('blah')
};
}
};
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',
authors: ['Mr Bean'],
publishedAt: new Date(),
featuredImage: null,
excerpt: 'Small text',
url: new URI('blah')
};
}
};
const service = new ActivityService(
mockActorRepository,
mockPostRepository
);
const postId = new ObjectID();
await assert.rejects(async () => {
await service.createArticleForPost(postId);
}, /Actor not found/);
});
});
});

View File

@ -1,36 +0,0 @@
import ObjectID from 'bson-objectid';
import {ActorRepository} from './actor.repository';
import {Article} from './article.object';
import {PostRepository} from './post.repository';
import {Inject} from '@nestjs/common';
export class ActivityService {
constructor(
@Inject('ActorRepository') private readonly actorRepository: ActorRepository,
@Inject('PostRepository') private readonly postRepository: PostRepository
) {}
async createArticleForPost(postId: ObjectID) {
const actor = await this.actorRepository.getOne('index');
if (!actor) {
throw new Error('Actor not found');
}
const post = await this.postRepository.getOne(postId);
if (!post) {
throw new Error('Post not found');
}
if (post.visibility !== 'public') {
return;
}
const article = Article.fromPost(post);
actor.createArticle(article);
await this.actorRepository.save(actor);
}
}

View File

@ -1,61 +0,0 @@
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));
});
});
});

View File

@ -1,29 +0,0 @@
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);
}
}

View File

@ -1,229 +0,0 @@
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('actorId', function () {
it('Correctly returns the actor url', function () {
const actor = Actor.create({username: 'testing'});
const idString = actor.id.toHexString();
const actorId = actor.actorId;
const baseUrl = new URL('https://domain.tld/base');
assert.equal(
actorId.getValue(baseUrl),
`https://domain.tld/base/actor/${idString}`
);
});
});
});
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',
url: new URI(''),
authors: ['Mr Burns'],
featuredImage: null,
publishedAt: null,
excerpt: 'Hey'
});
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 baseUrl = new URL('https://example.com/ap');
const actor = Actor.create({
username: 'Testing'
});
const url = new URL('https://some-server.com/users/username/inbox');
const date = new Date();
const request = new Request(url, {
headers: {
Host: url.host,
Date: date.toISOString(),
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
}
});
const signedRequest = await actor.sign(request, baseUrl);
const publicKey = actor.getJSONLD(baseUrl).publicKey;
class MockHTTPSignature extends HTTPSignature {
protected static async getPublicKey() {
return crypto.createPublicKey(publicKey.publicKeyPem);
}
}
const signedRequestURL = new URL(signedRequest.url);
const actual = await MockHTTPSignature.validate(
signedRequest.method,
signedRequestURL.pathname,
signedRequest.headers
);
const expected = true;
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: {
id: newFollower,
type: 'Person'
},
object: {
id: actor.actorId,
type: 'Person'
},
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: {
id: newFollower,
type: 'Person'
},
object: {
id: actor.actorId,
type: 'Person'
},
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: {
id: newFollower,
type: 'Person'
},
object: {
id: newFollower,
type: 'Person'
},
to: actor.actorId
});
await actor.postToInbox(activity);
assert(actor.following.find(follower => follower.id.href === newFollower.href));
assert(actor.following.find(follower => follower.username === '@index@activitypub.server'));
});
});
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

@ -1,262 +0,0 @@
import crypto from 'crypto';
import ObjectID from 'bson-objectid';
import {Entity} from '../../common/entity.base';
import {ActivityPub} from './types';
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 & {
id? : ObjectID
};
export class Actor extends Entity<ActorData> {
private getURI(input: string): URI {
const id = this.id.toHexString();
return new URI(input.replace(':id', id));
}
get actorId() {
return this.getURI('actor/:id');
}
get publicKeyId() {
return this.getURI('actor/:id#main-key');
}
get inboxId() {
return this.getURI('inbox/:id');
}
get outboxId() {
return this.getURI('outbox/:id');
}
get followingCollectionId() {
return this.getURI('following/:id');
}
get followersCollectionId() {
return this.getURI('followers/:id');
}
get featuredCollectionId() {
return this.getURI('featured/:id');
}
get username() {
return this.attr.username;
}
get displayName() {
if (this.attr.displayName) {
return this.attr.displayName;
}
return this.username;
}
get inbox() {
return this.attr.inbox;
}
get outbox() {
return this.attr.outbox;
}
get following() {
return this.attr.following;
}
get followers() {
return this.attr.followers;
}
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({
id: activity.actorId,
username: `@index@${activity.actorId.hostname}`
});
}
}
async follow(actor: {id: URI, username: string;}) {
const activity = new Activity({
activity: new URI(`activity/${(new ObjectID).toHexString()}`),
type: 'Follow',
actor: this,
object: {
...actor,
type: 'Person'
},
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,
object: {
id: activity.activityId,
type: 'Follow'
}
});
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) {
const activities = actor.activities;
actor.activities = [];
fn(activities);
}
createArticle(article: Article) {
const activity = new Activity({
activity: new URI(`activity/${new ObjectID().toHexString()}`),
to: this.followersCollectionId,
type: 'Create',
actor: this,
object: article
});
this.doActivity(activity);
}
getJSONLD(url: URL): ActivityPub.Actor & ActivityPub.RootObject {
if (!url.href.endsWith('/')) {
url.href += '/';
}
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
featured: {
'@id': 'http://joinmastodon.org/ns#featured',
'@type': '@id'
}
},
{
discoverable: {
'@id': 'http://joinmastodon.org/ns#discoverable',
'@type': '@id'
}
},
{
manuallyApprovesFollowers: {
'@id': 'http://joinmastodon.org/ns#manuallyApprovesFollowers',
'@type': '@id'
}
},
{
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value'
}
],
type: 'Person',
id: this.actorId.getValue(url),
name: this.displayName, // Full name
preferredUsername: this.username, // Username
summary: 'The bio for the actor', // Bio
url: this.actorId.getValue(url), // Profile URL
icon: '', // Avatar
image: '', // Header image
published: '1970-01-01T00:00:00Z', // When profile was created
manuallyApprovesFollowers: false, // Locked account
discoverable: true, // Shown in the profile directory
attachment: [{
type: 'PropertyValue',
name: 'Website',
value: `<a href='${url.href}'>${url.hostname}</a>`
}],
// Collections
following: this.followingCollectionId.getValue(url),
followers: this.followersCollectionId.getValue(url),
inbox: this.inboxId.getValue(url),
outbox: this.outboxId.getValue(url),
featured: this.featuredCollectionId.getValue(url),
publicKey: {
id: this.publicKeyId.getValue(url),
owner: this.actorId.getValue(url),
publicKeyPem: this.attr.publicKey
}
};
}
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,
displayName: data.displayName,
publicKey: publicKey,
privateKey: privateKey,
outbox: data.outbox || [],
inbox: data.inbox || [],
followers: data.followers || [],
following: data.following || []
});
}
}

View File

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

View File

@ -1,63 +0,0 @@
import ObjectID from 'bson-objectid';
import {ActivityPub} from './types';
import {Post} from './post.repository';
import {URI} from './uri.object';
type ArticleData = {
id: ObjectID
name: string
content: string
url: URI
image: URI | null
published: Date | null
attributedTo: {type: string, name: string}[]
preview: {type: string, content: string}
};
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 += '/';
}
const id = new URL(`article/${this.attr.id.toHexString()}`, url.href);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Article',
id: id.href,
name: this.attr.name,
content: this.attr.content,
url: this.attr.url.getValue(url),
image: this.attr.image?.getValue(url),
published: this.attr.published?.toISOString(),
attributedTo: this.attr.attributedTo,
preview: this.attr.preview
};
}
static fromPost(post: Post) {
return new Article({
id: post.id,
name: post.title,
content: post.html,
url: post.url,
image: post.featuredImage,
published: post.publishedAt,
attributedTo: post.authors.map(name => ({
type: 'Person',
name
})),
preview: {
type: 'Note',
content: post.excerpt
}
});
}
}

View File

@ -1,100 +0,0 @@
import assert from 'assert';
import crypto from 'crypto';
import {HTTPSignature} from './http-signature.service';
describe('HTTPSignature', function () {
describe('#validate', function () {
it('returns true when the signature is valid', async function () {
const requestMethod = 'POST';
const requestUrl = '/activitypub/inbox/deadbeefdeadbeefdeadbeef';
const requestHeaders = new Headers({
host: 'a424-171-97-56-187.ngrok-free.app',
'user-agent': 'http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-04-30; +https://mastodon.social/)',
'content-length': '286',
'accept-encoding': 'gzip',
'content-type': 'application/activity+json',
date: 'Thu, 02 May 2024 09:51:57 GMT',
digest: 'SHA-256=tbr1NMXoLisaWc4LplxkUO19vrpGSjslPpHN5qGMEaU=',
signature: 'keyId="https://mastodon.social/users/testingshtuff#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="rbkHYjeJ6WpO5Pa6Ui3Z/9GzOeB4c/3IMKlXH+ZrBwtAy7DGannGzHXBe+sYWlLOS9U18IQvOcHvsnWkKMs6f63Fbk9kIylxoSOwZqlkWekI5/dfAhEnlz6azW0X3psiW6I/nAqTdAmWYTqszfQVRD19TwgsQXNsPVD/lEfbsopANCGALePY7mPhmf/ukGluy7Ck4sskwDn6eCqoSHSXi7Mav6ZEp5OABX9C626CyvRG5U/IWE2AVjc8hwGghp7NUgxSLiMKk/Tt3xKFd39dDMDJwj8NinCZQTBmvcZurdzChH2ShDsETxZDvPTFrj30jeH2g29kxZhq5rqHP7a6Gw=="',
'x-forwarded-for': '49.13.137.65',
'x-forwarded-host': 'a424-171-97-56-187.ngrok-free.app',
'x-forwarded-proto': 'https'
});
const requestBody = Buffer.from('eyJAY29udGV4dCI6Imh0dHBzOi8vd3d3LnczLm9yZy9ucy9hY3Rpdml0eXN0cmVhbXMiLCJpZCI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsLzgzMWNlOWMyLWNkYWYtNGJhMC05NmUyLWE3MzY5NDk3MmI5OSIsInR5cGUiOiJGb2xsb3ciLCJhY3RvciI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYiLCJvYmplY3QiOiJodHRwczovL2E0MjQtMTcxLTk3LTU2LTE4Ny5uZ3Jvay1mcmVlLmFwcC9hY3Rpdml0eXB1Yi9hY3Rvci9kZWFkYmVlZmRlYWRiZWVmZGVhZGJlZWYifQ==', 'base64');
const actual = await HTTPSignature.validate(requestMethod, requestUrl, requestHeaders, requestBody);
const expected = true;
assert.equal(actual, expected, 'The signature should have been validated');
});
it('also returns true when the signature is valid', async function () {
const requestMethod = 'POST';
const requestUrl = '/activitypub/inbox/deadbeefdeadbeefdeadbeef';
const requestHeaders = new Headers({
host: 'a424-171-97-56-187.ngrok-free.app',
'user-agent': 'http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-04-30; +https://mastodon.social/)',
'content-length': '438',
'accept-encoding': 'gzip',
'content-type': 'application/activity+json',
date: 'Thu, 02 May 2024 09:51:30 GMT',
digest: 'SHA-256=Bru67GlP+0N3ySTtv/D8/QfhCaBc2P9vC1AjCxl5gmA=',
signature: 'keyId="https://mastodon.social/users/testingshtuff#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="qx5uo2gRN447a1B+yzjFyc5zy/lYCZqC8tJnIe2Tn6Q+vvVLRZL5hUoZQhFzwlxMPpcpibz2EoFdGlNBf/OFuNBoKa+dsjRA9JyCyc0fd/W2adoA+cp/y1smgSpLFjZUrIViG/SfnVBa3JTw+YeeqX4yY27WYiDMw1hSiQYGWbb64kwayChP6povH5MyoqkjyS1QZWYxOmbn27hlcGuqHgqhEEQhDeqwVEOPzq+JrkuosfIxCPTw/oLX0SWITGUwIffXFquOIV8oB1pWkqfbIXjstrMfFq5n48Ee/5vadsj3rR/dDFLMbUUAwO7uKTsvfurcWmzM4fJKoLyAOxzAgQ=="',
'x-forwarded-for': '78.47.65.118',
'x-forwarded-host': 'a424-171-97-56-187.ngrok-free.app',
'x-forwarded-proto': 'https'
});
const requestBody = Buffer.from('eyJAY29udGV4dCI6Imh0dHBzOi8vd3d3LnczLm9yZy9ucy9hY3Rpdml0eXN0cmVhbXMiLCJpZCI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYjZm9sbG93cy80MjQ3NDc2Ny91bmRvIiwidHlwZSI6IlVuZG8iLCJhY3RvciI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYiLCJvYmplY3QiOnsiaWQiOiJodHRwczovL21hc3RvZG9uLnNvY2lhbC8yNmY5M2Q2Yy03NmU3LTRiNzAtOWE4Yy03MzMzMTBhMjU4MjQiLCJ0eXBlIjoiRm9sbG93IiwiYWN0b3IiOiJodHRwczovL21hc3RvZG9uLnNvY2lhbC91c2Vycy90ZXN0aW5nc2h0dWZmIiwib2JqZWN0IjoiaHR0cHM6Ly9hNDI0LTE3MS05Ny01Ni0xODcubmdyb2stZnJlZS5hcHAvYWN0aXZpdHlwdWIvYWN0b3IvZGVhZGJlZWZkZWFkYmVlZmRlYWRiZWVmIn19', 'base64');
const actual = await HTTPSignature.validate(requestMethod, requestUrl, requestHeaders, requestBody);
const expected = true;
assert.equal(actual, expected, 'The signature should have been validated');
});
});
describe('#sign', function () {
it('Can sign a request that does not have explicit Date or Host headers', async function () {
const keypair = crypto.generateKeyPairSync('rsa', {
modulusLength: 512
});
const request = new Request('https://example.com:2368/blah');
const signed = await HTTPSignature.sign(request, new URL('https://keyid.com'), keypair.privateKey);
assert.ok(signed);
});
it('Can sign a post request which is valid', async function () {
const keypair = crypto.generateKeyPairSync('rsa', {
modulusLength: 512
});
class MockHTTPSignature extends HTTPSignature {
protected static async getPublicKey() {
return keypair.publicKey;
}
}
const request = new Request('https://example.com:2368/blah', {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify({
id: 'https://mastodon.social/users/testingshtuff',
type: 'Accept',
actor: 'https://mastodon.social/users/testingshtuff',
object: 'https://mastodon.social/79f89120-fd13-43e8-aa6d-3bd03652cfad'
})
});
const signed = await MockHTTPSignature.sign(request, new URL('https://keyid.com'), keypair.privateKey);
assert.ok(signed);
const url = new URL(signed.url);
const body = Buffer.from(await signed.text());
const result = await MockHTTPSignature.validate(signed.method, url.pathname, signed.headers, body);
assert.equal(result, true);
});
});
});

View File

@ -1,209 +0,0 @@
import crypto from 'node:crypto';
type Signature = {
signature: Buffer
headers: string[]
keyId: URL
algorithm: string
};
export class HTTPSignature {
private static generateSignatureString(
signature: Signature,
headers: Headers,
requestMethod: string,
requestUrl: string
): string {
const data = signature.headers
.map((header) => {
return `${header}: ${this.getHeader(
header,
headers,
requestMethod,
requestUrl
)}`;
})
.join('\n');
return data;
}
private static parseSignatureHeader(signature: string): Signature {
const signatureData: Record<string, string> = signature
.split(',')
.reduce((data, str) => {
try {
const [key, value] = str.split('=');
return {
// values are wrapped in quotes like key="the value"
[key]: value.replace(/"/g, ''),
...data
};
} catch (err) {
return data;
}
}, {});
if (
!signatureData.signature ||
!signatureData.headers ||
!signatureData.keyId ||
!signatureData.algorithm
) {
throw new Error('Could not parse signature');
}
return {
keyId: new URL(signatureData.keyId),
headers: signatureData.headers.split(/\s/),
signature: Buffer.from(signatureData.signature, 'base64url'),
algorithm: signatureData.algorithm
};
}
private static getHeader(
header: string,
headers: Headers,
requestMethod: string,
requestUrl: string
) {
if (header === '(request-target)') {
return `${requestMethod.toLowerCase()} ${requestUrl}`;
}
if (!headers.has(header)) {
throw new Error(`Missing Header ${header}`);
}
return headers.get(header);
}
protected static async getPublicKey(keyId: URL): Promise<crypto.KeyObject> {
try {
const keyRes = await fetch(keyId, {
headers: {
Accept: 'application/ld+json'
}
});
// This whole thing is wrapped in try/catch so we can just cast as we want and not worry about errors
const json = (await keyRes.json()) as {
publicKey: { publicKeyPem: string };
};
const key = crypto.createPublicKey(json.publicKey.publicKeyPem);
return key;
} catch (err) {
throw new Error(`Could not find public key ${keyId.href}: ${err}`);
}
}
private static validateDigest(
signatureData: Signature,
requestBody: Buffer,
requestHeaders: Headers
) {
const digest = crypto
.createHash(signatureData.algorithm)
.update(requestBody)
.digest('base64');
const parts = requestHeaders.get('digest')?.split('=');
parts?.shift();
const remoteDigest = parts?.join('=');
return digest === remoteDigest;
}
static async validate(
requestMethod: string,
requestUrl: string,
requestHeaders: Headers,
requestBody: Buffer = Buffer.alloc(0, 0)
) {
const signatureHeader = requestHeaders.get('signature');
if (typeof signatureHeader !== 'string') {
throw new Error('Invalid Signature header');
}
const signatureData = this.parseSignatureHeader(signatureHeader);
if (requestMethod.toLowerCase() === 'post') {
const digestIsValid = this.validateDigest(
signatureData,
requestBody,
requestHeaders
);
if (!digestIsValid) {
return false;
}
}
const publicKey = await this.getPublicKey(signatureData.keyId);
const signatureString = this.generateSignatureString(
signatureData,
requestHeaders,
requestMethod,
requestUrl
);
const verified = crypto
.createVerify(signatureData.algorithm)
.update(signatureString)
.verify(publicKey, signatureData.signature);
return verified;
}
static async sign(
request: Request,
keyId: URL,
privateKey: crypto.KeyObject
): Promise<Request> {
let headers;
if (request.method.toLowerCase() === 'post') {
headers = ['(request-target)', 'host', 'date', 'digest'];
} else {
headers = ['(request-target)', 'host', 'date'];
}
const signatureData: Signature = {
signature: Buffer.alloc(0, 0),
headers,
keyId,
algorithm: 'rsa-sha256'
};
const url = new URL(request.url);
const requestHeaders = new Headers(request.headers);
if (!requestHeaders.has('host')) {
requestHeaders.set('host', url.host);
}
if (!requestHeaders.has('date')) {
requestHeaders.set('date', (new Date()).toUTCString());
}
if (request.method.toLowerCase() === 'post') {
const digest = crypto
.createHash(signatureData.algorithm)
.update(Buffer.from(await request.clone().text(), 'utf8'))
.digest('base64');
requestHeaders.set('digest', `${signatureData.algorithm}=${digest}`);
}
const signatureString = this.generateSignatureString(
signatureData,
requestHeaders,
request.method,
url.pathname
);
const signature = crypto
.createSign(signatureData.algorithm)
.update(signatureString)
.sign(privateKey)
.toString('base64');
requestHeaders.set(
'Signature',
`keyId="${keyId}",headers="${headers.join(' ')}",signature="${signature}",algorithm="${signatureData.algorithm}"`
);
return new Request(request, {
headers: requestHeaders
});
}
}

View File

@ -1,83 +0,0 @@
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: {
type: 'Application',
id: new URI('https://whatever.com')
},
actor: {
type: 'Person',
id: 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: {
type: 'Person',
id: new URI('https://whatever.com')
},
actor: {
type: 'Person',
id: 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

@ -1,25 +0,0 @@
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

@ -1,122 +0,0 @@
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

@ -1,91 +0,0 @@
import {Inject} from '@nestjs/common';
import {ActorRepository} from './actor.repository';
import ObjectID from 'bson-objectid';
import {PostRepository} from './post.repository';
import {Article} from './article.object';
export class JSONLDService {
constructor(
@Inject('ActorRepository') private repository: ActorRepository,
@Inject('PostRepository') private postRepository: PostRepository,
@Inject('ActivityPubBaseURL') private url: URL
) {}
async getActor(id: ObjectID) {
const actor = await this.repository.getOne(id);
return actor?.getJSONLD(this.url);
}
async getFollowing(owner: ObjectID) {
const actor = await this.repository.getOne(owner);
if (!actor) {
return null;
}
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: actor.followingCollectionId.getValue(this.url),
summary: `Following collection for ${actor.username}`,
type: 'Collection',
totalItems: actor.following.length,
items: actor.following.map(item => ({id: item.id.getValue(this.url), username: item.username}))
};
}
async getFollowers(owner: ObjectID) {
const actor = await this.repository.getOne(owner);
if (!actor) {
return null;
}
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: actor.followersCollectionId.getValue(this.url),
summary: `Followers collection for ${actor.username}`,
type: 'Collection',
totalItems: actor.followers.length,
items: actor.followers.map(item => item.id.getValue(this.url))
};
}
async getInbox(owner: ObjectID) {
const actor = await this.repository.getOne(owner);
if (!actor) {
return null;
}
const json = actor.getJSONLD(this.url);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: json.inbox,
summary: `Inbox for ${actor.username}`,
type: 'OrderedCollection',
totalItems: actor.inbox.length,
orderedItems: actor.inbox.map(activity => activity.getJSONLD(this.url))
};
}
async getOutbox(owner: ObjectID) {
const actor = await this.repository.getOne(owner);
if (!actor) {
return null;
}
const json = actor.getJSONLD(this.url);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
id: json.outbox,
summary: `Outbox for ${actor.username}`,
type: 'OrderedCollection',
totalItems: actor.outbox.length,
orderedItems: actor.outbox.map(activity => activity.getJSONLD(this.url))
};
}
async getArticle(id: ObjectID) {
const post = await this.postRepository.getOne(id);
if (!post) {
throw new Error('Not found');
}
if (post.visibility !== 'public') {
throw new Error('Cannot view');
}
return Article.fromPost(post).getJSONLD(this.url);
}
}

View File

@ -1,19 +0,0 @@
import ObjectID from 'bson-objectid';
import {URI} from './uri.object';
export type Post = {
id: ObjectID;
title: string;
slug: string;
html: string;
visibility: string;
featuredImage: URI | null;
url: URI;
publishedAt: Date | null;
authors: string[];
excerpt: string;
};
export interface PostRepository {
getOne(id: ObjectID): Promise<Post | null>
}

View File

@ -1,116 +0,0 @@
import assert from 'assert';
import {Activity} from './activity.entity';
import {Actor} from './actor.entity';
import {TheWorld} from './tell-the-world.service';
import {URI} from './uri.object';
import nock from 'nock';
describe('TheWorld', function () {
describe('deliverActivity', function () {
beforeEach(function () {
nock.disableNetConnect();
});
afterEach(function () {
nock.enableNetConnect();
});
it('Can deliver the activity to the inbox of the desired recipient', async function () {
const service = new TheWorld(new URL('https://base.com'), console);
const actor = Actor.create({
username: 'Testing'
});
const toFollow = new URI('https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef');
const activity = new Activity({
type: 'Follow',
activity: null,
actor: actor,
object: {
type: 'Person',
id: toFollow
},
to: toFollow
});
const actorFetch = nock('https://main.ghost.org')
.get('/activitypub/actor/deadbeefdeadbeefdeadbeef')
.reply(200, {
inbox: 'https://main.ghost.org/activitypub/inbox/deadbeefdeadbeefdeadbeef'
});
const activityDelivery = nock('https://main.ghost.org')
.post('/activitypub/inbox/deadbeefdeadbeefdeadbeef')
.reply(201, {});
await service.deliverActivity(activity, actor);
assert(actorFetch.isDone(), 'Expected actor to be fetched');
assert(activityDelivery.isDone(), 'Expected activity to be delivered');
});
it('Can deliver the activity to the inboxes of a collection of actors', async function () {
const service = new TheWorld(new URL('https://base.com'), console);
const actor = Actor.create({
username: 'Testing'
});
const followers = new URI('https://main.ghost.org/activitypub/followers/deadbeefdeadbeefdeadbeef');
const activity = new Activity({
type: 'Create',
activity: null,
actor: actor,
object: {
id: new URI('https://main.ghost.org/hello-world'),
type: 'Note',
content: '<p> Hello, world. </p>'
},
to: followers
});
nock('https://main.ghost.org')
.get('/activitypub/followers/deadbeefdeadbeefdeadbeef')
.reply(200, {
'@context': '',
type: 'Collection',
totalItems: 3,
items: [
'https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef',
{
id: 'https://main.ghost.org/activitypub/actor/beefdeadbeefdeadbeefdead'
},
{
invalid: true
}
]
});
nock('https://main.ghost.org')
.get('/activitypub/actor/deadbeefdeadbeefdeadbeef')
.reply(200, {
inbox: 'https://main.ghost.org/activitypub/inbox/deadbeefdeadbeefdeadbeef'
});
nock('https://main.ghost.org')
.get('/activitypub/actor/beefdeadbeefdeadbeefdead')
.reply(200, {
inbox: 'https://main.ghost.org/activitypub/inbox/beefdeadbeefdeadbeefdead'
});
const firstActivityDelivery = nock('https://main.ghost.org')
.post('/activitypub/inbox/deadbeefdeadbeefdeadbeef')
.reply(201, {});
const secondActivityDelivery = nock('https://main.ghost.org')
.post('/activitypub/inbox/beefdeadbeefdeadbeefdead')
.reply(201, {});
await service.deliverActivity(activity, actor);
assert(firstActivityDelivery.isDone(), 'Expected activity to be delivered');
assert(secondActivityDelivery.isDone(), 'Expected activity to be delivered');
});
});
});

View File

@ -1,86 +0,0 @@
import {Inject} from '@nestjs/common';
import {Activity} from './activity.entity';
import {Actor} from './actor.entity';
export class TheWorld {
constructor(
@Inject('ActivityPubBaseURL') private readonly url: URL,
@Inject('logger') private readonly logger: Console
) {}
async deliverActivity(activity: Activity, actor: Actor): Promise<void> {
const recipients = await this.getRecipients(activity);
for (const recipient of recipients) {
const data = await this.fetchForActor(recipient.href, actor);
if ('inbox' in data && typeof data.inbox === 'string') {
const inbox = new URL(data.inbox);
await this.sendActivity(inbox, activity, actor);
}
if ('type' in data && data.type === 'Collection') {
if ('items' in data && Array.isArray(data.items)) {
for (const item of data.items) {
let url;
if (typeof item === 'string') {
url = new URL(item);
} else if ('id' in item && typeof item.id === 'string') {
url = new URL(item.id);
}
if (url) {
const fetchedActor = await this.fetchForActor(url.href, actor);
if ('inbox' in fetchedActor && typeof fetchedActor.inbox === 'string') {
const inbox = new URL(fetchedActor.inbox);
await this.sendActivity(inbox, activity, actor);
}
}
}
}
}
}
}
private async sendActivity(to: URL, activity: Activity, from: Actor) {
const request = new Request(to.href, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify(activity.getJSONLD(this.url))
});
const signedRequest = await from.sign(request, this.url);
try {
await fetch(signedRequest);
} catch (err) {
this.logger.error(err);
}
}
private async getRecipients(activity: Activity): Promise<URL[]>{
const json = activity.getJSONLD(this.url);
const recipients = [];
if (json.to) {
recipients.push(new URL(json.to));
}
return recipients;
}
private async fetchForActor(uri: string, actor: Actor) {
const request = new Request(uri, {
headers: {
Accept: 'application/ld+json'
}
});
const signedRequest = await actor.sign(request, this.url);
const result = await fetch(signedRequest);
const json = await result.json();
if (typeof json !== 'object' || json === null) {
throw new Error('Could not read data');
}
return json;
}
}

View File

@ -1,79 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ActivityPub {
export type AnonymousObject = {
'@context': string | (string | object)[];
id: null;
type: string | string[];
};
export type RootObject = {
'@context': string | (string | object)[];
id: string;
type: string | string[];
};
export type SubObject = {
id: string;
type: string | string[];
};
export type Object = RootObject | AnonymousObject | SubObject;
export type Actor = ActivityPub.Object & {
inbox: string;
outbox: string;
name: string;
preferredUsername: string;
summary: string;
url: string;
icon: string;
image: string;
published: string;
manuallyApprovesFollowers: boolean;
discoverable: boolean;
attachment: object[];
following: string;
followers: string;
featured: string;
publicKey: {
id: string,
owner: string,
publicKeyPem: string
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any
};
export type Article = ActivityPub.Object & {
type: 'Article';
name: string;
content: string;
url?: string;
attributedTo?: string | object[];
image?: string;
published?: string;
preview?: {type: string, content: string};
};
export type Link = string | {
type: 'Link'
href: string
id?: string
hreflang?: string
mediaType?: string
rel?: string
height?: number
width?: number
};
export type ActivityType = 'Create' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Reject' | 'Undo';
export type Activity = ActivityPub.Object & {
type: ActivityType;
actor: Link | Actor | ActivityPub.Object;
object: Link | ActivityPub.Object;
to: Link | Actor | null;
}
}

View File

@ -1,12 +0,0 @@
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) {
const replaceValue = url.href.endsWith('/') ? url.href : url.href + '/';
return this.href.replace(URI.BASE_URL.href, replaceValue);
}
}

View File

@ -1,79 +0,0 @@
import assert from 'assert';
import {ActorRepository} from './actor.repository';
import {WebFingerService} from './webfinger.service';
import {Actor} from './actor.entity';
describe('WebFingerService', function () {
describe('getResource', function () {
it('Throws with invalid resource', async function () {
const repository: ActorRepository = {} as ActorRepository;
const service = new WebFingerService(repository, new URL('https://activitypub.server'));
await service.getResource('invalid').then(
() => {
throw new Error('Should have thrown');
},
(err) => {
assert.ok(err);
}
);
});
it('Throws with missing actor', async function () {
const repository: ActorRepository = {
async getOne() {
return null;
}
} as unknown as ActorRepository;
const service = new WebFingerService(repository, new URL('https://activitypub.server'));
await service.getResource('invalid').then(
() => {
throw new Error('Should have thrown');
},
(err) => {
assert.ok(err);
}
);
});
it('Responds with webfinger for found actors', async function () {
const actor = Actor.create({username: 'c00ld00d'});
const repository: ActorRepository = {
async getOne() {
return actor;
}
} as unknown as ActorRepository;
const url = new URL('https://activitypub.server');
const service = new WebFingerService(repository, url);
const subject = 'acct:c00ld00d@activitypub.server';
const result = await service.getResource(subject);
assert.deepEqual(result, {
subject,
links: [{
rel: 'self',
type: 'application/activity+json',
href: actor.getJSONLD(url).id
}]
});
});
});
describe('#finger', function () {
it('Throws with invalid usernames', async function () {
const repository: ActorRepository = {} as ActorRepository;
const service = new WebFingerService(repository, new URL('https://activitypub.server'));
await service.finger('invalid').then(
() => {
throw new Error('Should have thrown');
},
(err) => {
assert.ok(err);
}
);
});
});
});

View File

@ -1,81 +0,0 @@
import {Inject} from '@nestjs/common';
import {ActorRepository} from './actor.repository';
const accountResourceMatcher = /acct:(\w+)@(\w+)/;
const usernameMatcher = /@?(.+)@(.+)/;
export class WebFingerService {
constructor(
@Inject('ActorRepository') private repository: ActorRepository,
@Inject('ActivityPubBaseURL') private url: URL
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getResource(resource: string, rel?: string[]) {
const match = resource.match(accountResourceMatcher);
if (!match) {
throw new Error('Invalid Resource');
}
const username = match[1];
const actor = await this.repository.getOne(username);
if (!actor) {
throw new Error('not found');
}
const result = {
subject: resource,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: actor.getJSONLD(this.url).id
}
]
};
return result;
}
async finger(handle: string) {
const match = handle.match(usernameMatcher);
if (!match) {
throw new Error('Invalid username');
}
const username = match[1];
const host = match[2];
let protocol = 'https';
// TODO Never in prod
if (host.startsWith('localhost') || host.startsWith('127.0.0.1')) {
protocol = 'http';
}
const res = await fetch(`${protocol}://${host}/.well-known/webfinger?resource=acct:${username}@${host}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json: any = await res.json();
if (json.subject !== `acct:${username}@${host}`) {
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, {
headers: {
accept: self.type
}
});
const data = await selfRes.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return data as any;
}
}

View File

@ -1,18 +0,0 @@
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

@ -1,63 +0,0 @@
import {Actor} from '../../core/activitypub/actor.entity';
import {ActorRepository} from '../../core/activitypub/actor.repository';
import ObjectID from 'bson-objectid';
import {Inject} from '@nestjs/common';
import {SettingsCache} from '../../common/types/settings-cache.type';
interface DomainEvents {
dispatch(event: unknown): void
}
export class ActorRepositoryInMemory implements ActorRepository {
actors: Actor[];
private readonly domainEvents: DomainEvents;
constructor(
@Inject('SettingsCache') settingsCache: SettingsCache,
@Inject('DomainEvents') domainEvents: DomainEvents
) {
this.actors = [
Actor.create({
id: ObjectID.createFromHexString('deadbeefdeadbeefdeadbeef'),
username: 'index',
displayName: settingsCache.get('title'),
publicKey: settingsCache.get('ghost_public_key'),
privateKey: settingsCache.get('ghost_private_key'),
following: []
})
];
this.domainEvents = domainEvents;
}
private getOneByUsername(username: string) {
return this.actors.find(actor => actor.username === username) || null;
}
private getOneById(id: ObjectID) {
return this.actors.find(actor => actor.id.equals(id)) || null;
}
async getOne(identifier: string | ObjectID) {
if (identifier instanceof ObjectID) {
return this.getOneById(identifier);
} else {
return this.getOneByUsername(identifier);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async save(actor: Actor) {
if (!this.actors.includes(actor)) {
this.actors.push(actor);
}
Actor.getActivitiesToSave(actor, (/* activities */) => {
// Persist activities
});
Actor.getEventsToDispatch(actor, (events) => {
for (const event of events) {
this.domainEvents.dispatch(event);
}
});
}
}

View File

@ -1,58 +0,0 @@
import {Inject} from '@nestjs/common';
import ObjectID from 'bson-objectid';
import {PostRepository} from '../../core/activitypub/post.repository';
import {URI} from '../../core/activitypub/uri.object';
import htmlToPlaintext from '@tryghost/html-to-plaintext';
type UrlUtils = {
transformReadyToAbsolute(html: string): string
}
export class KnexPostRepository implements PostRepository {
constructor(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Inject('knex') private readonly knex: any,
@Inject('UrlUtils') private readonly urlUtils: UrlUtils
) {}
async getOne(identifier: ObjectID) {
return this.getOneById(identifier);
}
async getOneById(id: ObjectID) {
const row = await this.knex('posts').where('id', id.toHexString()).first();
const authorRows = await this.knex('users')
.leftJoin('posts_authors', 'users.id', 'posts_authors.author_id')
.where('posts_authors.post_id', id.toHexString())
.select('users.name');
if (!row) {
return null;
}
let excerpt = row.custom_excerpt;
if (!excerpt) {
const metaRow = await this.knex('posts_meta').where('post_id', id.toHexString()).select('meta_description').first();
if (metaRow?.meta_description) {
excerpt = metaRow.meta_description;
}
}
if (!excerpt) {
excerpt = htmlToPlaintext.excerpt(row.html);
}
return {
id,
title: row.title,
html: this.urlUtils.transformReadyToAbsolute(row.html),
slug: row.slug,
visibility: row.visibility,
featuredImage: row.feature_image ? new URI(row.feature_image) : null,
publishedAt: row.published_at ? new Date(row.published_at) : null,
authors: authorRows.map((authorRow: {name: string}) => authorRow.name),
excerpt,
url: new URI('') // TODO: Get URL for Post
};
}
};

View File

@ -1,20 +0,0 @@
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'));
});
});
});

View File

@ -1,28 +0,0 @@
import {
Controller,
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 {};
}
}

View File

@ -1,142 +0,0 @@
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';
import {URI} from '../../../core/activitypub/uri.object';
describe('ActivityPubController', function () {
let app: NestApplication;
before(async function () {
const moduleRef = await Test.createTestingModule({
controllers: [ActivityPubController],
providers: [
{
provide: 'logger',
useValue: console
},
{
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',
authors: ['Mr Roach'],
url: new URI('roachie'),
publishedAt: new Date(),
featuredImage: null,
excerpt: 'testing...'
};
}
}
},
{
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 inbox', async function () {
await request(app.getHttpServer())
.get('/activitypub/inbox/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 the followers', async function () {
await request(app.getHttpServer())
.get('/activitypub/followers/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,108 +0,0 @@
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';
@Controller('activitypub')
export class ActivityPubController {
constructor(
private readonly service: JSONLDService,
private readonly inboxService: InboxService
) {}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('actor/:id')
async getActor(@Param('id') id: unknown) {
if (typeof id !== 'string') {
throw new Error('Bad Request');
}
return this.service.getActor(ObjectID.createFromHexString(id));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('inbox/:owner')
async getInbox(@Param('owner') owner: unknown) {
if (typeof owner !== 'string') {
throw new Error('Bad Request');
}
return this.service.getInbox(ObjectID.createFromHexString(owner));
}
@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'])
@Get('outbox/:owner')
async getOutbox(@Param('owner') owner: unknown) {
if (typeof owner !== 'string') {
throw new Error('Bad Request');
}
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.service.getFollowing(ObjectID.createFromHexString(owner));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('followers/:owner')
async getFollowers(@Param('owner') owner: unknown) {
if (typeof owner !== 'string') {
throw new Error('Bad Request');
}
return this.service.getFollowers(ObjectID.createFromHexString(owner));
}
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('article/:id')
async getArticle(@Param('id') id: unknown) {
if (typeof id !== 'string') {
throw new Error('Bad Request');
}
return this.service.getArticle(ObjectID.createFromHexString(id));
}
}

View File

@ -1,38 +0,0 @@
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

@ -1,18 +0,0 @@
import {Controller, Get, Header, Query} from '@nestjs/common';
import {WebFingerService} from '../../../core/activitypub/webfinger.service';
@Controller('.well-known/webfinger')
export class WebFingerController {
constructor(
private readonly service: WebFingerService
) {}
@Header('Cache-Control', 'no-store')
@Get('')
async getResource(@Query('resource') resource: unknown) {
if (typeof resource !== 'string') {
throw new Error('Bad Request');
}
return await this.service.getResource(resource);
}
}

View File

@ -1,51 +0,0 @@
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'), console));
const actor = Actor.create({
username: 'Testing'
});
const toFollow = new URI('https://example.com/user');
const activity = new Activity({
type: 'Follow',
activity: null,
actor: actor,
object: {
type: 'Person',
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

@ -1,15 +0,0 @@
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,47 +0,0 @@
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';
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: [],
providers: [
{
provide: 'ActorRepository',
useClass: ActorRepositoryInMemory
},
{
provide: 'ActivityService',
useClass: ActivityService
},
{
provide: 'PostRepository',
useClass: KnexPostRepository
},
{
provide: 'ActivityRepository',
useClass: ActivityRepositoryInMemory
},
WebFingerService,
JSONLDService,
HTTPSignature,
ActivityService,
InboxService,
ActivityListener,
ActivityPubService,
TheWorld
]
})
export class ActivityPubModule {}

View File

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

View File

@ -4,7 +4,6 @@ import {AdminAPIModule} from './admin-api.module';
import {NotFoundFallthroughExceptionFilter} from '../filters/not-found-fallthrough.filter'; import {NotFoundFallthroughExceptionFilter} from '../filters/not-found-fallthrough.filter';
import {ExampleListener} from '../../listeners/example.listener'; import {ExampleListener} from '../../listeners/example.listener';
import {GlobalExceptionFilter} from '../filters/global-exception.filter'; import {GlobalExceptionFilter} from '../filters/global-exception.filter';
import {ActivityPubModule} from './activitypub.module';
class AppModuleClass {} class AppModuleClass {}
@ -16,13 +15,9 @@ export const AppModule: DynamicModule = {
{ {
path: 'ghost/api/admin', path: 'ghost/api/admin',
module: AdminAPIModule module: AdminAPIModule
}, {
path: '',
module: ActivityPubModule
} }
]), ]),
AdminAPIModule, AdminAPIModule
ActivityPubModule
], ],
exports: [], exports: [],
controllers: [], controllers: [],

View File

@ -12,8 +12,6 @@ const {
PostsBulkUnfeaturedEvent, PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent PostsBulkAddTagsEvent
} = require('@tryghost/post-events'); } = require('@tryghost/post-events');
const GhostNestApp = require('@tryghost/ghost');
const {default: ObjectID} = require('bson-objectid');
const messages = { const messages = {
invalidVisibilityFilter: 'Invalid visibility filter.', invalidVisibilityFilter: 'Invalid visibility filter.',
@ -209,13 +207,6 @@ class PostsService {
} }
} }
if (this.isSet('ActivityPub')) {
if (model.previous('status') !== model.get('status') && model.get('status') === 'published') {
const activityService = await GhostNestApp.resolve('ActivityService');
await activityService.createArticleForPost(ObjectID.createFromHexString(model.id));
}
}
if (typeof options?.eventHandler === 'function') { if (typeof options?.eventHandler === 'function') {
await options.eventHandler(this.getChanges(model), dto); await options.eventHandler(this.getChanges(model), dto);
} }