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({
username,
await User.register(username, {
password,
email,
});

View file

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

View file

@ -28,7 +28,7 @@ describe("/api/v1/accounts/verify_credentials", () => {
expect(data.id).toBe(users[0].id);
expect(data.username).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.url).toBe(
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);
const data = {
username: user.data.username,
uri: user.data.uri.href,
createdAt: new Date(user.data.created_at).toISOString(),
endpoints: {
dislikes:
user.data.collections["pub.versia:likes/Dislikes"]?.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,
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 =
user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)),
/**
* 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),
);
// Check if new user already exists
const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href));
const user =
existingUser ??
(await User.insert({
username,
id: randomUUIDv7(),
publicKey: public_key.key,
uri: uri.href,
instanceId: instance.id,
}));
// If it exists, simply update it
if (foundUser) {
let avatar: Media | null = null;
let header: Media | null = null;
// Avatars and headers are stored in a separate table, so we need to update them separately
let userAvatar: Media | null = null;
let userHeader: 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 (avatar) {
if (user.avatar) {
userAvatar = new Media(
await user.avatar.update({
content: avatar,
}),
);
} else {
userAvatar = await Media.insert({
id: randomUUIDv7(),
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
const avatar = user.data.avatar
? await Media.insert({
id: randomUUIDv7(),
content: user.data.avatar,
})
: null;
if (header) {
if (user.header) {
userHeader = new Media(
await user.header.update({
content: header,
}),
);
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
});
}
}
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 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,
});
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(
@ -879,60 +865,45 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
};
}
public static async fromDataLocal(data: {
username: string;
display_name?: string;
password: string | undefined;
email: string | undefined;
bio?: string;
avatar?: Media;
header?: Media;
admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<User> {
public static async register(
username: string,
options?: Partial<{
email: string;
password: string;
avatar: Media;
isAdmin: boolean;
}>,
): Promise<User> {
const keys = await User.generateKeys();
const newUser = (
await db
.insert(Users)
.values({
id: randomUUIDv7(),
username: data.username,
displayName: data.display_name ?? data.username,
password:
data.skipPasswordHash || !data.password
? data.password
: await bunPassword.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatarId: data.avatar?.id,
headerId: data.header?.id,
isAdmin: data.admin ?? false,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date().toISOString(),
source: {
language: "en",
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");
}
const user = await User.insert({
id: randomUUIDv7(),
username: username,
displayName: username,
password: options?.password
? await bunPassword.hash(options.password)
: null,
email: options?.email,
note: "",
avatarId: options?.avatar?.id,
isAdmin: options?.isAdmin,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date().toISOString(),
source: {
language: "en",
note: "",
privacy: "public",
sensitive: false,
fields: [],
} as z.infer<typeof Source>,
});
// 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 {
id: user.id,
username: user.username,
display_name: user.displayName,
display_name: user.displayName || user.username,
note: user.note,
uri: this.getUri().toString(),
url:
@ -1119,7 +1090,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
verified_at: null,
})),
bot: user.isBot,
source: isOwnAccount ? user.source : undefined,
source: isOwnAccount ? (user.source ?? undefined) : undefined,
// TODO: Add static avatar and header
avatar_static: this.getAvatarUrl().proxied,
header_static: this.getHeaderUrl()?.proxied ?? "",

View file

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

View file

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

View file

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

View file

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

View file

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