Skip to content

Commit 04c49e6

Browse files
authored
Removed full support for the ActivityPub Outbox
ref https://linear.app/ghost/issue/PROD-1733 This was still reliant on the key_value table, which we want to remove support for. As a first step we are reducing the support for the outbox, which isn't used by our application, and isn't essential for federation. We still support the endpoint, but it always returns an empty collection. Our e2e cucumber tests had a lot of reliance on the outbox, so the tests and step defs have been updated to preserve features and to keep the suite passing.
1 parent 1fed876 commit 04c49e6

29 files changed

+304
-465
lines changed

features/create-article-from-post.feature

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
Feature: Deliver Create(Article) activities when a post.published webhook is received
22

33
Scenario: We recieve a webhook for the post.published event
4-
Given a "post.published" webhook
4+
Given we are followed by "Alice"
5+
And a "post.published" webhook
56
When it is sent to the webhook endpoint
67
Then the request is accepted
7-
Then a "Create(Article)" activity is in the Outbox
8-
And the found "Create(Article)" has property "object.attributedTo"
8+
Then A "Create(Article)" Activity is sent to all followers
99

1010
Scenario: We recieve a webhook for the post.published event and the post has no content
11-
Given a "post.published" webhook:
11+
Given we are followed by "Alice"
12+
And a "post.published" webhook:
1213
| property | value |
1314
| post.current.html | null |
1415
| post.current.excerpt | null |
1516
When it is sent to the webhook endpoint
1617
Then the request is accepted
17-
Then a "Create(Article)" activity is in the Outbox
18-
And the found "Create(Article)" has property "object.attributedTo"
18+
Then A "Create(Article)" Activity is sent to all followers
1919

2020
Scenario: We recieve a webhook for the post.published event with an old signature
2121
Given a "post.published" webhook

features/create-note.feature

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,27 @@ Feature: Creating a note
88
When we attempt to create a note with invalid content
99
Then the request is rejected with a 400
1010

11-
Scenario: Created note is added to the Outbox
12-
When we create a note "Note" with the content
13-
"""
14-
Hello, world!
15-
"""
16-
Then "Note" is in our Outbox
17-
1811
Scenario: Created note is formatted
12+
Given we are followed by "Alice"
1913
When we create a note "Note" with the content
2014
"""
2115
Hello
2216
World
2317
"""
24-
Then "Note" is in our Outbox
18+
Then Activity with object "Note" is sent to all followers
2519
And "Note" has the content "<p>Hello<br />World</p>"
2620

2721
Scenario: Created note has user provided HTML escaped
2822
If HTML is provided as user input, it should be escaped. The content
2923
should still be wrapped in an unescaped <p> though.
3024

25+
Given we are followed by "Alice"
3126
When we create a note "Note" with the content
3227
"""
3328
<p>Hello, world!</p>
3429
<script>alert("Hello, world!");</script>
3530
"""
36-
Then "Note" is in our Outbox
31+
Then Activity with object "Note" is sent to all followers
3732
And "Note" has the content "<p>&lt;p&gt;Hello, world!&lt;/p&gt;<br />&lt;script&gt;alert(\"Hello, world!\");&lt;/script&gt;</p>"
3833

3934
Scenario: Created note is sent to followers
@@ -48,11 +43,12 @@ Feature: Creating a note
4843
Then Activity with object "Note" is sent to all followers
4944

5045
Scenario: Creating a note with an image URL
46+
Given we are followed by "Alice"
5147
When we create a note "Note" with imageUrl "http://localhost:4443/image.jpg" and content
5248
"""
5349
Hello, world!
5450
"""
55-
Then "Note" is in our Outbox
51+
Then Activity with object "Note" is sent to all followers
5652
And "Note" has the content "<p>Hello, world!</p>"
5753
And note "Note" has the image URL "http://localhost:4443/image.jpg"
5854

features/create-reply.feature

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Feature: Creating a reply
2525
"""
2626
This is a great article!
2727
"""
28-
Then "Reply" is in our Outbox
28+
Then Activity with object "Reply" is sent to all followers
2929
And "Reply" has the content "<p>This is a great article!</p>"
3030

