Skip to content

Conversation

TheNoim
Copy link

@TheNoim TheNoim commented Feb 15, 2025

In this PR I try to add mastodon style relays conforming to FEP-ae0c.

Todos

  • Testing with AodeRelay
  • Testing with FediBuzz
    • At the moment I simply lookup @[email protected], but this doesn't work for FediBuzz. Sadly the spec isn't clear how to get the relay server actor from the inbox url. Most relay softwares simply have @[email protected], but not softwares with custom inboxes for different feeds. Do we actually need the relay server actor? I may only added this, to fill out the id property of Recipient when calling sendActivity. Maybe the inbox url is enough?
  • Find more relay softwares to test
  • relay.toot.io / relay.intahnet.co.uk not working. Implementation: https://github.com/yukimochi/Activity-Relay
    • Activity.to is required to be an array in Yukimochi
  • Consider disabled and enabled state for relays to temporary disable a relay. Mastodon has this feature.
  • How to fix the unwanted transformation from https://www.w3.org/ns/activitystreams#Public to as:public
  • Open issue of AodeRelay to support spec conform Unfollow https://git.asonix.dog/asonix/relay/issues/7
    • If you follow the spec, you can simply use the follow request id as object. But AodeRelay only accepts the full object.
  • oAuth2 screen shows relay actor
  • Make sure that we only forward activities ment to be forwarded to the relay
  • Add relays to docs
  • feat: add mastodon style relays #109 (comment)
  • Remove outbox messages after relay remove

* "id": "https://client.example/6ae15297",
* "type": "Follow",
* "actor": "https://client.example/actor",
* "object": "https://www.w3.org/ns/activitystreams#Public"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

He the relay should actually understand both, as they're semantically the same, I'd file a bug with the relay implementation

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about this.

