refactor(federation): 🔥 Remove confusing User federation methods

This commit is contained in:
Jesse Wierzbinski 2025-04-08 17:27:08 +02:00
parent 9ff9b90f6b
commit 54b2dfb78d
No known key found for this signature in database
12 changed files with 72 additions and 94 deletions

View file

@ -3,6 +3,7 @@ import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod"; import { resolver } from "hono-openapi/zod";
import { User } from "~/classes/database/user";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
@ -48,7 +49,7 @@ export default apiRoute((app) =>
throw new ApiError(400, "Cannot refetch a local user"); throw new ApiError(400, "Cannot refetch a local user");
} }
const newUser = await otherUser.updateFromRemote(); const newUser = await User.fromVersia(otherUser.uri);
return context.json(newUser.toApi(false), 200); return context.json(newUser.toApi(false), 200);
}, },

View file

@ -73,7 +73,7 @@ export default apiRoute((app) =>
// Check if accepting remote follow // Check if accepting remote follow
if (account.isRemote()) { if (account.isRemote()) {
// Federate follow accept // Federate follow accept
await user.sendFollowAccept(account); await user.acceptFollowRequest(account);
} }
return context.json(foundRelationship.toApi(), 200); return context.json(foundRelationship.toApi(), 200);

View file

@ -74,7 +74,7 @@ export default apiRoute((app) =>
// Check if rejecting remote follow // Check if rejecting remote follow
if (account.isRemote()) { if (account.isRemote()) {
// Federate follow reject // Federate follow reject
await user.sendFollowReject(account); await user.rejectFollowRequest(account);
} }
return context.json(foundRelationship.toApi(), 200); return context.json(foundRelationship.toApi(), 200);

View file

@ -218,7 +218,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true); expect(ok).toBe(true);
expect(data).toMatchObject({ expect(data).toMatchObject({
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].getUri()}">@${users[1].data.username}</a>!</p>`, content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].uri.href}">@${users[1].data.username}</a>!</p>`,
}); });
expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize( expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize(
1, 1,
@ -241,7 +241,7 @@ describe("/api/v1/statuses", () => {
expect(ok).toBe(true); expect(ok).toBe(true);
expect(data).toMatchObject({ expect(data).toMatchObject({
content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].getUri()}">@${users[1].data.username}</a>!</p>`, content: `<p>Hello, <a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${users[1].uri.href}">@${users[1].data.username}</a>!</p>`,
}); });
expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize( expect((data as z.infer<typeof Status>).mentions).toBeArrayOfSize(
1, 1,

View file

@ -89,7 +89,7 @@ export default apiRoute((app) =>
); );
const uriCollection = new VersiaEntities.URICollection({ const uriCollection = new VersiaEntities.URICollection({
author: note.author.getUri(), author: note.author.uri,
first: new URL( first: new URL(
`/notes/${note.id}/quotes?offset=0`, `/notes/${note.id}/quotes?offset=0`,
config.http.base_url, config.http.base_url,

View file

@ -87,7 +87,7 @@ export default apiRoute((app) =>
); );
const uriCollection = new VersiaEntities.URICollection({ const uriCollection = new VersiaEntities.URICollection({
author: note.author.getUri(), author: note.author.uri,
first: new URL( first: new URL(
`/notes/${note.id}/replies?offset=0`, `/notes/${note.id}/replies?offset=0`,
config.http.base_url, config.http.base_url,

View file

@ -106,7 +106,7 @@ export default apiRoute((app) =>
config.http.base_url, config.http.base_url,
), ),
total: totalNotes, total: totalNotes,
author: author.getUri(), author: author.uri,
next: next:
notes.length === NOTES_PER_PAGE notes.length === NOTES_PER_PAGE
? new URL( ? new URL(

View file

@ -861,7 +861,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return new VersiaEntities.Delete({ return new VersiaEntities.Delete({
type: "Delete", type: "Delete",
id, id,
author: this.author.getUri(), author: this.author.uri,
deleted_type: "Note", deleted_type: "Note",
deleted: this.getUri(), deleted: this.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
@ -878,7 +878,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
type: "Note", type: "Note",
created_at: new Date(status.createdAt).toISOString(), created_at: new Date(status.createdAt).toISOString(),
id: status.id, id: status.id,
author: this.author.getUri(), author: this.author.uri,
uri: this.getUri(), uri: this.getUri(),
content: { content: {
"text/html": { "text/html": {

View file

@ -156,7 +156,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return !this.isLocal(); return !this.isLocal();
} }
public getUri(): URL { public get uri(): URL {
return this.data.uri return this.data.uri
? new URL(this.data.uri) ? new URL(this.data.uri)
: new URL(`/users/${this.data.id}`, config.http.base_url); : new URL(`/users/${this.data.id}`, config.http.base_url);
@ -208,8 +208,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity: { entity: {
type: "Follow", type: "Follow",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri().toString(), author: this.uri.href,
followee: otherUser.getUri().toString(), followee: otherUser.uri.href,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}, },
recipientId: otherUser.id, recipientId: otherUser.id,
@ -247,13 +247,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return new VersiaEntities.Unfollow({ return new VersiaEntities.Unfollow({
type: "Unfollow", type: "Unfollow",
id, id,
author: this.getUri(), author: this.uri,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
followee: followee.getUri(), followee: followee.uri,
}); });
} }
public async sendFollowAccept(follower: User): Promise<void> { public async acceptFollowRequest(follower: User): Promise<void> {
if (!follower.isRemote()) { if (!follower.isRemote()) {
throw new Error("Follower must be a remote user"); throw new Error("Follower must be a remote user");
} }
@ -265,9 +265,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowAccept({ const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept", type: "FollowAccept",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri(), author: this.uri,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri(), follower: follower.uri,
}); });
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -277,7 +277,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}); });
} }
public async sendFollowReject(follower: User): Promise<void> { public async rejectFollowRequest(follower: User): Promise<void> {
if (!follower.isRemote()) { if (!follower.isRemote()) {
throw new Error("Follower must be a remote user"); throw new Error("Follower must be a remote user");
} }
@ -289,9 +289,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowReject({ const entity = new VersiaEntities.FollowReject({
type: "FollowReject", type: "FollowReject",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri(), author: this.uri,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri(), follower: follower.uri,
}); });
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -326,7 +326,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const { headers } = await sign( const { headers } = await sign(
privateKey, privateKey,
this.getUri(), this.uri,
new Request(signatureUrl, { new Request(signatureUrl, {
method: signatureMethod, method: signatureMethod,
body: JSON.stringify(entity), body: JSON.stringify(entity),
@ -600,66 +600,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
public async updateFromRemote(): Promise<User> {
if (!this.isRemote()) {
throw new Error(
"Cannot refetch a local user (they are not remote)",
);
}
const updated = await User.fetchFromRemote(this.getUri());
if (!updated) {
throw new Error("Failed to update user from remote");
}
this.data = updated.data;
return this;
}
public static async fetchFromRemote(uri: URL): Promise<User | null> {
const instance = await Instance.resolve(uri);
if (!instance) {
return null;
}
if (instance.data.protocol === "versia") {
return await User.saveFromVersia(uri);
}
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
const bridgeUri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.toString(),
})}`,
config.federation.bridge.url,
);
return await User.saveFromVersia(bridgeUri);
}
throw new Error(`Unsupported protocol: ${instance.data.protocol}`);
}
private static async saveFromVersia(uri: URL): Promise<User> {
const userData = await User.federationRequester.fetchEntity(
uri,
VersiaEntities.User,
);
const user = await User.fromVersia(userData);
await searchManager.addUser(user);
return user;
}
/** /**
* Change the emojis linked to this user in database * Change the emojis linked to this user in database
* @param emojis * @param emojis
@ -679,6 +619,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
/**
* Tries to fetch a Versia user from the given URL.
*
* @param url The URL to fetch the user from
*/
public static async fromVersia(url: URL): Promise<User>;
/** /**
* Takes a Versia User representation, and serializes it to the database. * Takes a Versia User representation, and serializes it to the database.
* *
@ -687,7 +634,36 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
*/ */
public static async fromVersia( public static async fromVersia(
versiaUser: VersiaEntities.User, versiaUser: VersiaEntities.User,
): Promise<User>;
public static async fromVersia(
versiaUser: VersiaEntities.User | URL,
): Promise<User> { ): Promise<User> {
if (versiaUser instanceof URL) {
let uri = versiaUser;
const instance = await Instance.resolve(uri);
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
uri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.href,
})}`,
config.federation.bridge.url,
);
}
const user = await User.federationRequester.fetchEntity(
uri,
VersiaEntities.User,
);
return User.fromVersia(user);
}
const { const {
username, username,
inbox, inbox,
@ -799,7 +775,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
getLogger(["federation", "resolvers"]) getLogger(["federation", "resolvers"])
.debug`Resolving user ${chalk.gray(uri)}`; .debug`Resolving user ${chalk.gray(uri)}`;
// Check if user not already in database // Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri.toString())); const foundUser = await User.fromSql(eq(Users.uri, uri.href));
if (foundUser) { if (foundUser) {
return foundUser; return foundUser;
@ -821,7 +797,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
getLogger(["federation", "resolvers"]) getLogger(["federation", "resolvers"])
.debug`User not found in database, fetching from remote`; .debug`User not found in database, fetching from remote`;
return await User.fetchFromRemote(uri); return User.fromVersia(uri);
} }
/** /**
@ -983,7 +959,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
["sign"], ["sign"],
) )
.then((k) => { .then((k) => {
return new FederationRequester(k, this.getUri()); return new FederationRequester(k, this.uri);
}); });
} }
@ -1037,7 +1013,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (!inbox) { if (!inbox) {
throw new Error( throw new Error(
`User ${chalk.gray(user.getUri())} does not have an inbox endpoint`, `User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
); );
} }
@ -1048,7 +1024,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} catch (e) { } catch (e) {
getLogger(["federation", "delivery"]) getLogger(["federation", "delivery"])
.error`Federating ${chalk.gray(entity.data.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`; .error`Federating ${chalk.gray(entity.data.type)} to ${user.uri} ${chalk.bold.red("failed")}`;
getLogger(["federation", "delivery"]).error`${e}`; getLogger(["federation", "delivery"]).error`${e}`;
sentry?.captureException(e); sentry?.captureException(e);
@ -1066,10 +1042,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
username: user.username, username: user.username,
display_name: user.displayName || user.username, display_name: user.displayName || user.username,
note: user.note, note: user.note,
uri: this.getUri().toString(), uri: this.uri.href,
url: url:
user.uri || user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(), new URL(`/@${user.username}`, config.http.base_url).href,
avatar: this.getAvatarUrl().proxied, avatar: this.getAvatarUrl().proxied,
header: this.getHeaderUrl()?.proxied ?? "", header: this.getHeaderUrl()?.proxied ?? "",
locked: user.isLocked, locked: user.isLocked,
@ -1123,7 +1099,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return new VersiaEntities.User({ return new VersiaEntities.User({
id: user.id, id: user.id,
type: "User", type: "User",
uri: this.getUri(), uri: this.uri,
bio: { bio: {
"text/html": { "text/html": {
content: user.note, content: user.note,
@ -1190,7 +1166,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public toMention(): z.infer<typeof MentionSchema> { public toMention(): z.infer<typeof MentionSchema> {
return { return {
url: this.getUri().toString(), url: this.uri.href,
username: this.data.username, username: this.data.username,
acct: this.getAcct(), acct: this.getAcct(),
id: this.id, id: this.id,

View file

@ -296,7 +296,7 @@ export const parseTextMentions = async (
export const replaceTextMentions = (text: string, mentions: User[]): string => { export const replaceTextMentions = (text: string, mentions: User[]): string => {
return mentions.reduce((finalText, mention) => { return mentions.reduce((finalText, mention) => {
const { username, instance } = mention.data; const { username, instance } = mention.data;
const uri = mention.getUri(); const { uri } = mention;
const baseHost = config.http.base_url.host; const baseHost = config.http.base_url.host;
const linkTemplate = (displayText: string): string => const linkTemplate = (displayText: string): string =>
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`; `<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;

View file

@ -254,7 +254,7 @@ export class InboxProcessor {
); );
if (!followee.data.isLocked) { if (!followee.data.isLocked) {
await followee.sendFollowAccept(author); await followee.acceptFollowRequest(author);
} }
} }

View file

@ -3,6 +3,7 @@ import chalk from "chalk";
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc"; import { type Root, defineCommand } from "clerc";
import ora from "ora"; import ora from "ora";
import { User } from "~/classes/database/user.ts";
import { retrieveUser } from "../utils.ts"; import { retrieveUser } from "../utils.ts";
export const refetchUserCommand = defineCommand( export const refetchUserCommand = defineCommand(
@ -29,7 +30,7 @@ export const refetchUserCommand = defineCommand(
const spinner = ora("Refetching user").start(); const spinner = ora("Refetching user").start();
try { try {
await user.updateFromRemote(); await User.fromVersia(user.uri);
} catch (error) { } catch (error) {
spinner.fail( spinner.fail(
`Failed to refetch user ${chalk.gray(user.data.username)}`, `Failed to refetch user ${chalk.gray(user.data.username)}`,