diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 0bbc8ce1..ecb97c8d 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -611,12 +611,10 @@ Given('we follow {string}', async function (name) { }, ); if (this.response.ok) { - const actor = await this.response.clone().json(); - this.activities[`Follow(${name})`] = await createActivity( 'Follow', - actor, - actor, + this.actors[name], + this.actors.Us, ); } }); diff --git a/src/account/account.service.integration.test.ts b/src/account/account.service.integration.test.ts index 025b759d..518f9e43 100644 --- a/src/account/account.service.integration.test.ts +++ b/src/account/account.service.integration.test.ts @@ -453,6 +453,39 @@ describe('AccountService', () => { }); }); + describe('checkIfAccountIsFollowing', () => { + it('should check if an account is following another account', async () => { + const account = await service.createInternalAccount( + site, + 'account', + ); + const followee = await service.createInternalAccount( + site, + 'followee', + ); + const nonFollowee = await service.createInternalAccount( + site, + 'non-followee', + ); + + await service.recordAccountFollow(followee, account); + + const isFollowing = await service.checkIfAccountIsFollowing( + account, + followee, + ); + + expect(isFollowing).toBe(true); + + const isNotFollowing = await service.checkIfAccountIsFollowing( + account, + nonFollowee, + ); + + expect(isNotFollowing).toBe(false); + }); + }); + it('should update accounts and emit an account.updated event if they have changed', async () => { const account = await service.createInternalAccount( site, diff --git a/src/account/account.service.ts b/src/account/account.service.ts index 8875764e..aba6caa5 100644 --- a/src/account/account.service.ts +++ b/src/account/account.service.ts @@ -246,6 +246,25 @@ export class AccountService { return Number(result[0].count); } + /** + * Check if an account is following another account + * + * @param account Account to check + * @param followee Followee account + */ + async checkIfAccountIsFollowing( + account: Account, + followee: Account, + ): Promise { + const result = await this.db(TABLE_FOLLOWS) + .where('follower_id', account.id) + .where('following_id', followee.id) + .select(1) + .first(); + + return result !== undefined; + } + async getByInternalId(id: number): Promise { const rows = await this.db(TABLE_ACCOUNTS).select('*').where({ id }); diff --git a/src/app.ts b/src/app.ts index 9dcb890d..4f422fee 100644 --- a/src/app.ts +++ b/src/app.ts @@ -73,7 +73,7 @@ import { updateDispatcher, } from './dispatchers'; import { - followAction, + createFollowActionHandler, getSiteDataHandler, inboxHandler, likeAction, @@ -755,7 +755,7 @@ app.get( app.post( '/.ghost/activitypub/actions/follow/:handle', requireRole(GhostRole.Owner), - spanWrapper(followAction), + spanWrapper(createFollowActionHandler(accountService)), ); app.post( '/.ghost/activitypub/actions/like/:id', diff --git a/src/handlers.ts b/src/handlers.ts index e86ff821..1b4b4149 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -13,17 +13,19 @@ import { import { Temporal } from '@js-temporal/polyfill'; import type { Context } from 'hono'; import { v4 as uuidv4 } from 'uuid'; +import z from 'zod'; + +import type { AccountService } from './account/account.service'; +import { mapActorToExternalAccountData } from './account/utils'; import { type HonoContextVariables, fedify } from './app'; import { ACTOR_DEFAULT_HANDLE } from './constants'; import { buildActivity } from './helpers/activitypub/activity'; +import { updateSiteActor } from './helpers/activitypub/actor'; +import { getSiteSettings } from './helpers/ghost'; import { escapeHtml } from './helpers/html'; import { getUserData } from './helpers/user'; import { addToList, removeFromList } from './kv-helpers'; import { lookupActor, lookupObject } from './lookup-helpers'; - -import z from 'zod'; -import { updateSiteActor } from './helpers/activitypub/actor'; -import { getSiteSettings } from './helpers/ghost'; import type { SiteService } from './site/site.service'; export async function unlikeAction( @@ -302,61 +304,90 @@ export async function replyAction( }); } -export async function followAction( - ctx: Context<{ Variables: HonoContextVariables }>, -) { - const handle = ctx.req.param('handle'); - const apCtx = fedify.createContext(ctx.req.raw as Request, { - db: ctx.get('db'), - globaldb: ctx.get('globaldb'), - logger: ctx.get('logger'), - }); - const actorToFollow = await lookupObject(apCtx, handle); - if (!isActor(actorToFollow)) { - // Not Found? - return new Response(null, { - status: 404, +export function createFollowActionHandler(accountService: AccountService) { + return async function followAction( + ctx: Context<{ Variables: HonoContextVariables }>, + ) { + const handle = ctx.req.param('handle'); + const apCtx = fedify.createContext(ctx.req.raw as Request, { + db: ctx.get('db'), + globaldb: ctx.get('globaldb'), + logger: ctx.get('logger'), }); - } + const actorToFollow = await lookupObject(apCtx, handle); - const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE); // TODO This should be the actor making the request + if (!isActor(actorToFollow)) { + return new Response(null, { + status: 404, + }); + } - if (actorToFollow.id!.href === actor!.id!.href) { - return new Response(null, { - status: 400, + const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE); // TODO This should be the actor making the request + + if (actorToFollow.id!.href === actor!.id!.href) { + return new Response(null, { + status: 400, + }); + } + + const followerAccount = await accountService.getAccountByApId( + actor!.id!.href, + ); + + if (!followerAccount) { + return new Response(null, { + status: 404, + }); + } + + let followeeAccount = await accountService.getAccountByApId( + actorToFollow.id!.href, + ); + if (!followeeAccount) { + followeeAccount = await accountService.createExternalAccount( + await mapActorToExternalAccountData(actorToFollow), + ); + } + + if ( + await accountService.checkIfAccountIsFollowing( + followerAccount, + followeeAccount, + ) + ) { + return new Response(null, { + status: 409, + }); + } + + const followId = apCtx.getObjectUri(Follow, { + id: uuidv4(), }); - } - const following = (await ctx.get('db').get(['following'])) || []; - if (following.includes(actorToFollow.id!.href)) { - return new Response(null, { - status: 409, + const follow = new Follow({ + id: followId, + actor: actor, + object: actorToFollow, }); - } - const followId = apCtx.getObjectUri(Follow, { - id: uuidv4(), - }); - const follow = new Follow({ - id: followId, - actor: actor, - object: actorToFollow, - }); - const followJson = await follow.toJsonLd(); - ctx.get('globaldb').set([follow.id!.href], followJson); + const followJson = await follow.toJsonLd(); - await apCtx.sendActivity( - { handle: ACTOR_DEFAULT_HANDLE }, - actorToFollow, - follow, - ); - // We return the actor because the serialisation of the object property is not working as expected - return new Response(JSON.stringify(await actorToFollow.toJsonLd()), { - headers: { - 'Content-Type': 'application/activity+json', - }, - status: 200, - }); + ctx.get('globaldb').set([follow.id!.href], followJson); + + await apCtx.sendActivity( + { handle: ACTOR_DEFAULT_HANDLE }, + actorToFollow, + follow, + ); + + // We return the actor because the serialisation of the object property is not working as expected + return new Response(JSON.stringify(await actorToFollow.toJsonLd()), { + headers: { + 'Content-Type': 'application/activity+json', + }, + status: 200, + }); + }; } export const getSiteDataHandler =