Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 123 additions & 7 deletions packages/relay/src/base.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import type { Context, Federation, FederationBuilder } from "@fedify/fedify";
import { isActor, Object as APObject } from "@fedify/vocab";
import type {
Context,
Federation,
FederationBuilder,
InboxContext,
InboxListenerSetters,
} from "@fedify/fedify";
import {
type Actor,
Announce,
Create,
Delete,
Follow,
isActor,
Move,
Object as APObject,
Undo,
Update,
} from "@fedify/vocab";
import type { Logger } from "@logtape/logtape";
import {
handleUndoFollow,
sendFollowResponse,
validateFollowActivity,
} from "./follow.ts";
import {
isRelayFollowerData,
type Relay,
RELAY_SERVER_ACTOR,
type RelayFollower,
type RelayFollowerState,
type RelayOptions,
} from "./types.ts";

/** @internal */
export type RelayableActivity = Create | Delete | Move | Update | Announce;

/**
* Abstract base class for relay implementations.
* Provides common infrastructure for both Mastodon and LitePub relays.
Expand All @@ -19,6 +46,9 @@ export abstract class BaseRelay implements Relay {
protected options: RelayOptions;
protected federation?: Federation<RelayOptions>;

protected abstract readonly initialFollowerState: RelayFollowerState;
protected abstract readonly logger: Logger;

constructor(
options: RelayOptions,
relayBuilder: FederationBuilder<RelayOptions>,
Expand Down Expand Up @@ -126,11 +156,97 @@ export abstract class BaseRelay implements Relay {
return await this.parseFollowerData(actorId, followerData);
}

/**
* Set up inbox listeners for handling ActivityPub activities.
* Each relay type implements this method with protocol-specific logic.
*/
protected abstract setupInboxListeners(): void;
protected shouldSkipFollow(
_ctx: InboxContext<RelayOptions>,
_follower: Actor,
): Promise<boolean> {
return Promise.resolve(false);
}

protected afterFollowApproved(
_ctx: InboxContext<RelayOptions>,
_follower: Actor,
): Promise<void> {
return Promise.resolve();
}

protected abstract deliverActivity(
ctx: InboxContext<RelayOptions>,
activity: RelayableActivity,
excludeBaseUris: URL[],
): Promise<void>;

async #handleFollow(
ctx: InboxContext<RelayOptions>,
follow: Follow,
): Promise<void> {
const follower = await validateFollowActivity(ctx, follow);
if (follower?.id == null || await this.shouldSkipFollow(ctx, follower)) {
return;
}

const approved = await this.options.subscriptionHandler(ctx, follower);
if (approved) {
await ctx.data.kv.set(
["follower", follower.id.href],
{
actor: await follower.toJsonLd(),
state: this.initialFollowerState,
},
);
}

await sendFollowResponse(ctx, follow, follower, approved);
if (approved) await this.afterFollowApproved(ctx, follower);
}

async #relayActivity(
ctx: InboxContext<RelayOptions>,
activity: RelayableActivity,
): Promise<void> {
const sender = await activity.getActor(ctx);
const excludeBaseUris = sender?.id == null ? [] : [new URL(sender.id)];
await this.deliverActivity(ctx, activity, excludeBaseUris);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

protected setupInboxListeners(): InboxListenerSetters<RelayOptions> {
if (this.federation == null) {
throw new Error("Federation must be initialized before inbox listeners");
}

const listeners = this.federation.setInboxListeners(
"/users/{identifier}/inbox",
"/inbox",
);
listeners
.on(Follow, async (ctx, follow) => await this.#handleFollow(ctx, follow))
.on(
Undo,
async (ctx, undo) => await handleUndoFollow(ctx, undo, this.logger),
)
.on(
Create,
async (ctx, create) => await this.#relayActivity(ctx, create),
)
.on(
Delete,
async (ctx, deleteActivity) =>
await this.#relayActivity(ctx, deleteActivity),
)
.on(
Move,
async (ctx, move) => await this.#relayActivity(ctx, move),
)
.on(
Update,
async (ctx, update) => await this.#relayActivity(ctx, update),
)
.on(
Announce,
async (ctx, announce) => await this.#relayActivity(ctx, announce),
);
return listeners;
}

async #getFederation(): Promise<Federation<RelayOptions>> {
if (this.federation == null) {
Expand Down
98 changes: 49 additions & 49 deletions packages/relay/src/litepub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,64 +583,64 @@ describe("LitePubRelay", () => {
strictEqual(followerData.state, "accepted");
});

test("handles Undo Follow activity", async () => {
const kv = new MemoryKvStore();
for (const state of ["pending", "accepted"] as const) {
test(`handles Undo Follow activity for ${state} follower`, async () => {
const kv = new MemoryKvStore();

// Pre-populate with an accepted follower
const followerId = "https://remote.example.com/users/alice";
const follower = new Person({
id: new URL(followerId),
preferredUsername: "alice",
inbox: new URL("https://remote.example.com/users/alice/inbox"),
});
const followerId = "https://remote.example.com/users/alice";
const follower = new Person({
id: new URL(followerId),
preferredUsername: "alice",
inbox: new URL("https://remote.example.com/users/alice/inbox"),
});

await kv.set(
["follower", followerId],
{ actor: await follower.toJsonLd(), state: "accepted" },
);
await kv.set(
["follower", followerId],
{ actor: await follower.toJsonLd(), state },
);

const relay = createRelay("litepub", {
kv,
origin: "https://relay.example.com",
documentLoaderFactory: () => mockDocumentLoader,
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
subscriptionHandler: () => Promise.resolve(true),
});
const relay = createRelay("litepub", {
kv,
origin: "https://relay.example.com",
documentLoaderFactory: () => mockDocumentLoader,
authenticatedDocumentLoaderFactory: () => mockDocumentLoader,
subscriptionHandler: () => Promise.resolve(true),
});

const originalFollow = new Follow({
id: new URL("https://remote.example.com/activities/follow/1"),
actor: new URL(followerId),
object: new URL("https://relay.example.com/users/relay"),
});
const originalFollow = new Follow({
id: new URL("https://remote.example.com/activities/follow/1"),
actor: new URL(followerId),
object: new URL("https://relay.example.com/users/relay"),
});

const undoActivity = new Undo({
id: new URL("https://remote.example.com/activities/undo/1"),
actor: new URL(followerId),
object: originalFollow,
});
const undoActivity = new Undo({
id: new URL("https://remote.example.com/activities/undo/1"),
actor: new URL(followerId),
object: originalFollow,
});

let request = new Request("https://relay.example.com/inbox", {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
},
body: JSON.stringify(
await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }),
),
});
let request = new Request("https://relay.example.com/inbox", {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
},
body: JSON.stringify(
await undoActivity.toJsonLd({ contextLoader: mockDocumentLoader }),
),
});

request = await signRequest(
request,
rsaKeyPair.privateKey,
rsaPublicKey.id,
);
request = await signRequest(
request,
rsaKeyPair.privateKey,
rsaPublicKey.id,
);

await relay.fetch(request);
await relay.fetch(request);

// Verify follower was removed
const followerData = await kv.get(["follower", followerId]);
strictEqual(followerData, undefined);
});
const followerData = await kv.get(["follower", followerId]);
strictEqual(followerData, undefined);
});
}

test("handles Create activity with Announce forwarding", async () => {
const kv = new MemoryKvStore();
Expand Down
Loading