From 4c82660015e2b556196a0a39c884d99118a5c733 Mon Sep 17 00:00:00 2001 From: Nils Bergmann Date: Sat, 15 Feb 2025 22:25:52 +0100 Subject: [PATCH 01/12] feat: add mastodon style relays --- drizzle/meta/0062_snapshot.json | 2 +- drizzle/meta/_journal.json | 2 +- src/api/v1/statuses.ts | 251 ++++++++++++++++++++++---- src/entities/relay.ts | 70 ++++++++ src/federation/inbox.ts | 25 +++ src/pages/accounts.tsx | 4 +- src/pages/federation.tsx | 303 +++++++++++++++++++++++++++++++- src/schema.ts | 35 ++++ 8 files changed, 648 insertions(+), 44 deletions(-) create mode 100644 src/entities/relay.ts diff --git a/drizzle/meta/0062_snapshot.json b/drizzle/meta/0062_snapshot.json index 666cbd53..e8cfd128 100644 --- a/drizzle/meta/0062_snapshot.json +++ b/drizzle/meta/0062_snapshot.json @@ -2833,4 +2833,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e4e71531..2d6a928b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -451,4 +451,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 689bd7f1..9e2f8bbc 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -67,6 +67,7 @@ import { polls, posts, reactions, + relays, } from "../../schema"; import { formatPostContent } from "../../text"; import { type Uuid, isUuid, uuid, uuidv7 } from "../../uuid"; @@ -280,6 +281,26 @@ app.post( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + { handle }, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } } return c.json(serializePost(post, owner, c.req.url)); }, @@ -371,6 +392,26 @@ app.put( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); + if (post?.visibility !== "direct" && !owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -433,6 +474,24 @@ app.delete( excludeBaseUris: [new URL(c.req.url)], }, ); + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); } return c.json({ ...serializePost(post, owner, c.req.url), @@ -797,15 +856,36 @@ app.post( where: eq(posts.id, id), with: getPostRelations(owner.id), }); + const activity = toAnnounce(post!, fedCtx); await fedCtx.sendActivity( { username: owner.handle }, "followers", - toAnnounce(post!, fedCtx), + activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }, ); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -852,18 +932,39 @@ app.post( .where(eq(posts.id, originalPostId)); const fedCtx = federation.createContext(c.req.raw, undefined); for (const post of postList) { + const activity = new Undo({ + actor: new URL(owner.account.iri), + object: toAnnounce(post, fedCtx), + }); await fedCtx.sendActivity( { username: owner.handle }, "followers", - new Undo({ - actor: new URL(owner.account.iri), - object: toAnnounce(post, fedCtx), - }), + activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }, ); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } } const originalPost = await db.query.posts.findFirst({ where: eq(posts.id, originalPostId), @@ -1014,23 +1115,39 @@ app.post( } satisfies NewPinnedPost) .returning(); const fedCtx = federation.createContext(c.req.raw, undefined); - await fedCtx.sendActivity( - owner, - "followers", - new Add({ - id: new URL( - `#add/${result[0].index}`, - fedCtx.getFeaturedUri(owner.handle), - ), - actor: new URL(owner.account.iri), - object: new URL(post.iri), - target: fedCtx.getFeaturedUri(owner.handle), - }), - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + const activity = new Add({ + id: new URL( + `#add/${result[0].index}`, + fedCtx.getFeaturedUri(owner.handle), + ), + actor: new URL(owner.account.iri), + object: new URL(post.iri), + target: fedCtx.getFeaturedUri(owner.handle), + }); + await fedCtx.sendActivity(owner, "followers", activity, { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } const resultPost = await db.query.posts.findFirst({ where: eq(posts.id, postId), with: getPostRelations(owner.id), @@ -1070,23 +1187,39 @@ app.post( with: getPostRelations(owner.id), }); const fedCtx = federation.createContext(c.req.raw, undefined); - await fedCtx.sendActivity( - owner, - "followers", - new Remove({ - id: new URL( - `#remove/${result[0].index}`, - fedCtx.getFeaturedUri(owner.handle), - ), - actor: new URL(owner.account.iri), - object: new URL(post!.iri), - target: fedCtx.getFeaturedUri(owner.handle), - }), - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + const activity = new Remove({ + id: new URL( + `#remove/${result[0].index}`, + fedCtx.getFeaturedUri(owner.handle), + ), + actor: new URL(owner.account.iri), + object: new URL(post!.iri), + target: fedCtx.getFeaturedUri(owner.handle), + }); + await fedCtx.sendActivity(owner, "followers", activity, { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post!, owner, c.req.url)); }, ); @@ -1208,6 +1341,26 @@ async function addEmojiReaction( activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)] }, ); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post, owner, c.req.url)); } @@ -1301,6 +1454,26 @@ async function removeEmojiReaction( activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)] }, ); + if (!owner.account.protected) { + const acceptedRelays = await db.query.relays.findMany({ + where: eq(relays.state, "accepted"), + with: { + relayServerActor: true, + }, + }); + await fedCtx.sendActivity( + owner, + acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + })), + activity, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + } return c.json(serializePost(post, owner, c.req.url)); } diff --git a/src/entities/relay.ts b/src/entities/relay.ts new file mode 100644 index 00000000..53dca985 --- /dev/null +++ b/src/entities/relay.ts @@ -0,0 +1,70 @@ +import { type DocumentLoader, Follow, Undo } from "@fedify/fedify"; + +// Use fixed username and id for the relay client actor +// Why use fixed username and id? +// Because the relay client actor is a special actor that is not supposed to be interacted with by users. +// Also, if we delete the a relay and send the Undo activity, the relay client needs to be online. +// For example, AodeRelay first fetches the actor from our server again, and if we already deleted the actor, it will fail +// and the realy keeps our server in the database. +// And because we won't receive an activity to check whether the Undo was successful, we can't delete the relay client actor safely. +export const HOLLO_RELAY_ACTOR_ID = "8a683714-6fa2-4e53-9f05-b4acbcda4db7"; +export const HOLLO_RELAY_ACTOR_USERNAME = "hollo-relay-follower"; + +/** + * Workaround fedify jsonld serialization for relay follow. + * + * Without this, the jsonld serialization would be: + * ```json + * { + * "@context": "https://www.w3.org/ns/activitystreams", + * "id": "https://client.example/6ae15297", + * "type": "Follow", + * "actor": "https://client.example/actor", + * "object": "as:public" + * } + * ``` + * instead of + * ```json + * { + * "@context": "https://www.w3.org/ns/activitystreams", + * "id": "https://client.example/6ae15297", + * "type": "Follow", + * "actor": "https://client.example/actor", + * "object": "https://www.w3.org/ns/activitystreams#Public" + * } + * ``` + */ +export class RelayFollow extends Follow { + async toJsonLd(options?: { + format?: "compact" | "expand"; + contextLoader?: DocumentLoader; + context?: + | string + | Record + | (string | Record)[]; + }): Promise { + const json = (await super.toJsonLd(options)) as { object: string }; + json.object = "https://www.w3.org/ns/activitystreams#Public"; + return json; + } +} + +/** + * Similar workaround as RelayFollow. However, normaly we should not need to do this, because the spec is very clear that we are allowed to set object to just the id. + * + * But AodeRelay does not support this, so we need to work around this and send the full Follow object. + */ +export class RelayUndo extends Undo { + async toJsonLd(options?: { + format?: "compact" | "expand"; + contextLoader?: DocumentLoader; + context?: + | string + | Record + | (string | Record)[]; + }): Promise { + const json = (await super.toJsonLd(options)) as { object: unknown }; + json.object = await (await this.getObject())?.toJsonLd(); + return json; + } +} diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 429f37c7..3cfde8ba 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -38,6 +38,7 @@ import { pollOptions, posts, reactions, + relays, } from "../schema"; import { isUuid } from "../uuid"; import { @@ -173,6 +174,18 @@ export async function onFollowAccepted( inboxLogger.debug("Invalid actor: {actor}", { actor }); return; } + + const isRelayAccept = accept.objectId?.hash.includes("#relay-follows/"); + + if (isRelayAccept) { + // Update relay state to accepted if the follow is a relay + await db + .update(relays) + .set({ state: "accepted" }) + .where(eq(relays.followRequestId, accept.objectId!.href)); + return; + } + const account = await persistAccount(db, actor, ctx.origin, ctx); if (account == null) return; if (accept.objectId != null) { @@ -222,6 +235,18 @@ export async function onFollowRejected( inboxLogger.debug("Invalid actor: {actor}", { actor }); return; } + + const isRelayAccept = reject.objectId?.hash.includes("#relay-follows/"); + + if (isRelayAccept) { + // Update relay state to accepted if the follow is a relay + await db + .update(relays) + .set({ state: "accepted" }) + .where(eq(relays.followRequestId, reject.objectId!.href)); + return; + } + const account = await persistAccount(db, actor, ctx.origin, ctx); if (account == null) return; if (reject.objectId != null) { diff --git a/src/pages/accounts.tsx b/src/pages/accounts.tsx index 8a338453..a7eefc4d 100644 --- a/src/pages/accounts.tsx +++ b/src/pages/accounts.tsx @@ -14,7 +14,7 @@ import { import { getLogger } from "@logtape/logtape"; import { PromisePool } from "@supercharge/promise-pool"; import { createObjectCsvStringifier } from "csv-writer-portable"; -import { and, count, eq, inArray } from "drizzle-orm"; +import { and, count, eq, inArray, ne } from "drizzle-orm"; import { uniq } from "es-toolkit"; import { Hono } from "hono"; import { streamText } from "hono/streaming"; @@ -27,6 +27,7 @@ import { type NewAccountPageProps, } from "../components/NewAccountPage.tsx"; import db from "../db.ts"; +import { HOLLO_RELAY_ACTOR_USERNAME } from "../entities/relay.ts"; import federation from "../federation"; import { REMOTE_ACTOR_FETCH_POSTS, @@ -67,6 +68,7 @@ accounts.use(loginRequired); accounts.get("/", async (c) => { const owners = await db.query.accountOwners.findMany({ + where: ne(accountOwners.handle, HOLLO_RELAY_ACTOR_USERNAME), with: { account: true }, }); return c.html(); diff --git a/src/pages/federation.tsx b/src/pages/federation.tsx index 065fe677..729b9afa 100644 --- a/src/pages/federation.tsx +++ b/src/pages/federation.tsx @@ -1,12 +1,22 @@ -import { isActor } from "@fedify/fedify"; -import { count, sql } from "drizzle-orm"; +import { exportJwk, generateCryptoKeyPair, isActor } from "@fedify/fedify"; +import { Temporal } from "@js-temporal/polyfill"; +import { count, eq, sql } from "drizzle-orm"; import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; import { DashboardLayout } from "../components/DashboardLayout"; import db from "../db"; +import { + HOLLO_RELAY_ACTOR_ID, + HOLLO_RELAY_ACTOR_USERNAME, + RelayFollow, + RelayUndo, +} from "../entities/relay"; import federation from "../federation"; import { persistAccount } from "../federation/account"; import { isPost, persistPost } from "../federation/post"; import { loginRequired } from "../login"; +import { accountOwners, accounts, instances, relays } from "../schema"; +import { isUuid } from "../uuid"; const data = new Hono(); @@ -30,6 +40,12 @@ data.get("/", async (c) => { queueMessages = []; } + const relays = await db.query.relays.findMany({ + with: { + relayServerActor: true, + }, + }); + return c.html(
@@ -87,6 +103,73 @@ data.get("/", async (c) => { +
+
+
+

Relays

+

Manage relays.

+
+
+
+
+ + +
+ {error === "invalid_relay" ? ( + The given relay URL is invalid. Please try again. + ) : error === "relay_alread_exists" ? ( + The given relay URL already exists. Please try again. + ) : ( + + A relay conforming to{" "} + + FEP-ae0c + {" "} + is supported. + + )} +
+ + + + + + + + + + {relays.map((relay) => ( + + + + + + ))} + +
InboxStatusActions
{relay.relayServerActor.inboxUrl}{relay.state} +
+ +
+
+
+
@@ -160,4 +243,220 @@ data.post("/refresh", async (c) => { return c.redirect("/federation?error=refresh"); }); +data.post("/relay", async (c) => { + const fedCtx = federation.createContext(c.req.raw, undefined); + const form = await c.req.formData(); + + const inboxUrl = form.get("inbox_url"); + + if (typeof inboxUrl !== "string") { + return c.redirect("/federation?error=invalid_relay"); + } + + const domain = new URL(inboxUrl).hostname; + + // The spec doesn't specify the name of the relay server actor, but I looked at multiple implementations and they all use `@relay@domain`. + const relayActor = await fedCtx.lookupObject(`@relay@${domain}`); + + if (!isActor(relayActor)) { + return c.redirect("/federation?error=invalid_relay"); + } + + const [loadedRelay] = await db.transaction(async (tx) => { + await tx + .insert(instances) + .values({ + host: fedCtx.host, + software: "hollo", + softwareVersion: null, + }) + .onConflictDoNothing(); + + const persistedServerRelayActor = await persistAccount( + tx, + relayActor, + c.req.url, + {}, + ); + + if (!persistedServerRelayActor) { + throw tx.rollback(); + } + + const existingRelay = await tx.query.relays.findFirst({ + where: eq(relays.relayServerActorId, persistedServerRelayActor.id), + }); + + if (existingRelay) { + throw tx.rollback(); + } + + // Create client actor for the relay if it doesn't exist + const account = await tx + .insert(accounts) + .values({ + id: HOLLO_RELAY_ACTOR_ID, + iri: fedCtx.getActorUri(HOLLO_RELAY_ACTOR_USERNAME).href, + instanceHost: fedCtx.host, + type: "Application", + name: HOLLO_RELAY_ACTOR_USERNAME, + handle: `@${HOLLO_RELAY_ACTOR_USERNAME}@${fedCtx.host}`, + url: fedCtx.getActorUri(HOLLO_RELAY_ACTOR_USERNAME).href, + protected: false, + inboxUrl: fedCtx.getInboxUri(HOLLO_RELAY_ACTOR_USERNAME).href, + followersUrl: fedCtx.getFollowersUri(HOLLO_RELAY_ACTOR_USERNAME).href, + sharedInboxUrl: fedCtx.getInboxUri().href, + featuredUrl: fedCtx.getFeaturedUri(HOLLO_RELAY_ACTOR_USERNAME).href, + published: new Date(), + }) + .onConflictDoUpdate({ + target: accounts.id, + set: { + // Set anything to still return a value if the account already exists + protected: false, + }, + }) + .returning(); + + const rsaKeyPair = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519KeyPair = await generateCryptoKeyPair("Ed25519"); + + await tx + .insert(accountOwners) + .values({ + id: HOLLO_RELAY_ACTOR_ID, + handle: HOLLO_RELAY_ACTOR_USERNAME, + rsaPrivateKeyJwk: await exportJwk(rsaKeyPair.privateKey), + rsaPublicKeyJwk: await exportJwk(rsaKeyPair.publicKey), + ed25519PrivateKeyJwk: await exportJwk(ed25519KeyPair.privateKey), + ed25519PublicKeyJwk: await exportJwk(ed25519KeyPair.publicKey), + bio: "", + language: "en", + // TODO: Which visibility should be set? + visibility: "public", + discoverable: false, + }) + .onConflictDoNothing() + .returning(); + + // Create follow request for this relay + const followRequestId = new URL( + `#relay-follows/${crypto.randomUUID()}`, + account[0].iri, + ); + + // Create relay + const [relay] = await tx + .insert(relays) + .values({ + state: "idle", + relayClientActorId: HOLLO_RELAY_ACTOR_ID, + relayServerActorId: persistedServerRelayActor.id, + followRequestId: followRequestId.href, + }) + .returning(); + + const loadedRelay = await tx.query.relays.findFirst({ + where: eq(relays.relayServerActorId, relay.relayServerActorId), + with: { + relayClientActor: { + with: { + owner: true, + }, + }, + relayServerActor: { + with: { + owner: true, + }, + }, + }, + }); + + if (!loadedRelay) { + throw tx.rollback(); + } + + return [loadedRelay]; + }); + + await fedCtx.sendActivity( + { username: loadedRelay.relayClientActor.owner.handle }, + [ + { + id: new URL(loadedRelay.relayServerActor.iri), + inboxId: new URL(loadedRelay.relayServerActor.inboxUrl), + }, + ], + new RelayFollow({ + id: new URL(loadedRelay.followRequestId), + actor: new URL(loadedRelay.relayClientActor.iri), + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }), + { + preferSharedInbox: true, + }, + ); + + await db + .update(relays) + .set({ + state: "pending", + }) + .where(eq(relays.relayServerActorId, loadedRelay.relayServerActorId)); + + return c.redirect("/federation?done=relay:add"); +}); + +data.post("/relay/:serverActorId/delete", async (c) => { + const fedCtx = federation.createContext(c.req.raw, undefined); + + const serverActorId = c.req.param("serverActorId"); + if (!isUuid(serverActorId)) return c.notFound(); + + await db.transaction(async (tx) => { + const relay = await tx.query.relays.findFirst({ + where: eq(relays.relayServerActorId, serverActorId), + with: { + relayServerActor: { + with: { + owner: true, + }, + }, + relayClientActor: { + with: { + owner: true, + }, + }, + }, + }); + + if (!relay) { + throw new HTTPException(404, { res: await c.notFound() }); + } + + await fedCtx.sendActivity( + { username: relay.relayClientActor.owner.handle }, + [ + { + id: new URL(relay.relayServerActor.iri), + inboxId: new URL(relay.relayServerActor.inboxUrl), + }, + ], + new RelayUndo({ + actor: new URL(relay.relayClientActor.iri), + object: new RelayFollow({ + id: new URL(relay.followRequestId), + actor: new URL(relay.relayClientActor.iri), + object: new URL("https://www.w3.org/ns/activitystreams#Public"), + }), + published: Temporal.Now.instant(), + }), + ); + + await tx.delete(relays).where(eq(relays.relayServerActorId, serverActorId)); + }); + + return c.redirect("/federation?done=relay:removed"); +}); + export default data; diff --git a/src/schema.ts b/src/schema.ts index 40a90661..68e1c69c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1090,3 +1090,38 @@ export const listPostRelations = relations(listPosts, ({ one }) => ({ references: [posts.id], }), })); + +export const relayStateEnum = pgEnum("relay_state", [ + "idle", + "pending", + "accepted", + "rejected", +]); + +export const relays = pgTable("relays", { + relayServerActorId: uuid("relay_server_actor_id") + .primaryKey() + .$type() + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + state: relayStateEnum("state").notNull().default("idle"), + followRequestId: text("follow_request_id").notNull().unique(), + relayClientActorId: uuid("relay_client_actor_id") + .$type() + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), +}); + +export type ListRelays = typeof relays.$inferSelect; +export type NewRelays = typeof relays.$inferInsert; + +export const relayRelations = relations(relays, ({ one }) => ({ + relayServerActor: one(accounts, { + fields: [relays.relayServerActorId], + references: [accounts.id], + }), + relayClientActor: one(accounts, { + fields: [relays.relayClientActorId], + references: [accounts.id], + }), +})); From d763e0c3af6adad3cbac1034bd02c0b4fd9c4193 Mon Sep 17 00:00:00 2001 From: Nils Bergmann Date: Sun, 16 Feb 2025 15:18:39 +0100 Subject: [PATCH 02/12] refactor: some improvements --- src/api/v1/statuses.ts | 192 +++++---------------------------------- src/entities/relay.ts | 77 +++++++++++++++- src/federation/inbox.ts | 75 ++++++++++----- src/federation/index.ts | 22 ++++- src/pages/federation.tsx | 52 +++-------- src/schema.ts | 3 +- 6 files changed, 184 insertions(+), 237 deletions(-) diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 9e2f8bbc..190bc399 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -28,6 +28,7 @@ import { serializeAccount, serializeAccountOwner, } from "../../entities/account"; +import { forwardActivityToRelays } from "../../entities/relay"; import { getPostRelations, serializePost } from "../../entities/status"; import federation from "../../federation"; import { updateAccountStats } from "../../federation/account"; @@ -281,26 +282,9 @@ app.post( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - { handle }, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); - } + } + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, { handle }, activity); } return c.json(serializePost(post, owner, c.req.url)); }, @@ -392,25 +376,8 @@ app.put( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); - if (post?.visibility !== "direct" && !owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post!.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json(serializePost(post!, owner, c.req.url)); }, @@ -474,24 +441,9 @@ app.delete( excludeBaseUris: [new URL(c.req.url)], }, ); - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + } + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json({ ...serializePost(post, owner, c.req.url), @@ -866,25 +818,8 @@ app.post( excludeBaseUris: [new URL(c.req.url)], }, ); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post!.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json(serializePost(post!, owner, c.req.url)); }, @@ -945,25 +880,8 @@ app.post( excludeBaseUris: [new URL(c.req.url)], }, ); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } } const originalPost = await db.query.posts.findFirst({ @@ -1128,25 +1046,8 @@ app.post( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } const resultPost = await db.query.posts.findFirst({ where: eq(posts.id, postId), @@ -1200,25 +1101,8 @@ app.post( preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post!.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json(serializePost(post!, owner, c.req.url)); }, @@ -1341,25 +1225,8 @@ async function addEmojiReaction( activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)] }, ); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json(serializePost(post, owner, c.req.url)); } @@ -1454,25 +1321,8 @@ async function removeEmojiReaction( activity, { preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)] }, ); - if (!owner.account.protected) { - const acceptedRelays = await db.query.relays.findMany({ - where: eq(relays.state, "accepted"), - with: { - relayServerActor: true, - }, - }); - await fedCtx.sendActivity( - owner, - acceptedRelays.map((relay) => ({ - id: new URL(relay.relayServerActor.iri), - inboxId: new URL(relay.relayServerActor.inboxUrl), - })), - activity, - { - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }, - ); + if (owner.discoverable && post.visibility === "public") { + await forwardActivityToRelays(db, fedCtx, owner, activity); } return c.json(serializePost(post, owner, c.req.url)); } diff --git a/src/entities/relay.ts b/src/entities/relay.ts index 53dca985..a34ca611 100644 --- a/src/entities/relay.ts +++ b/src/entities/relay.ts @@ -1,4 +1,22 @@ -import { type DocumentLoader, Follow, Undo } from "@fedify/fedify"; +import { + type Activity, + type Context, + type DocumentLoader, + Follow, + type Recipient, + type SenderKeyPair, + Undo, +} from "@fedify/fedify"; +import { + type ExtractTablesWithRelations, + and, + eq, + isNotNull, + not, +} from "drizzle-orm"; +import type { PgDatabase } from "drizzle-orm/pg-core"; +import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; +import * as schema from "../schema"; // Use fixed username and id for the relay client actor // Why use fixed username and id? @@ -8,7 +26,7 @@ import { type DocumentLoader, Follow, Undo } from "@fedify/fedify"; // and the realy keeps our server in the database. // And because we won't receive an activity to check whether the Undo was successful, we can't delete the relay client actor safely. export const HOLLO_RELAY_ACTOR_ID = "8a683714-6fa2-4e53-9f05-b4acbcda4db7"; -export const HOLLO_RELAY_ACTOR_USERNAME = "hollo-relay-follower"; +export const HOLLO_RELAY_ACTOR_USERNAME = "$hollo~relay~follower$"; /** * Workaround fedify jsonld serialization for relay follow. @@ -63,8 +81,63 @@ export class RelayUndo extends Undo { | Record | (string | Record)[]; }): Promise { + await this.getObject(); const json = (await super.toJsonLd(options)) as { object: unknown }; json.object = await (await this.getObject())?.toJsonLd(); return json; } } + +export async function getRelayRecipients( + db: PgDatabase< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, +): Promise { + const acceptedRelays = await db.query.relays.findMany({ + where: and( + eq(schema.relays.state, "accepted"), + isNotNull(schema.relays.relayServerActorId), + ), + with: { + relayServerActor: true, + }, + }); + return acceptedRelays.map((relay) => ({ + id: new URL(relay.relayServerActor!.iri), + inboxId: new URL(relay.relayServerActor!.inboxUrl), + })); +} + +/** + * Forward a given activity to all relays currently active + * @param db Database instance or transaction + * @param ctx fedify context + * @param sender Sender of the activity + * @param activity Activity to forward to relays + */ +export async function forwardActivityToRelays( + db: PgDatabase< + PostgresJsQueryResultHKT, + typeof schema, + ExtractTablesWithRelations + >, + ctx: Context, + sender: + | SenderKeyPair + | SenderKeyPair[] + | { + identifier: string; + } + | { + username: string; + } + | { + handle: string; + }, + activity: Activity, +) { + const recipients = await getRelayRecipients(db); + await ctx.sendActivity(sender, recipients, activity); +} diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 3cfde8ba..6aaacdb2 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -175,17 +175,6 @@ export async function onFollowAccepted( return; } - const isRelayAccept = accept.objectId?.hash.includes("#relay-follows/"); - - if (isRelayAccept) { - // Update relay state to accepted if the follow is a relay - await db - .update(relays) - .set({ state: "accepted" }) - .where(eq(relays.followRequestId, accept.objectId!.href)); - return; - } - const account = await persistAccount(db, actor, ctx.origin, ctx); if (account == null) return; if (accept.objectId != null) { @@ -236,17 +225,6 @@ export async function onFollowRejected( return; } - const isRelayAccept = reject.objectId?.hash.includes("#relay-follows/"); - - if (isRelayAccept) { - // Update relay state to accepted if the follow is a relay - await db - .update(relays) - .set({ state: "accepted" }) - .where(eq(relays.followRequestId, reject.objectId!.href)); - return; - } - const account = await persistAccount(db, actor, ctx.origin, ctx); if (account == null) return; if (reject.objectId != null) { @@ -866,3 +844,56 @@ export async function onAccountMoved( ); } } + +export async function onRelayFollowAccepted( + ctx: InboxContext, + accept: Accept, +): Promise { + const actor = await accept.getActor(); + if (!isActor(actor) || actor.id == null) { + inboxLogger.debug("Invalid actor: {actor}", { actor }); + return; + } + + const relayServerActor = await persistAccount(db, actor, ctx.origin, ctx); + + inboxLogger.debug("Relay follow accepted: {actor} {relayServerActor}", { + actor, + relayServerActor, + }); + + if (!relayServerActor) { + inboxLogger.debug("Actor not persited: {actor}", { actor }); + return; + } + + // Update relay state to accepted + await db + .update(relays) + .set({ state: "accepted", relayServerActorId: relayServerActor.id }) + .where(eq(relays.followRequestId, accept.objectId!.href)); +} + +export async function onRelayFollowRejected( + ctx: InboxContext, + reject: Reject, +): Promise { + const actor = await reject.getActor(); + if (!isActor(actor) || actor.id == null) { + inboxLogger.debug("Invalid actor: {actor}", { actor }); + return; + } + + const relayServerActor = await persistAccount(db, actor, ctx.origin, ctx); + + if (!relayServerActor) { + inboxLogger.debug("Actor not persited: {actor}", { actor }); + return; + } + + // Update relay state to rejected + await db + .update(relays) + .set({ state: "rejected", relayServerActorId: relayServerActor.id }) + .where(eq(relays.followRequestId, reject.objectId!.href)); +} diff --git a/src/federation/index.ts b/src/federation/index.ts index 766ce646..ed707cd1 100644 --- a/src/federation/index.ts +++ b/src/federation/index.ts @@ -40,6 +40,8 @@ import { onPostUnpinned, onPostUnshared, onPostUpdated, + onRelayFollowAccepted, + onRelayFollowRejected, onUnblocked, onUnfollowed, onUnliked, @@ -58,8 +60,24 @@ federation return anyOwner ?? null; }) .on(Follow, onFollowed) - .on(Accept, onFollowAccepted) - .on(Reject, onFollowRejected) + .on(Accept, async (ctx, accept) => { + const isRelayAccept = accept.objectId?.hash.startsWith("#relay-follows/"); + + if (isRelayAccept) { + return await onRelayFollowAccepted(ctx, accept); + } + + await onFollowAccepted(ctx, accept); + }) + .on(Reject, async (ctx, reject) => { + const isRelayReject = reject.objectId?.hash.startsWith("#relay-follows/"); + + if (isRelayReject) { + return await onRelayFollowRejected(ctx, reject); + } + + await onFollowRejected(ctx, reject); + }) .on(Create, async (ctx, create) => { const object = await create.getObject(); if ( diff --git a/src/pages/federation.tsx b/src/pages/federation.tsx index 729b9afa..b7c76bde 100644 --- a/src/pages/federation.tsx +++ b/src/pages/federation.tsx @@ -152,12 +152,12 @@ data.get("/", async (c) => { {relays.map((relay) => ( - {relay.relayServerActor.inboxUrl} + {relay.relayServerActor?.inboxUrl ?? relay.inboxUrl} {relay.state}