3131
Scenario: Created reply contains newlines
@@ -34,15 +34,15 @@ Feature: Creating a reply
3434
Hello
3535
World
3636
"""
37-
Then "Reply" is in our Outbox
37+
Then Activity with object "Reply" is sent to all followers
3838
And "Reply" has the content "<p>Hello<br />World</p>"
3939

4040
Scenario: Created reply has user provided HTML escaped
4141
When we reply "Reply" to "Article" with the content
4242
"""
4343
This is a great article!<script>alert("Hello, world!");</script>
4444
"""
45-
Then "Reply" is in our Outbox
45+
Then Activity with object "Reply" is sent to all followers
4646
And "Reply" has the content "<p>This is a great article!&lt;script&gt;alert(\"Hello, world!\");&lt;/script&gt;</p>"
4747

4848
Scenario: Created reply is sent to followers
@@ -58,7 +58,7 @@ Feature: Creating a reply
5858
"""
5959
This is a great article!
6060
"""
61-
Then "Reply" is in our Outbox
61+
Then Activity with object "Reply" is sent to all followers
6262
And "Reply" has the content "<p>This is a great article!</p>"
6363
And note "Reply" has the image URL "http://localhost:4443/image.jpg"
6464

features/delete-post.feature

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ Feature: Delete a post
1111
Hello
1212
World
1313
"""
14-
And "OurNote" is in our Outbox
1514
And an authenticated request is made to "/.ghost/activitypub/feed"
1615
And the request is accepted
1716
And "OurNote" is in the feed
@@ -23,7 +22,6 @@ Feature: Delete a post
2322
When an authenticated request is made to "/.ghost/activitypub/feed"
2423
Then the request is accepted
2524
And "OurNote" is not in the feed
26-
And "OurNote" is not in our Outbox
2725
And a "Delete(OurNote)" activity is sent to "Alice"
2826

2927
Scenario: Attempting to delete another user's post

features/handle-replies.feature

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,8 @@
11
Feature: Create(Note<inReplyTo>)
22
We want to handle incoming replies to our content and add them to the inbox.
33

4-
Scenario: We recieve a Create(Note) in response to our content from someone we don't follow
5-
# Setup our article
6-
Given a "post.published" webhook
7-
When it is sent to the webhook endpoint
8-
Then the request is accepted
9-
Then a "Create(Article)" activity is in the Outbox
10-
11-
Given the found "Create(Article)" as "ArticleCreate(OurArticle)"
12-
13-
Given an Actor "Person(Alice)"
14-
Given a "Note" Object "Reply" by "Alice"
15-
And "Reply" is a reply to "OurArticle"
16-
And a "Create(Reply)" Activity "A" by "Alice"
17-
When "Alice" sends "A" to the Inbox
18-
Then the request is accepted
19-
Then "A" is in our Inbox
4+
Scenario: We recieve a reply to our content from someone we don't follow
5+
Given we are not following "Alice"
6+
And we publish an article
7+
When "Alice" sends us a reply to our article
8+
Then the reply is in our notifications

features/step_definitions/activitypub_steps.js

Lines changed: 47 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import assert from 'node:assert';
22

33
import { Given, Then, When } from '@cucumber/cucumber';
44

5-
import {
6-
findInOutbox,
7-
waitForInboxActivity,
8-
waitForOutboxActivity,
9-
waitForOutboxObject,
10-
} from '../support/activitypub.js';
5+
import { waitForInboxActivity } from '../support/activitypub.js';
116
import {
127
createActivity,
138
createActor,
@@ -266,72 +261,6 @@ Then(
266261
},
267262
);
268263

