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.
*
* If the user already exists, it will update it.
* @param user
*/
public static async fromVersia(
versiaUser: VersiaEntities.User,
): Promise<User> {
const {
username,
inbox,
avatar,
header,
display_name,
fields,
collections,
created_at,
bio,
public_key,
uri,
extensions,
} = versiaUser.data;
const instance = await Instance.resolve(versiaUser.data.uri);
const existingUser = await User.fromSql(
eq(Users.uri, versiaUser.data.uri.href),
);
const data = { const user =
username: user.data.username, existingUser ??
uri: user.data.uri.href, (await User.insert({
createdAt: new Date(user.data.created_at).toISOString(), username,
endpoints: { id: randomUUIDv7(),
dislikes: publicKey: public_key.key,
user.data.collections["pub.versia:likes/Dislikes"]?.href ?? uri: uri.href,
undefined,
featured: user.data.collections.featured.href,
likes:
user.data.collections["pub.versia:likes/Likes"]?.href ??
undefined,
followers: user.data.collections.followers.href,
following: user.data.collections.following.href,
inbox: user.data.inbox.href,
outbox: user.data.collections.outbox.href,
},
fields: user.data.fields ?? [],
updatedAt: new Date(user.data.created_at).toISOString(),
instanceId: instance.id, instanceId: instance.id,
displayName: user.data.display_name ?? "", }));
note: getBestContentType(user.data.bio).content,
publicKey: user.data.public_key.key,
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
};
const userEmojis = // Avatars and headers are stored in a separate table, so we need to update them separately
user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? []; let userAvatar: Media | null = null;
let userHeader: Media | null = null;
if (avatar) {
if (user.avatar) {
userAvatar = new Media(
await user.avatar.update({
content: avatar,
}),
);
} else {
userAvatar = await Media.insert({
id: randomUUIDv7(),
content: avatar,
});
}
}
if (header) {
if (user.header) {
userHeader = new Media(
await user.header.update({
content: header,
}),
);
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
});
}
}
await user.update({
createdAt: new Date(created_at).toISOString(),
endpoints: {
inbox: inbox.href,
outbox: collections.outbox.href,
followers: collections.followers.href,
following: collections.following.href,
featured: collections.featured.href,
likes: collections["pub.versia:likes/Likes"]?.href,
dislikes: collections["pub.versia:likes/Dislikes"]?.href,
},
avatarId: userAvatar?.id,
headerId: userHeader?.id,
fields: fields ?? [],
displayName: display_name,
note: getBestContentType(bio).content,
});
// Emojis are stored in a separate table, so we need to update them separately
const emojis = await Promise.all( const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)), extensions?.["pub.versia:custom_emojis"]?.emojis.map((e) =>
Emoji.fromVersia(e, instance),
) ?? [],
); );
// Check if new user already exists await user.updateEmojis(emojis);
const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href));
// If it exists, simply update it return user;
if (foundUser) {
let avatar: Media | null = null;
let header: Media | null = null;
if (user.data.avatar) {
if (foundUser.avatar) {
avatar = new Media(
await foundUser.avatar.update({
content: user.data.avatar,
}),
);
} else {
avatar = await Media.insert({
id: randomUUIDv7(),
content: user.data.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
const avatar = user.data.avatar
? await Media.insert({
id: randomUUIDv7(),
content: user.data.avatar,
})
: null;
const header = user.data.header
? await Media.insert({
id: randomUUIDv7(),
content: user.data.header,
})
: null;
const newUser = await User.insert({
id: randomUUIDv7(),
...data,
avatarId: avatar?.id,
headerId: header?.id,
});
await newUser.updateEmojis(emojis);
return newUser;
} }
public static async insert( public static async insert(
@ -879,35 +865,28 @@ 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
.insert(Users)
.values({
id: randomUUIDv7(), id: randomUUIDv7(),
username: data.username, username: username,
displayName: data.display_name ?? data.username, displayName: username,
password: password: options?.password
data.skipPasswordHash || !data.password ? await bunPassword.hash(options.password)
? data.password : null,
: await bunPassword.hash(data.password), email: options?.email,
email: data.email, note: "",
note: data.bio ?? "", avatarId: options?.avatar?.id,
avatarId: data.avatar?.id, isAdmin: options?.isAdmin,
headerId: data.header?.id,
isAdmin: data.admin ?? false,
publicKey: keys.public_key, publicKey: keys.public_key,
fields: [], fields: [],
privateKey: keys.private_key, privateKey: keys.private_key,
@ -919,20 +898,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
sensitive: false, sensitive: false,
fields: [], fields: [],
} as z.infer<typeof Source>, } 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,
}); });