Skip to content

Commit

Permalink
Added API endpoint to repost a post (#302)
Browse files Browse the repository at this point in the history
ref https://linear.app/ghost/issue/AP-702

- added `POST /actions/reposts/:id` endpoint to repost a post
- "Repost" is the equivalent of the "Announce" activity in the ActivityPub vocabulary, or "Boost" in the Mastodon vocabulary
  • Loading branch information
sagzy authored Feb 5, 2025
1 parent dd3a1ae commit 52648a0
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 2 deletions.
32 changes: 32 additions & 0 deletions features/repost-activity.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Feature: Reposting a post
In order to share content with my followers
As a user
I want to be able to repost a post in my feed

Scenario: Reposting a post that has not been reposted before
Given an Actor "Person(Alice)"
And we follow "Alice"
And the request is accepted
And a "Accept(Follow(Alice))" Activity "Accept" by "Alice"
And "Alice" sends "Accept" to the Inbox
And "Accept" is in our Inbox
And a "Create(Note)" Activity "Note" by "Alice"
And "Alice" sends "Note" to the Inbox
And "Note" is in our Inbox
When we repost the object "Note"
Then a "Announce(Note)" activity is sent to "Alice"

Scenario: Reposting an post that has been reposted before
Given an Actor "Person(Alice)"
And we follow "Alice"
And the request is accepted
And a "Accept(Follow(Alice))" Activity "Accept" by "Alice"
And "Alice" sends "Accept" to the Inbox
And "Accept" is in our Inbox
And a "Create(Note)" Activity "Note" by "Alice"
And "Alice" sends "Note" to the Inbox
And "Note" is in our Inbox
And we repost the object "Note"
Then the request is accepted
Then we repost the object "Note"
Then the request is rejected with a 409
10 changes: 10 additions & 0 deletions features/step_definitions/stepdefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,16 @@ Then('the object {string} should not be liked', async function (name) {
assert(found.object.liked !== true);
});

When('we repost the object {string}', async function (name) {
const id = this.objects[name].id;
this.response = await fetchActivityPub(
`http://fake-ghost-activitypub/.ghost/activitypub/actions/repost/${encodeURIComponent(id)}`,
{
method: 'POST',
},
);
});

async function getObjectInCollection(objectName, collectionType) {
const initialResponse = await fetchActivityPub(
`http://fake-ghost-activitypub/.ghost/activitypub/${collectionType}/index`,
Expand Down
13 changes: 12 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { client } from './db';
import {
acceptDispatcher,
actorDispatcher,
announceDispatcher,
articleDispatcher,
createAcceptHandler,
createAnnounceHandler,
Expand Down Expand Up @@ -76,6 +77,7 @@ import {
import { FeedService } from './feed/feed.service';
import {
createFollowActionHandler,
createRepostActionHandler,
createUnfollowActionHandler,
getSiteDataHandler,
inboxHandler,
Expand Down Expand Up @@ -380,7 +382,11 @@ fedify.setObjectDispatcher(
'/.ghost/activitypub/undo/{id}',
spanWrapper(undoDispatcher),
);

fedify.setObjectDispatcher(
Announce,
'/.ghost/activitypub/announce/{id}',
spanWrapper(announceDispatcher),
);
fedify.setNodeInfoDispatcher(
'/.ghost/activitypub/nodeinfo/2.1',
spanWrapper(nodeInfoDispatcher),
Expand Down Expand Up @@ -798,6 +804,11 @@ app.post(
requireRole(GhostRole.Owner),
spanWrapper(replyAction),
);
app.post(
'/.ghost/activitypub/actions/repost/:id',
requireRole(GhostRole.Owner),
spanWrapper(createRepostActionHandler(accountService)),
);
app.post(
'/.ghost/activitypub/actions/note',
requireRole(GhostRole.Owner),
Expand Down
14 changes: 13 additions & 1 deletion src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Accept,
Activity,
type Actor,
type Announce,
Announce,
Article,
type Context,
Create,
Expand Down Expand Up @@ -1300,6 +1300,18 @@ export async function likeDispatcher(
return Like.fromJsonLd(exists);
}

export async function announceDispatcher(
ctx: RequestContext<ContextData>,
data: Record<'id', string>,
) {
const id = ctx.getObjectUri(Announce, data);
const exists = await ctx.data.globaldb.get([id.href]);
if (!exists) {
return null;
}
return Announce.fromJsonLd(exists);
}

export async function undoDispatcher(
ctx: RequestContext<ContextData>,
data: Record<'id', string>,
Expand Down
86 changes: 86 additions & 0 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import {
type Actor,
Announce,
Create,
Follow,
Like,
Expand Down Expand Up @@ -571,3 +572,88 @@ export async function inboxHandler(
},
);
}

export function createRepostActionHandler(accountService: AccountService) {
return async function repostAction(
ctx: Context<{ Variables: HonoContextVariables }>,
) {
const id = ctx.req.param('id');
const apCtx = fedify.createContext(ctx.req.raw as Request, {
db: ctx.get('db'),
globaldb: ctx.get('globaldb'),
logger: ctx.get('logger'),
});

const objectToRepost = await lookupObject(apCtx, id);
if (!objectToRepost) {
return new Response(null, {
status: 404,
});
}

const announceId = apCtx.getObjectUri(Announce, {
id: createHash('sha256')
.update(objectToRepost.id!.href)
.digest('hex'),
});

if (await ctx.get('globaldb').get([announceId.href])) {
return new Response(null, {
status: 409,
});
}

const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE); // TODO This should be the actor making the request

const announce = new Announce({
id: announceId,
actor: actor,
object: objectToRepost,
to: PUBLIC_COLLECTION,
cc: apCtx.getFollowersUri(ACTOR_DEFAULT_HANDLE),
});

const announceJson = await announce.toJsonLd();

await ctx.get('globaldb').set([announce.id!.href], announceJson);
await addToList(ctx.get('db'), ['reposted'], announce.id!.href);

// Add to the actor's outbox
await addToList(ctx.get('db'), ['outbox'], announce.id!.href);

// Send the announce activity
let attributionActor: Actor | null = null;
if (objectToRepost.attributionId) {
attributionActor = await lookupActor(
apCtx,
objectToRepost.attributionId.href,
);
}
if (attributionActor) {
apCtx.sendActivity(
{ handle: ACTOR_DEFAULT_HANDLE },
attributionActor,
announce,
{
preferSharedInbox: true,
},
);
}

apCtx.sendActivity(
{ handle: ACTOR_DEFAULT_HANDLE },
'followers',
announce,
{
preferSharedInbox: true,
},
);

return new Response(JSON.stringify(announceJson), {
headers: {
'Content-Type': 'application/activity+json',
},
status: 200,
});
};
}

0 comments on commit 52648a0

Please sign in to comment.