269-
Then('{string} is not in our Outbox', async function (activityName) {
270-
const activity = this.activities[activityName];
271-
const found = await findInOutbox(activity);
272-
assert(
273-
!found,
274-
`Expected not to find activity "${activityName}" in outbox, but it was found`,
275-
);
276-
});
277-
278-
Then('{string} is in our Outbox', async function (name) {
279-
const activity = this.activities[name];
280-
if (activity) return waitForOutboxActivity(activity);
281-
const object = this.objects[name];
282-
if (object) return waitForOutboxObject(object);
283-
});
284-
285-
async function waitForOutboxActivityType(
286-
activityType,
287-
objectType,
288-
options = {
289-
retryCount: 0,
290-
delay: 0,
291-
},
292-
) {
293-
const MAX_RETRIES = 5;
294-
295-
const initialResponse = await fetchActivityPub(
296-
'http://fake-ghost-activitypub.test/.ghost/activitypub/outbox/index',
297-
{
298-
headers: {
299-
Accept: 'application/ld+json',
300-
},
301-
},
302-
);
303-
const initialResponseJson = await initialResponse.json();
304-
const firstPageReponse = await fetchActivityPub(initialResponseJson.first, {
305-
headers: {
306-
Accept: 'application/ld+json',
307-
},
308-
});
309-
const outbox = await firstPageReponse.json();
310-
311-
const found = (outbox.orderedItems || []).find((item) => {
312-
return item.type === activityType && item.object?.type === objectType;
313-
});
314-
315-
if (found) {
316-
return found;
317-
}
318-
319-
if (options.retryCount === MAX_RETRIES) {
320-
throw new Error(
321-
`Max retries reached (${MAX_RETRIES}) when waiting for ${activityType}(${objectType}) in the outbox`,
322-
);
323-
}
324-
325-
if (options.delay > 0) {
326-
await new Promise((resolve) => setTimeout(resolve, options.delay));
327-
}
328-
329-
return waitForOutboxActivityType(activityType, objectType, {
330-
retryCount: options.retryCount + 1,
331-
delay: options.delay + 500,
332-
});
333-
}
334-
335264
Then(
336265
'Activity {string} is sent to {string}',
337266
async function (activityName, actorName) {
@@ -388,6 +317,52 @@ Then(
388317
},
389318
);
390319

320+
Then(
321+
'A {string} Activity is sent to all followers',
322+
async function (activityString) {
323+
const followersResponse = await fetchActivityPub(
324+
'http://fake-ghost-activitypub.test/.ghost/activitypub/followers/index',
325+
);
326+
const followersResponseJson = await followersResponse.json();
327+
328+
const [match, activity, object] = activityString.match(
329+
/(\w+)\((\w+)\)/,
330+
) || [null];
331+
if (!match) {
332+
throw new Error(`Could not match ${activityString} to an activity`);
333+
}
334+
335+
const followers = followersResponseJson.orderedItems;
336+
337+
for (const followerUrl of followers) {
338+
const follower = await (await fetchActivityPub(followerUrl)).json();
339+
const inbox = new URL(follower.inbox);
340+
341+
const found = await waitForRequest(
342+
'POST',
343+
inbox.pathname,
344+
(call) => {
345+
const json = JSON.parse(call.request.body);
346+
347+
return (
348+
json.type === activity && json.object.type === object
349+
);
350+
},
351+
);
352+
353+
if (!this.found) {
354+
this.found = {};
355+
}
356+
this.found[activityString] = found;
357+
358+
assert(
359+
found,
360+
`Activity "${activityString}" was not sent to "${follower.name}"`,
361+
);
362+
}
363+
},
364+
);
365+
391366
Then(
392367
'Activity with object {string} is sent to all followers',
393368
async function (objectName) {
@@ -422,21 +397,6 @@ Then(
422397
},
423398
);
424399

425-
Then('a {string} activity is in the Outbox', async function (string) {
426-
const [match, activity, object] = string.match(/(\w+)\((\w+)\)/) || [null];
427-
if (!match) {
428-
throw new Error(`Could not match ${string} to an activity`);
429-
}
430-
431-
const found = await waitForOutboxActivityType(activity, object);
432-
433-
if (!this.found) {
434-
this.found = {};
435-
}
436-
this.found[string] = found;
437-
assert.ok(found);
438-
});
439-
440400
Then('the found {string} as {string}', function (foundName, name) {
441401
const found = this.found[foundName];
442402

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createHmac } from 'node:crypto';
2+
import { Given } from '@cucumber/cucumber';
3+
import { createWebhookPost, getWebhookSecret } from '../support/fixtures.js';
4+
import { fetchActivityPub } from '../support/request.js';
5+
6+
Given('we publish an article', async function () {
7+
if (this.articleId) {
8+
throw new Error('This step does not support multiple articles');
9+
}
10+
const endpoint =
11+
'http://fake-ghost-activitypub.test/.ghost/activitypub/webhooks/post/published';
12+
const payload = createWebhookPost();
13+
const body = JSON.stringify(payload);
14+
const timestamp = Date.now();
15+
const hmac = createHmac('sha256', getWebhookSecret())
16+
.update(body + timestamp)
17+
.digest('hex');
18+
19+
const response = await fetchActivityPub(endpoint, {
20+
method: 'POST',
21+
headers: {
22+
'Content-Type': 'application/json',
23+
'X-Ghost-Signature': `sha256=${hmac}, t=${timestamp}`,
24+
},
25+
body: body,
26+
});
27+
28+
const post = await response.json();
29+
30+
this.articleId = post.id;
31+
});

features/step_definitions/follow_steps.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ async function getActor(input) {
2929
};
3030
}
3131

32+
Given('we are not following {string}', async function (input) {
33+
const { actor } = await getActor.call(this, input);
34+
35+
const unfollowResponse = await fetchActivityPub(
36+
`http://fake-ghost-activitypub.test/.ghost/activitypub/actions/unfollow/${actor.handle}`,
37+
{
38+
method: 'POST',
39+
},
40+
);
41+
42+
if (!unfollowResponse.ok && unfollowResponse.status !== 409) {
43+
throw new Error('Something went wrong');
44+
}
45+
});
46+
3247
Given('we are following {string}', async function (input) {
3348
const { actor } = await getActor.call(this, input);
3449

0 commit comments

Comments
 (0)