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:
parent
9e1a70eed7
commit
5acdafc0e8
@ -388,8 +388,6 @@ async function initNestDependencies() {
|
||||
debug('Begin: initNestDependencies');
|
||||
const GhostNestApp = require('@tryghost/ghost');
|
||||
const providers = [];
|
||||
const urlUtils = require('./shared/url-utils');
|
||||
const activityPubBaseUrl = new URL('activitypub', urlUtils.urlFor('home', true));
|
||||
providers.push({
|
||||
provide: 'logger',
|
||||
useValue: require('@tryghost/logging')
|
||||
@ -402,9 +400,6 @@ async function initNestDependencies() {
|
||||
}, {
|
||||
provide: 'DomainEvents',
|
||||
useValue: require('@tryghost/domain-events')
|
||||
}, {
|
||||
provide: 'ActivityPubBaseURL',
|
||||
useValue: activityPubBaseUrl
|
||||
}, {
|
||||
provide: 'SettingsCache',
|
||||
useValue: require('./shared/settings-cache')
|
||||
|
@ -3,7 +3,6 @@ const path = require('path');
|
||||
const express = require('../../shared/express');
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {MemberPageViewEvent} = require('@tryghost/member-events');
|
||||
const GhostNestApp = require('@tryghost/ghost');
|
||||
|
||||
// App requires
|
||||
const config = require('../../shared/config');
|
||||
@ -21,8 +20,6 @@ const siteRoutes = require('./routes');
|
||||
const shared = require('../../server/web/shared');
|
||||
const errorHandler = require('@tryghost/mw-error-handler');
|
||||
const mw = require('./middleware');
|
||||
const labs = require('../../shared/labs');
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_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)
|
||||
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(linkRedirects.service.handleRequest);
|
||||
|
@ -39,7 +39,7 @@ module.exports = function setupApiApp() {
|
||||
apiApp.use(routes());
|
||||
|
||||
apiApp.use(async function nestApp(req, res, next) {
|
||||
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) {
|
||||
if (labs.isSet('NestPlayground')) {
|
||||
const originalExpressApp = req.app;
|
||||
const app = await GhostNestApp.getApp();
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
@ -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});
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import {Activity} from './activity.entity';
|
||||
|
||||
export interface ActivityRepository {
|
||||
getOne(id: URL): Promise<Activity | null>
|
||||
save(activity: Activity): Promise<void>
|
||||
}
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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 || []
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
};
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
@ -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 {};
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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));
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -2,25 +2,15 @@ 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, ActivityPubController],
|
||||
controllers: [ExampleController],
|
||||
exports: [ExampleService],
|
||||
providers: [
|
||||
ExampleService,
|
||||
ActivityPubService,
|
||||
WebFingerService,
|
||||
{
|
||||
provide: 'ExampleRepository',
|
||||
useClass: ExampleRepositoryInMemory
|
||||
},
|
||||
{
|
||||
provide: 'ActorRepository',
|
||||
useClass: ActorRepositoryInMemory
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@ -4,7 +4,6 @@ import {AdminAPIModule} from './admin-api.module';
|
||||
import {NotFoundFallthroughExceptionFilter} from '../filters/not-found-fallthrough.filter';
|
||||
import {ExampleListener} from '../../listeners/example.listener';
|
||||
import {GlobalExceptionFilter} from '../filters/global-exception.filter';
|
||||
import {ActivityPubModule} from './activitypub.module';
|
||||
|
||||
class AppModuleClass {}
|
||||
|
||||
@ -16,13 +15,9 @@ export const AppModule: DynamicModule = {
|
||||
{
|
||||
path: 'ghost/api/admin',
|
||||
module: AdminAPIModule
|
||||
}, {
|
||||
path: '',
|
||||
module: ActivityPubModule
|
||||
}
|
||||
]),
|
||||
AdminAPIModule,
|
||||
ActivityPubModule
|
||||
AdminAPIModule
|
||||
],
|
||||
exports: [],
|
||||
controllers: [],
|
||||
|
@ -12,8 +12,6 @@ const {
|
||||
PostsBulkUnfeaturedEvent,
|
||||
PostsBulkAddTagsEvent
|
||||
} = require('@tryghost/post-events');
|
||||
const GhostNestApp = require('@tryghost/ghost');
|
||||
const {default: ObjectID} = require('bson-objectid');
|
||||
|
||||
const messages = {
|
||||
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') {
|
||||
await options.eventHandler(this.getChanges(model), dto);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user