The object of the Follow request MUST be the fully expanded URI of the Public pseudo-collection (https://www.w3.org/ns/activitystreams#Public).

As far as I understand, but you can correct me:

  • https://www.w3.org/ns/activitystreams#Public fully expanded
  • as:public not fully expanded

Imagine building a new relay software: I would look at the spec and see that the object property should be equal to https://www.w3.org/ns/activitystreams#Public. So I would just check for this. This is why I think using https://www.w3.org/ns/activitystreams#Public would make it more compatible with more relay softwares. But at this point I only tested one software.

Copy link
Member

@dahlia dahlia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your amazing work, @TheNoim! I'm really looking forward to shipping this feature!

I left several review comments. Could you address them?

@ThisIsMissEm
Copy link
Contributor

Would probably also make sense to abstract out functions for determining "should this be relayed" and "forward this to a relay"

@TheNoim
Copy link
Author

TheNoim commented Feb 16, 2025

@dahlia can you look into the verify key issue? You can use https://relay2.bergsman.de/actor for testing.

@dahlia
Copy link
Member

dahlia commented Feb 17, 2025

@dahlia can you look into the verify key issue? You can use https://relay2.bergsman.de/actor for testing.

It seems that Fedify fails to parse its public key is because it's encoded in PEM-PKCS#1 whereas Fedify expects PEM-SPKI, which is much more common in the fediverse (as far as I know). Hmm… should Fedify accept PEM-PKCS#1 as well? 🤔

@TheNoim
Copy link
Author

TheNoim commented Feb 17, 2025

@dahlia can you look into the verify key issue? You can use https://relay2.bergsman.de/actor for testing.

It seems that Fedify fails to parse its public key is because it's encoded in PEM-PKCS#1 whereas Fedify expects PEM-SPKI, which is much more common in the fediverse (as far as I know). Hmm… should Fedify accept PEM-PKCS#1 as well? 🤔

I think, yes 🙃 As far as I know, it is not against the spec. Even though I think this is not the only issue with the tested relay software. But this would bring it one step further. I naively tried to patch fedify yesterday, but what I did was 100% wrong 🥲

@dahlia
Copy link
Member

dahlia commented Feb 18, 2025

Now Fedify accepts PEM-PKCS#1-encoded RSA public keys (besides PEM-SPKI-encoded ones), and it will be shipped in Fedify v1.5.0, the next minor release. Until then, you could give it a try Fedify v1.5.0-dev.654+b5166915, which is an unstable release.

@TheNoim
Copy link
Author

TheNoim commented Feb 19, 2025

The next issue I found. This is the http signature:

keyId="https://relay2.bergsman.de/actor",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="gavImwimAZcFMcLiCPhmg87I5n5fsAnBDSUNSaiPhnSvev5A0bgxaXnMlu2PL3kB1Ru5eWxAPiFEMPmoYicJZkN9sCgVd/CSEVCP3dnbeHH5HsT+G43MhHZcd3Ofg4xtO0yziXNQkwVoBhsN63iXSfxxqwipIKg5yp/3o3YDWUWKf6mGd/ykO95Dq5lpgQmd5WpRJ3udYggjITBtJ5bNA+kGWgWJxHq2NPixiEhQcwld1J3o7BW/5UjOS64lbP1/p2I3LAOCnLe8sBD4zj72NPdLaXLnSG4x3pfloLo4xAV5xJl4eScbfrzSNeWp+D2YiTr9tc5l3VQl4kZKP93aHg=="

The keyId is set to https://relay2.bergsman.de/actor. Fedify fetches the key from https://relay2.bergsman.de/actor, but will not find the key there. The key there has the id https://relay2.bergsman.de/actor#main-key which is obviously != https://relay2.bergsman.de/actor. See https://github.com/fedify-dev/fedify/blob/4eb092ee66e5b66a5760b8178b3e1272bb4c7abf/src/sig/key.ts#L330

I am not sure if this is also an incorrect behavior of fedify. https://swicg.github.io/activitypub-http-signature/#how-to-obtain-a-signature-s-public-key

If it has a fragment, discard it.

Maybe the spec is meant this way: Fetch the key from keyId. If keyId specifies a specific key, use it. Otherwise, use first.

Damn, activity pub is harder than I could have ever imagined 😁 Respect for implementing fedify.

@dahlia
Copy link
Member

dahlia commented Feb 20, 2025

I am not sure if this is also an incorrect behavior of fedify. https://swicg.github.io/activitypub-http-signature/#how-to-obtain-a-signature-s-public-key

If it has a fragment, discard it.

Maybe the spec is meant this way: Fetch the key from keyId. If keyId specifies a specific key, use it. Otherwise, use first.

Whether it is Fedify's fault or not, Fedify should behave as liberal as possible in accordance with the robustness principlebe conservative in what you do, be liberal in what you accept from others. So I just filed an issue: fedify-dev/fedify#211.

@dahlia
Copy link
Member

dahlia commented Feb 20, 2025

Fedify now does the best to find the appropriate public key. If there is no fragment in keyId and the actor has only one public key, it chooses that key. It will be shipped in Fedify 1.5.0, the next minor release, but you can try it with Fedify 1.5.0-dev.672+6a6ed659, an unstable release.

@TheNoim
Copy link
Author

TheNoim commented May 3, 2025

I am not sure how to go forward with this PR. The most important parts are done. I may consider change it state from draft to ready. Everything else can be changed/implemented later. Otherwise this PR might stay in a infinite draft limbo. Any option?

@dahlia
Copy link
Member

dahlia commented May 3, 2025

If you change it to ready, I'll review it!

Copy link
Member

@dahlia dahlia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to test this on my local setup. Could you let me know how to test it? Sorry, I'm not familiar with ActivityPub relays.

Comment on lines +74 to +88
export class RelayUndo extends Undo {
async toJsonLd(options?: {
format?: "compact" | "expand";
contextLoader?: DocumentLoader;
context?:
| string
| Record<string, string>
| (string | Record<string, string>)[];
}): Promise<unknown> {
await this.getObject();
const json = (await super.toJsonLd(options)) as { object: unknown };
json.object = await (await this.getObject())?.toJsonLd();
return json;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having this subclass, how about hydrating the property before calling toJsonLd()?

@@ -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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes you've compare account_owners.id with HOLLO_RELAY_ACTOR_ID, and sometimes account_owners.handle with HOLLO_RELAY_ACTOR_USERNAME. Is there any difference between both ways?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check should be using the ID here, not the username.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may also affect the setup page, which checks the number of actors on the server.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, it may affect the NodeInfo stats as well:

const [{ total }] = await db.select({ total: count() }).from(accountOwners);
const [{ activeMonth }] = await db
.select({ activeMonth: countDistinct(accountOwners.id) })
.from(accountOwners)
.rightJoin(posts, eq(accountOwners.id, posts.accountId))
.where(gt(posts.updated, sql`CURRENT_TIMESTAMP - INTERVAL '1 month'`));
const [{ activeHalfyear }] = await db
.select({ activeHalfyear: countDistinct(accountOwners.id) })
.from(accountOwners)
.rightJoin(posts, eq(accountOwners.id, posts.accountId))
.where(gt(posts.updated, sql`CURRENT_TIMESTAMP - INTERVAL '6 months'`));

@@ -119,6 +121,28 @@ accounts.post("/", async (c) => {
const bioResult = await formatText(db, bio ?? "", fedCtx);
const nameEmojis = await extractCustomEmojis(db, name);
const emojis = { ...nameEmojis, ...bioResult.emojis };
const handle = `@${username}@${fedCtx.host}`;
if (handle === `@${HOLLO_OFFICIAL_ACCOUNT}@${fedCtx.host}`) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's a bug?

Suggested change
if (handle === `@${HOLLO_OFFICIAL_ACCOUNT}@${fedCtx.host}`) {
if (username === HOLLO_RELAY_ACTOR_USERNAME) {

@TheNoim
Copy link
Author

TheNoim commented May 3, 2025

I'd like to test this on my local setup. Could you let me know how to test it? Sorry, I'm not familiar with ActivityPub relays.

I started two test relay servers on my server.

And used a test mastodon instance. And sometimes I used some public relay that don't require approve.

@TheNoim TheNoim marked this pull request as ready for review May 5, 2025 16:54
@@ -55,6 +57,7 @@ app.get(
return c.json({ error: "invalid_redirect_uri" }, 400);
}
const accountOwners = await db.query.accountOwners.findMany({
where: not(eq(accountOwnersTable.id, HOLLO_RELAY_ACTOR_ID)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll definitely need a test case for this, but probably after #154 lands.

Comment on lines +1102 to +1104
relayServerActorId: uuid("relay_server_actor_id")
.$type<Uuid>()
.references(() => accounts.id, { onDelete: "cascade" }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be not-null?

.$type<Uuid>()
.references(() => accounts.id, { onDelete: "cascade" }),
state: relayStateEnum("state").notNull().default("idle"),
followRequestId: text("follow_request_id").notNull().unique(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this reference a sent follow request? Though maybe we're not storing in the database the follow requests we've sent? cc @dahlia

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we could actually store the follow request, by doing an insert to follows for the relay actor, and having approved be null?

From: https://github.com/dahlia/hollo/blob/9d5aa116141e43ed824ea8b44ba621f5978143c8/src/api/v1/follow_requests.ts

const followers = await db.query.follows.findMany({
    where: and(eq(follows.followingId, owner.id), isNull(follows.approved)),
    with: { follower: { with: { owner: true, successor: true } } },
  });

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, and as the primary key of the follows table is iri, the column should be renamed to followRequestIri:

Suggested change
followRequestId: text("follow_request_id").notNull().unique(),
followRequestIri: text("follow_request_iri")
.notNull()
.unique()
.references(() => follows.iri, { onDelete: "cascade" }),

import { exportJwk, generateCryptoKeyPair, isActor } from "@fedify/fedify";
import { Temporal } from "@js-temporal/polyfill";
import { count, eq, sql } from "drizzle-orm";
import { interval, jsonb, pgTable, timestamp, uuid } from "drizzle-orm/pg-core";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've a few unused variables here.

@@ -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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check should be using the ID here, not the username.

@@ -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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may also affect the setup page, which checks the number of actors on the server.

@@ -160,4 +243,202 @@ data.post("/refresh", async (c) => {
return c.redirect("/federation?error=refresh");
});

data.post("/relay", async (c) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be inclined to move this to a dedicated relays page, as this file already is quite large.

Comment on lines +248 to +250
const form = await c.req.formData();

const inboxUrl = form.get("inbox_url");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing form validation.

Comment on lines +266 to +272
const existingRelay = await tx.query.relays.findFirst({
where: eq(relays.inboxUrl, inboxUrl),
});

if (existingRelay) {
throw tx.rollback();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could do this first, and then not need to bail on the insert.

Comment on lines +323 to +327
// Create follow request for this relay
const followRequestId = new URL(
`#relay-follows/${crypto.randomUUID()}`,
account[0].iri,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this actually be a real Object ID, and not using the hash fragment?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could use followAccount instead? Since that'll send the follow request and store it in the database.

@TheNoim
Copy link
Author

TheNoim commented May 27, 2025

Thanks for the review! It will take some time to address all, because time is rare at the moment for me.

@ThisIsMissEm
Copy link
Contributor

I'm going to mark this as a draft due to pending changes and inactivity

@ThisIsMissEm ThisIsMissEm marked this pull request as draft August 10, 2025 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants