refactor(federation): ♻️ Refactor User federation code

This commit is contained in:
Jesse Wierzbinski 2025-04-08 16:59:18 +02:00
parent d638610361
commit 9ff9b90f6b
No known key found for this signature in database
9 changed files with 152 additions and 180 deletions

View file

@ -353,8 +353,7 @@ export default apiRoute((app) =>
); );
} }
await User.fromDataLocal({ await User.register(username, {
username,
password, password,
email, email,
}); });

View file

@ -176,6 +176,15 @@ export default apiRoute((app) =>
} = context.req.valid("json"); } = context.req.valid("json");
const self = user.data; const self = user.data;
if (!self.source) {
self.source = {
fields: [],
privacy: "public",
language: "en",
sensitive: false,
note: "",
};
}
const sanitizedDisplayName = await sanitizedHtmlStrip( const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "", display_name ?? "",
@ -185,7 +194,7 @@ export default apiRoute((app) =>
self.displayName = sanitizedDisplayName; self.displayName = sanitizedDisplayName;
} }
if (note && self.source) { if (note) {
self.source.note = note; self.source.note = note;
self.note = await contentToHtml( self.note = await contentToHtml(
new VersiaEntities.TextContentFormat({ new VersiaEntities.TextContentFormat({
@ -197,16 +206,13 @@ export default apiRoute((app) =>
); );
} }
if (source?.privacy) { if (source) {
self.source.privacy = source.privacy; self.source = {
} ...self.source,
privacy: source.privacy ?? self.source.privacy,
if (source?.sensitive) { sensitive: source.sensitive ?? self.source.sensitive,
self.source.sensitive = source.sensitive; language: source.language ?? self.source.language,
} };
if (source?.language) {
self.source.language = source.language;
} }
if (username) { if (username) {

View file

@ -28,7 +28,7 @@ describe("/api/v1/accounts/verify_credentials", () => {
expect(data.id).toBe(users[0].id); expect(data.id).toBe(users[0].id);
expect(data.username).toBe(users[0].data.username); expect(data.username).toBe(users[0].data.username);
expect(data.acct).toBe(users[0].data.username); expect(data.acct).toBe(users[0].data.username);
expect(data.display_name).toBe(users[0].data.displayName); expect(data.display_name).toBe(users[0].data.displayName ?? "");
expect(data.note).toBe(users[0].data.note); expect(data.note).toBe(users[0].data.note);
expect(data.url).toBe( expect(data.url).toBe(
new URL( new URL(

View file

@ -679,120 +679,106 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
public static async fromVersia(user: VersiaEntities.User): Promise<User> { /**
const instance = await Instance.resolve(user.data.uri); * Takes a Versia User representation, and serializes it to the database.
*
const data = { * If the user already exists, it will update it.
username: user.data.username, * @param user
uri: user.data.uri.href, */
createdAt: new Date(user.data.created_at).toISOString(), public static async fromVersia(
endpoints: { versiaUser: VersiaEntities.User,
dislikes: ): Promise<User> {
user.data.collections["pub.versia:likes/Dislikes"]?.href ?? const {
undefined, username,
featured: user.data.collections.featured.href, inbox,
likes: avatar,
user.data.collections["pub.versia:likes/Likes"]?.href ?? header,
undefined, display_name,
followers: user.data.collections.followers.href, fields,
following: user.data.collections.following.href, collections,
inbox: user.data.inbox.href, created_at,
outbox: user.data.collections.outbox.href, bio,
}, public_key,
fields: user.data.fields ?? [], uri,
updatedAt: new Date(user.data.created_at).toISOString(), extensions,
instanceId: instance.id, } = versiaUser.data;
displayName: user.data.display_name ?? "", const instance = await Instance.resolve(versiaUser.data.uri);
note: getBestContentType(user.data.bio).content, const existingUser = await User.fromSql(
publicKey: user.data.public_key.key, eq(Users.uri, versiaUser.data.uri.href),
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
};
const userEmojis =
user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)),
); );
// Check if new user already exists const user =
const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href)); existingUser ??
(await User.insert({
username,
id: randomUUIDv7(),
publicKey: public_key.key,
uri: uri.href,
instanceId: instance.id,
}));
// If it exists, simply update it // Avatars and headers are stored in a separate table, so we need to update them separately
if (foundUser) { let userAvatar: Media | null = null;
let avatar: Media | null = null; let userHeader: Media | null = null;
let header: Media | null = null;
if (user.data.avatar) { if (avatar) {
if (foundUser.avatar) { if (user.avatar) {
avatar = new Media( userAvatar = new Media(
await foundUser.avatar.update({ await user.avatar.update({
content: user.data.avatar, content: avatar,
}), }),
); );
} else { } else {
avatar = await Media.insert({ userAvatar = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: user.data.avatar, content: avatar,
}); });
}
} }
if (user.data.header) {
if (foundUser.header) {
header = new Media(
await foundUser.header.update({
content: user.data.header,
}),
);
} else {
header = await Media.insert({
id: randomUUIDv7(),
content: user.data.header,
});
}
}
await foundUser.update({
...data,
avatarId: avatar?.id,
headerId: header?.id,
});
await foundUser.updateEmojis(emojis);
return foundUser;
} }
// Else, create a new user if (header) {
const avatar = user.data.avatar if (user.header) {
? await Media.insert({ userHeader = new Media(
id: randomUUIDv7(), await user.header.update({
content: user.data.avatar, content: header,
}) }),
: null; );
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
});
}
}
const header = user.data.header await user.update({
? await Media.insert({ createdAt: new Date(created_at).toISOString(),
id: randomUUIDv7(), endpoints: {
content: user.data.header, inbox: inbox.href,
}) outbox: collections.outbox.href,
: null; followers: collections.followers.href,
following: collections.following.href,
const newUser = await User.insert({ featured: collections.featured.href,
id: randomUUIDv7(), likes: collections["pub.versia:likes/Likes"]?.href,
...data, dislikes: collections["pub.versia:likes/Dislikes"]?.href,
avatarId: avatar?.id, },
headerId: header?.id, avatarId: userAvatar?.id,
headerId: userHeader?.id,
fields: fields ?? [],
displayName: display_name,
note: getBestContentType(bio).content,
}); });
await newUser.updateEmojis(emojis);
return newUser; // Emojis are stored in a separate table, so we need to update them separately
const emojis = await Promise.all(
extensions?.["pub.versia:custom_emojis"]?.emojis.map((e) =>
Emoji.fromVersia(e, instance),
) ?? [],
);
await user.updateEmojis(emojis);
return user;
} }
public static async insert( public static async insert(
@ -879,60 +865,45 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}; };
} }
public static async fromDataLocal(data: { public static async register(
username: string; username: string,
display_name?: string; options?: Partial<{
password: string | undefined; email: string;
email: string | undefined; password: string;
bio?: string; avatar: Media;
avatar?: Media; isAdmin: boolean;
header?: Media; }>,
admin?: boolean; ): Promise<User> {
skipPasswordHash?: boolean;
}): Promise<User> {
const keys = await User.generateKeys(); const keys = await User.generateKeys();
const newUser = ( const user = await User.insert({
await db id: randomUUIDv7(),
.insert(Users) username: username,
.values({ displayName: username,
id: randomUUIDv7(), password: options?.password
username: data.username, ? await bunPassword.hash(options.password)
displayName: data.display_name ?? data.username, : null,
password: email: options?.email,
data.skipPasswordHash || !data.password note: "",
? data.password avatarId: options?.avatar?.id,
: await bunPassword.hash(data.password), isAdmin: options?.isAdmin,
email: data.email, publicKey: keys.public_key,
note: data.bio ?? "", fields: [],
avatarId: data.avatar?.id, privateKey: keys.private_key,
headerId: data.header?.id, updatedAt: new Date().toISOString(),
isAdmin: data.admin ?? false, source: {
publicKey: keys.public_key, language: "en",
fields: [], note: "",
privateKey: keys.private_key, privacy: "public",
updatedAt: new Date().toISOString(), sensitive: false,
source: { fields: [],
language: "en", } as z.infer<typeof Source>,
note: "", });
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
})
.returning()
)[0];
const finalUser = await User.fromId(newUser.id);
if (!finalUser) {
throw new Error("Failed to create user");
}
// Add to search index // Add to search index
await searchManager.addUser(finalUser); await searchManager.addUser(user);
return finalUser; return user;
} }
/** /**
@ -1093,7 +1064,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
display_name: user.displayName, display_name: user.displayName || user.username,
note: user.note, note: user.note,
uri: this.getUri().toString(), uri: this.getUri().toString(),
url: url:
@ -1119,7 +1090,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
verified_at: null, verified_at: null,
})), })),
bot: user.isBot, bot: user.isBot,
source: isOwnAccount ? user.source : undefined, source: isOwnAccount ? (user.source ?? undefined) : undefined,
// TODO: Add static avatar and header // TODO: Add static avatar and header
avatar_static: this.getAvatarUrl().proxied, avatar_static: this.getAvatarUrl().proxied,
header_static: this.getHeaderUrl()?.proxied ?? "", header_static: this.getHeaderUrl()?.proxied ?? "",

View file

@ -155,7 +155,7 @@ export class SonicSearchManager {
private static getNthDatabaseAccountBatch( private static getNthDatabaseAccountBatch(
n: number, n: number,
batchSize = 1000, batchSize = 1000,
): Promise<Record<string, string | Date>[]> { ): Promise<Record<string, string | null | Date>[]> {
return db.query.Users.findMany({ return db.query.Users.findMany({
offset: n * batchSize, offset: n * batchSize,
limit: batchSize, limit: batchSize,

View file

@ -48,11 +48,10 @@ export const createUserCommand = defineCommand(
throw new Error(`User ${chalk.gray(username)} is taken.`); throw new Error(`User ${chalk.gray(username)} is taken.`);
} }
const user = await User.fromDataLocal({ const user = await User.register(username, {
email, email,
password, password,
username, isAdmin: admin,
admin,
}); });
if (!user) { if (!user) {

View file

@ -556,7 +556,7 @@ export const Users = pgTable(
id: id(), id: id(),
uri: uri(), uri: uri(),
username: text("username").notNull(), username: text("username").notNull(),
displayName: text("display_name").notNull(), displayName: text("display_name"),
password: text("password"), password: text("password"),
email: text("email"), email: text("email"),
note: text("note").default("").notNull(), note: text("note").default("").notNull(),
@ -578,7 +578,7 @@ export const Users = pgTable(
inbox: string; inbox: string;
outbox: string; outbox: string;
}> | null>(), }> | null>(),
source: jsonb("source").notNull().$type<z.infer<typeof Source>>(), source: jsonb("source").$type<z.infer<typeof Source>>(),
avatarId: uuid("avatarId").references(() => Medias.id, { avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null", onDelete: "set null",
onUpdate: "cascade", onUpdate: "cascade",

View file

@ -235,11 +235,9 @@ export default (plugin: PluginType): void => {
: null; : null;
// Create new user // Create new user
const user = await User.fromDataLocal({ const user = await User.register(username, {
email: doesEmailExist ? undefined : email, email: doesEmailExist ? undefined : email,
username,
avatar: avatar ?? undefined, avatar: avatar ?? undefined,
password: undefined,
}); });
// Link account // Link account

View file

@ -103,8 +103,7 @@ export const getTestUsers = async (
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const password = randomString(32, "hex"); const password = randomString(32, "hex");
const user = await User.fromDataLocal({ const user = await User.register(`test-${randomString(8, "hex")}`, {
username: `test-${randomString(8, "hex")}`,
email: `${randomString(16, "hex")}@test.com`, email: `${randomString(16, "hex")}@test.com`,
password, password,
}); });