-
-
Notifications
You must be signed in to change notification settings - Fork 10.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ref https://linear.app/tryghost/issue/MOM-108 Apologies to my future self and maintainers if you come across this commit. This is a bit of a mega commit because we need to cut corners somewhere and it came down to commit atomicity or tests/code quality. The main changes here are a bunch of tests, as well as some scaffolding for Inbox handling of Activities and delivery of Activities. The structure is not final at all - and we have logic split across services which isn't ideal - but thsi will do for now as we play around and discover the structure through building.
- Loading branch information
Showing
31 changed files
with
1,345 additions
and
66 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
declare module '@tryghost/errors'; | ||
declare module '@tryghost/domain-events'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import assert from 'assert'; | ||
import {Activity} from './activity.entity'; | ||
import {URI} from './uri.object'; | ||
|
||
describe('Activity', function () { | ||
describe('fromJSONLD', function () { | ||
it('Can construct an entity from JSONLD with various id types', async function () { | ||
const input = { | ||
id: new URI('https://site.com/activity'), | ||
type: 'Follow', | ||
actor: { | ||
id: 'https://site.com/actor' | ||
}, | ||
object: 'https://site.com/object' | ||
}; | ||
|
||
const activity = Activity.fromJSONLD(input); | ||
assert(activity); | ||
}); | ||
|
||
it('Will throw for unknown types', async function () { | ||
const input = { | ||
id: new URI('https://site.com/activity'), | ||
type: 'Unknown', | ||
actor: { | ||
id: 'https://site.com/actor' | ||
}, | ||
object: 'https://site.com/object' | ||
}; | ||
|
||
assert.throws(() => { | ||
Activity.fromJSONLD(input); | ||
}); | ||
}); | ||
|
||
it('Will throw for missing actor,object or type', async function () { | ||
const input = { | ||
id: new URI('https://site.com/activity'), | ||
type: 'Unknown', | ||
actor: { | ||
id: 'https://site.com/actor' | ||
}, | ||
object: 'https://site.com/object' | ||
}; | ||
|
||
for (const prop of ['actor', 'object', 'type']) { | ||
const modifiedInput = Object.create(input); | ||
delete modifiedInput[prop]; | ||
assert.throws(() => { | ||
Activity.fromJSONLD(modifiedInput); | ||
}); | ||
} | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import {Entity} from '../../common/entity.base'; | ||
import {ActivityPub} from './types'; | ||
import {URI} from './uri.object'; | ||
|
||
type ActivityData = { | ||
activity: URI | null; | ||
type: ActivityPub.ActivityType; | ||
actor: URI; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
object: {id: URI, [x: string]: any}; | ||
to: URI | null; | ||
} | ||
|
||
function getURI(input: unknown) { | ||
if (input instanceof URI) { | ||
return input; | ||
} | ||
if (typeof input === 'string') { | ||
return new URI(input); | ||
} | ||
if (typeof input !== 'object' || input === null) { | ||
throw new Error(`Could not create URI from ${JSON.stringify(input)}`); | ||
} | ||
if ('id' in input && typeof input.id === 'string') { | ||
return new URI(input.id); | ||
} | ||
throw new Error(`Could not create URI from ${JSON.stringify(input)}`); | ||
} | ||
|
||
function checkKeys<T extends string>(keys: T[], obj: object): Record<T, unknown> { | ||
for (const key of keys) { | ||
if (!(key in obj)) { | ||
throw new Error(`Missing key ${key}`); | ||
} | ||
} | ||
return obj as Record<T, unknown>; | ||
} | ||
|
||
export class Activity extends Entity<ActivityData> { | ||
get type() { | ||
return this.attr.type; | ||
} | ||
|
||
getObject() { | ||
return this.attr.object; | ||
} | ||
|
||
get actorId() { | ||
return this.attr.actor; | ||
} | ||
|
||
get objectId() { | ||
return this.attr.object.id; | ||
} | ||
|
||
get activityId() { | ||
return this.attr.activity; | ||
} | ||
|
||
getJSONLD(url: URL): ActivityPub.Activity { | ||
return { | ||
'@context': 'https://www.w3.org/ns/activitystreams', | ||
id: this.activityId?.getValue(url) || null, | ||
type: this.attr.type, | ||
actor: { | ||
type: 'Person', | ||
id: this.actorId.getValue(url), | ||
username: `@index@${this.actorId.hostname}` | ||
}, | ||
object: this.objectId.getValue(url), | ||
to: this.attr.to?.getValue(url) || null | ||
}; | ||
} | ||
|
||
static fromJSONLD(json: object) { | ||
const parsed = checkKeys(['type', 'actor', 'object'], json); | ||
if (typeof parsed.type !== 'string' || !['Create', 'Follow', 'Accept'].includes(parsed.type)) { | ||
throw new Error(`Unknown type ${parsed.type}`); | ||
} | ||
return new Activity({ | ||
activity: 'id' in json ? getURI(json.id) : null, | ||
type: parsed.type as ActivityPub.ActivityType, | ||
actor: getURI(parsed.actor), | ||
object: {id: getURI(parsed.object)}, | ||
to: 'to' in json ? getURI(json.to) : null | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,14 @@ | ||
import {BaseEvent} from '../../common/event.base'; | ||
import {Activity} from './activity.object'; | ||
import {Activity} from './activity.entity'; | ||
import {Actor} from './actor.entity'; | ||
|
||
type ActivityEventData = { | ||
activity: Activity | ||
activity: Activity, | ||
actor: Actor | ||
} | ||
|
||
export class ActivityEvent extends BaseEvent<ActivityEventData> { | ||
static create(activity: Activity) { | ||
return new ActivityEvent({activity}); | ||
static create(activity: Activity, actor: Actor) { | ||
return new ActivityEvent({activity, actor}); | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import {Activity} from './activity.entity'; | ||
|
||
export interface ActivityRepository { | ||
getOne(id: URL): Promise<Activity | null> | ||
save(activity: Activity): Promise<void> | ||
} |
129 changes: 129 additions & 0 deletions
129
ghost/ghost/src/core/activitypub/activity.service.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import ObjectID from 'bson-objectid'; | ||
import {ActivityService} from './activity.service'; | ||
import {Actor} from './actor.entity'; | ||
import assert from 'assert'; | ||
|
||
describe('ActivityService', function () { | ||
describe('#createArticleForPost', function () { | ||
it('Adds a Create activity for an Article Object to the default actors Outbox', async function () { | ||
const actor = Actor.create({username: 'testing'}); | ||
const mockActorRepository = { | ||
async getOne() { | ||
return actor; | ||
}, | ||
async save() {} | ||
}; | ||
const mockPostRepository = { | ||
async getOne(id: ObjectID) { | ||
return { | ||
id: id, | ||
title: 'Testing', | ||
slug: 'testing', | ||
html: '<p> Testing stuff.. </p>', | ||
visibility: 'public' | ||
}; | ||
} | ||
}; | ||
const service = new ActivityService( | ||
mockActorRepository, | ||
mockPostRepository | ||
); | ||
|
||
const postId = new ObjectID(); | ||
|
||
await service.createArticleForPost(postId); | ||
|
||
const found = actor.outbox.find(activity => activity.type === 'Create'); | ||
|
||
assert.ok(found); | ||
}); | ||
|
||
it('Does not add a Create activity for non public posts', async function () { | ||
const actor = Actor.create({username: 'testing'}); | ||
const mockActorRepository = { | ||
async getOne() { | ||
return actor; | ||
}, | ||
async save() {} | ||
}; | ||
const mockPostRepository = { | ||
async getOne(id: ObjectID) { | ||
return { | ||
id: id, | ||
title: 'Testing', | ||
slug: 'testing', | ||
html: '<p> Testing stuff.. </p>', | ||
visibility: 'private' | ||
}; | ||
} | ||
}; | ||
const service = new ActivityService( | ||
mockActorRepository, | ||
mockPostRepository | ||
); | ||
|
||
const postId = new ObjectID(); | ||
|
||
await service.createArticleForPost(postId); | ||
|
||
const found = actor.outbox.find(activity => activity.type === 'Create'); | ||
|
||
assert.ok(!found); | ||
}); | ||
|
||
it('Throws when post is not found', async function () { | ||
const actor = Actor.create({username: 'testing'}); | ||
const mockActorRepository = { | ||
async getOne() { | ||
return actor; | ||
}, | ||
async save() {} | ||
}; | ||
const mockPostRepository = { | ||
async getOne() { | ||
return null; | ||
} | ||
}; | ||
const service = new ActivityService( | ||
mockActorRepository, | ||
mockPostRepository | ||
); | ||
|
||
const postId = new ObjectID(); | ||
|
||
await assert.rejects(async () => { | ||
await service.createArticleForPost(postId); | ||
}, /Post not found/); | ||
}); | ||
|
||
it('Throws when actor is not found', async function () { | ||
const mockActorRepository = { | ||
async getOne() { | ||
return null; | ||
}, | ||
async save() {} | ||
}; | ||
const mockPostRepository = { | ||
async getOne(id: ObjectID) { | ||
return { | ||
id: id, | ||
title: 'Testing', | ||
slug: 'testing', | ||
html: '<p> Testing stuff.. </p>', | ||
visibility: 'private' | ||
}; | ||
} | ||
}; | ||
const service = new ActivityService( | ||
mockActorRepository, | ||
mockPostRepository | ||
); | ||
|
||
const postId = new ObjectID(); | ||
|
||
await assert.rejects(async () => { | ||
await service.createArticleForPost(postId); | ||
}, /Actor not found/); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.