import type { Account, Mention as MentionSchema, RolePermission, Source, } from "@versia/client/schemas"; import * as VersiaEntities from "@versia/sdk/entities"; import { FederationRequester } from "@versia/sdk/http"; import type { ImageContentFormatSchema } from "@versia/sdk/schemas"; import { config, ProxiableUrl } from "@versia-server/config"; import { federationDeliveryLogger } from "@versia-server/logging"; import { password as bunPassword, randomUUIDv7 } from "bun"; import chalk from "chalk"; import { and, countDistinct, desc, eq, gte, type InferInsertModel, type InferSelectModel, inArray, isNotNull, isNull, type SQL, sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import type { z } from "zod"; import { getBestContentType } from "@/content_types"; import { randomString } from "@/math"; import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts"; import { PushJobType, pushQueue } from "../queues/push/queue.ts"; import { db } from "../tables/db.ts"; import { EmojiToUser, Notes, NoteToMentions, Notifications, Relationships, Users, UserToPinnedNotes, } from "../tables/schema.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; import { Media } from "./media.ts"; import type { Note } from "./note.ts"; import { PushSubscription } from "./pushsubscription.ts"; import { Relationship } from "./relationship.ts"; import { Role } from "./role.ts"; export const userRelations = { instance: true, emojis: { with: { emoji: { with: { instance: true, media: true, }, }, }, }, avatar: true, header: true, roles: { with: { role: true, }, }, } as const; // TODO: Remove this function and use what drizzle outputs directly instead of transforming it export const transformOutputToUserWithRelations = ( user: InferSelectModel & { followerCount: unknown; followingCount: unknown; statusCount: unknown; avatar: typeof Media.$type | null; header: typeof Media.$type | null; emojis: { userId: string; emojiId: string; emoji?: typeof Emoji.$type; }[]; instance: typeof Instance.$type | null; roles: { userId: string; roleId: string; role?: typeof Role.$type; }[]; }, ): typeof User.$type => { return { ...user, followerCount: Number(user.followerCount), followingCount: Number(user.followingCount), statusCount: Number(user.statusCount), emojis: user.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as typeof Emoji.$type, ), roles: user.roles .map((role) => role.role) .filter(Boolean) as (typeof Role.$type)[], }; }; const findManyUsers = async ( query: Parameters[0], ): Promise<(typeof User.$type)[]> => { const output = await db.query.Users.findMany({ ...query, with: { ...userRelations, ...query?.with, }, }); return output.map((user) => transformOutputToUserWithRelations(user)); }; type UserWithInstance = InferSelectModel & { instance: typeof Instance.$type | null; }; type UserWithRelations = UserWithInstance & { emojis: (typeof Emoji.$type)[]; avatar: typeof Media.$type | null; header: typeof Media.$type | null; followerCount: number; followingCount: number; statusCount: number; roles: (typeof Role.$type)[]; }; /** * Gives helpers to fetch users from database in a nice format */ export class User extends BaseInterface { public static $type: UserWithRelations; public avatar: Media | null; public header: Media | null; public constructor(data: UserWithRelations) { super(data); this.avatar = data.avatar ? new Media(data.avatar) : null; this.header = data.header ? new Media(data.header) : null; } public async reload(): Promise { const reloaded = await User.fromId(this.data.id); if (!reloaded) { throw new Error("Failed to reload user"); } this.data = reloaded.data; this.avatar = reloaded.avatar; this.header = reloaded.header; } public static async fromId(id: string | null): Promise { if (!id) { return null; } return await User.fromSql(eq(Users.id, id)); } public static async fromIds(ids: string[]): Promise { return await User.manyFromSql(inArray(Users.id, ids)); } public static async fromSql( sql: SQL | undefined, orderBy: SQL | undefined = desc(Users.id), ): Promise { const found = await findManyUsers({ where: sql, orderBy, }); if (!found[0]) { return null; } return new User(found[0]); } public static async manyFromSql( sql: SQL | undefined, orderBy: SQL | undefined = desc(Users.id), limit?: number, offset?: number, extra?: Parameters[0], ): Promise { const found = await findManyUsers({ where: sql, orderBy, limit, offset, with: extra?.with, }); return found.map((s) => new User(s)); } public get id(): string { return this.data.id; } public get local(): boolean { return this.data.instanceId === null; } public get remote(): boolean { return !this.local; } public get reference(): VersiaEntities.Reference { if (this.local) { return new VersiaEntities.Reference( this.id, config.http.base_url.hostname, ); } return new VersiaEntities.Reference( this.data.remoteId as string, (this.data.instance as typeof Instance.$type).domain, ); } public get uri(): URL { const domain = this.data.instance?.domain ? new URL(`https://${this.data.instance.domain}`) : config.http.base_url; return new URL( `/.versia/v0.6/entities/User/${this.id}`, `https://${domain}`, ); } public hasPermission(permission: RolePermission): boolean { return this.getAllPermissions().includes(permission); } public getAllPermissions(): RolePermission[] { return Array.from( new Set([ ...this.data.roles.flatMap((role) => role.permissions), // Add default permissions ...config.permissions.default, // If admin, add admin permissions ...(this.data.isAdmin ? config.permissions.admin : []), ]), ); } public async followRequest( otherUser: User, options?: { reblogs?: boolean; notify?: boolean; languages?: string[]; }, ): Promise { const foundRelationship = await Relationship.fromOwnerAndSubject( this, otherUser, ); await foundRelationship.update({ following: otherUser.remote ? false : !otherUser.data.isLocked, requested: otherUser.remote ? true : otherUser.data.isLocked, showingReblogs: options?.reblogs, notifying: options?.notify, languages: options?.languages, }); if (!otherUser.data.isLocked) { // Update the follower count await otherUser.recalculateFollowerCount(); await this.recalculateFollowingCount(); } if (otherUser.remote) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: { type: "Follow", id: crypto.randomUUID(), author: this.uri.href, followee: otherUser.uri.href, created_at: new Date().toISOString(), }, recipientId: otherUser.id, senderId: this.id, }); } else { await otherUser.notify( otherUser.data.isLocked ? "follow_request" : "follow", this, ); } return foundRelationship; } public async unfollow( followee: User, relationship: Relationship, ): Promise { if (followee.remote) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: this.unfollowToVersia(followee).toJSON(), recipientId: followee.id, senderId: this.id, }); } await this.recalculateFollowingCount(); await followee.recalculateFollowerCount(); await relationship.update({ following: false, }); } private unfollowToVersia(followee: User): VersiaEntities.Unfollow { return new VersiaEntities.Unfollow( { type: "Unfollow", author: this.id, created_at: new Date().toISOString(), followee: followee.data.instance ? `${followee.data.instance.domain}:${followee.id}` : followee.id, }, this.data.instance?.domain ?? config.http.base_url.hostname, ); } public async acceptFollowRequest(follower: User): Promise { if (!follower.remote) { throw new Error("Follower must be a remote user"); } if (this.remote) { throw new Error("Followee must be a local user"); } await follower.recalculateFollowerCount(); await this.recalculateFollowingCount(); const entity = new VersiaEntities.FollowAccept( { type: "FollowAccept", author: this.id, created_at: new Date().toISOString(), follower: follower.data.instance ? `${follower.data.instance.domain}:${follower.id}` : follower.id, }, this.data.instance?.domain ?? config.http.base_url.hostname, ); await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: entity.toJSON(), recipientId: follower.id, senderId: this.id, }); } public async rejectFollowRequest(follower: User): Promise { if (!follower.remote) { throw new Error("Follower must be a remote user"); } if (this.remote) { throw new Error("Followee must be a local user"); } const entity = new VersiaEntities.FollowReject( { type: "FollowReject", author: this.id, created_at: new Date().toISOString(), follower: follower.data.instance ? `${follower.data.instance.domain}:${follower.id}` : follower.id, }, this.data.instance?.domain ?? config.http.base_url.hostname, ); await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: entity.toJSON(), recipientId: follower.id, senderId: this.id, }); } /** * Perform a WebFinger lookup to find a user's URI * @param username * @param hostname * @returns URI, or null if not found */ public static webFinger( username: string, hostname: string, ): Promise { try { return FederationRequester.resolveWebFinger(username, hostname); } catch { try { return FederationRequester.resolveWebFinger( username, hostname, "application/activity+json", ); } catch { return Promise.resolve(null); } } } public static getCount(): Promise { return db.$count(Users, isNull(Users.instanceId)); } public static async getActiveInPeriod( milliseconds: number, ): Promise { return ( await db .select({ count: countDistinct(Users), }) .from(Users) .leftJoin(Notes, eq(Users.id, Notes.authorId)) .where( and( isNull(Users.instanceId), gte( Notes.createdAt, new Date(Date.now() - milliseconds), ), ), ) )[0].count; } public async delete(ids?: string[]): Promise { if (Array.isArray(ids)) { await db.delete(Users).where(inArray(Users.id, ids)); } else { await db.delete(Users).where(eq(Users.id, this.id)); } } public async resetPassword(): Promise { const resetToken = randomString(32, "hex"); await this.update({ passwordResetToken: resetToken, }); return resetToken; } public async pin(note: Note): Promise { await db.insert(UserToPinnedNotes).values({ noteId: note.id, userId: this.id, }); } public async unpin(note: Note): Promise { await db .delete(UserToPinnedNotes) .where( and( eq(NoteToMentions.noteId, note.id), eq(NoteToMentions.userId, this.id), ), ); } public save(): Promise { return this.update(this.data); } public async getLinkedOidcAccounts( providers: { id: string; name: string; url: ProxiableUrl; icon?: ProxiableUrl; }[], ): Promise< { id: string; name: string; url: ProxiableUrl; icon?: ProxiableUrl; server_id: string; }[] > { // Get all linked accounts const accounts = await db.query.OpenIdAccounts.findMany({ where: (User): SQL | undefined => eq(User.userId, this.id), }); return accounts .map((account) => { const issuer = providers.find( (provider) => provider.id === account.issuerId, ); if (!issuer) { return null; } return { id: issuer.id, name: issuer.name, url: issuer.url, icon: issuer.icon, server_id: account.serverId, }; }) .filter((x) => x !== null); } public async recalculateFollowerCount(): Promise { const followerCount = await db.$count( Relationships, and( eq(Relationships.subjectId, this.id), eq(Relationships.following, true), ), ); await this.update({ followerCount, }); } public async recalculateFollowingCount(): Promise { const followingCount = await db.$count( Relationships, and( eq(Relationships.ownerId, this.id), eq(Relationships.following, true), ), ); await this.update({ followingCount, }); } public async recalculateStatusCount(): Promise { const statusCount = await db.$count( Notes, and(eq(Notes.authorId, this.id)), ); await this.update({ statusCount, }); } public async notify( type: | "mention" | "follow_request" | "follow" | "favourite" | "reblog" | "reaction", relatedUser: User, note?: Note, ): Promise { const notification = ( await db .insert(Notifications) .values({ id: randomUUIDv7(), accountId: relatedUser.id, type, notifiedId: this.id, noteId: note?.id ?? null, }) .returning() )[0]; // Also do push notifications if (config.notifications.push) { await this.notifyPush(notification.id, type, relatedUser, note); } } private async notifyPush( notificationId: string, type: | "mention" | "follow_request" | "follow" | "favourite" | "reblog" | "reaction", relatedUser: User, note?: Note, ): Promise { // Fetch all push subscriptions const ps = await PushSubscription.manyFromUser(this); pushQueue.addBulk( ps.map((p) => ({ data: { psId: p.id, type, relatedUserId: relatedUser.id, noteId: note?.id, notificationId, }, name: PushJobType.Notify, })), ); } public async clearAllNotifications(): Promise { await db .update(Notifications) .set({ dismissed: true, }) .where(eq(Notifications.notifiedId, this.id)); } public async clearSomeNotifications(ids: string[]): Promise { await db .update(Notifications) .set({ dismissed: true, }) .where( and( inArray(Notifications.id, ids), eq(Notifications.notifiedId, this.id), ), ); } /** * Change the emojis linked to this user in database * @param emojis * @returns */ public async updateEmojis(emojis: Emoji[]): Promise { if (emojis.length === 0) { return; } await db.delete(EmojiToUser).where(eq(EmojiToUser.userId, this.id)); await db.insert(EmojiToUser).values( emojis.map((emoji) => ({ emojiId: emoji.id, userId: this.id, })), ); } /** * Takes a Versia User representation, and serializes it to the database. * * If the user already exists, it will update it. * @param versiaUser Reference or Versia User representation */ public static async fromVersia( versiaUser: VersiaEntities.User, instance: Instance, ): Promise; public static async fromVersia( versiaUser: VersiaEntities.Reference, ): Promise; public static async fromVersia( versiaUser: VersiaEntities.User | VersiaEntities.Reference, instance?: Instance, ): Promise { if (versiaUser instanceof VersiaEntities.Reference) { const user = await Instance.federationRequester.fetchEntity( versiaUser, VersiaEntities.User, ); const instance = await Instance.resolve(versiaUser.domain); return User.fromVersia(user, instance); } if (!instance) { throw new Error("Instance must be provided when fetching user"); } const { username, display_name, id, fields, created_at, manually_approves_followers, bio, extensions, } = versiaUser.data; const existingUser = await User.fromSql( and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)), ); const user = existingUser ?? (await User.insert({ username, id: randomUUIDv7(), instanceId: instance.id, remoteId: id, })); // 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 (versiaUser.avatar) { if (user.avatar) { userAvatar = new Media( await user.avatar.update({ content: versiaUser.avatar.data, }), ); } else { userAvatar = await Media.insert({ id: randomUUIDv7(), content: versiaUser.avatar.data, }); } } if (versiaUser.header) { if (user.header) { userHeader = new Media( await user.header.update({ content: versiaUser.header.data, }), ); } else { userHeader = await Media.insert({ id: randomUUIDv7(), content: versiaUser.header.data, }); } } await user.update({ createdAt: new Date(created_at), isLocked: manually_approves_followers, avatarId: userAvatar?.id, headerId: userHeader?.id, 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( extensions?.["pub.versia:custom_emojis"]?.emojis.map((e) => Emoji.fromVersia(e, instance), ) ?? [], ); await user.updateEmojis(emojis); return user; } public static async insert( data: InferInsertModel, ): Promise { const inserted = (await db.insert(Users).values(data).returning())[0]; const user = await User.fromId(inserted.id); if (!user) { throw new Error("Failed to insert user"); } return user; } public static async resolve( reference: VersiaEntities.Reference, ): Promise { // Check if user not already in database if (reference.domain === config.http.base_url.hostname) { const user = await User.fromId(reference.id); if (!user) { throw new Error( "Failed to resolve user reference: User not found", ); } return user; } const instance = await Instance.resolve(reference.domain); const foundUser = await User.fromSql( and( eq(Users.instanceId, instance.id), eq(Users.remoteId, reference.id), ), ); if (foundUser) { return foundUser; } return User.fromVersia( new VersiaEntities.Reference(reference.id, instance.data.domain), ); } /** * Get the user's avatar in raw URL format * @returns The raw URL for the user's avatar */ public getAvatarUrl(): ProxiableUrl { if (!this.avatar) { return ( config.defaults.avatar || new ProxiableUrl( `https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}`, ) ); } return this.avatar?.getUrl(); } public static async register( username: string, options?: Partial<{ email: string; password: string; avatar: Media; isAdmin: boolean; }>, ): Promise { const user = await User.insert({ id: randomUUIDv7(), username, displayName: username, password: options?.password ? await bunPassword.hash(options.password) : null, email: options?.email, note: "", avatarId: options?.avatar?.id, isAdmin: options?.isAdmin, fields: [], updatedAt: new Date(), source: { language: "en", note: "", privacy: "public", sensitive: false, fields: [], } as z.infer, }); return user; } /** * Get the user's header in raw URL format * @returns The raw URL for the user's header */ public getHeaderUrl(): ProxiableUrl | null { if (!this.header) { return config.defaults.header ?? null; } return this.header.getUrl(); } public getAcct(): string { return this.local ? this.data.username : `${this.data.username}@${this.data.instance?.domain}`; } public static getAcct( isLocal: boolean, username: string, baseUrl?: string, ): string { return isLocal ? username : `${username}@${baseUrl}`; } public async update( newUser: Partial, ): Promise { await db.update(Users).set(newUser).where(eq(Users.id, this.id)); const updated = await User.fromId(this.data.id); if (!updated) { throw new Error("Failed to update user"); } // If something important is updated, federate it if ( this.local && (newUser.username || newUser.displayName || newUser.note || newUser.avatar || newUser.header || newUser.fields || newUser.isAdmin || newUser.isBot || newUser.isLocked || newUser.isDiscoverable || newUser.isIndexable) ) { await this.federateToFollowers(this.toVersia()); } return updated.data; } /** * Get all remote followers of the user * @returns The remote followers */ private getRemoteFollowers(): Promise { return User.manyFromSql( and( sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${this.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, isNotNull(Users.instanceId), ), ); } /** * Federates an entity to all followers of the user * * @param entity Entity to federate * @returns The followers that received the entity */ public async federateToFollowers(entity: KnownEntity): Promise { // Get followers const followers = await this.getRemoteFollowers(); await deliveryQueue.addBulk( followers.map((follower) => ({ name: DeliveryJobType.FederateEntity, data: { entity: entity.toJSON(), type: entity.data.type, recipientId: follower.id, senderId: this.id, }, })), ); return followers; } /** * Federates an entity to any user. * * @param entity Entity to federate * @param user User to federate to * @returns Whether the federation was successful */ public async federateToUser( entity: KnownEntity, user: User, ): Promise<{ ok: boolean }> { if (!user.data.instance) { throw new Error("Cannot federate to a local user"); } try { await Instance.federationRequester.postEntity( user.data.instance.domain, entity, ); } catch (e) { federationDeliveryLogger.error`Federating ${chalk.gray( entity.data.type, )} to ${user.uri} ${chalk.bold.red("failed")}`; federationDeliveryLogger.error`${e}`; return { ok: false }; } return { ok: true }; } public toApi(isOwnAccount = false): z.infer { const user = this.data; return { id: user.id, username: user.username, display_name: user.displayName || user.username, note: user.note, uri: this.uri.href, url: new URL( `/@${user.username}${ user.instanceId ? `@${user.instance?.domain}` : "" }`, config.http.base_url, ).href, avatar: this.getAvatarUrl().proxied, header: this.getHeaderUrl()?.proxied ?? "", locked: user.isLocked, created_at: user.createdAt.toISOString(), followers_count: user.isHidingCollections && !isOwnAccount ? 0 : user.followerCount, following_count: user.isHidingCollections && !isOwnAccount ? 0 : user.followingCount, statuses_count: user.statusCount, emojis: user.emojis.map((emoji) => new Emoji(emoji).toApi()), fields: user.fields.map((field) => ({ name: htmlToText(getBestContentType(field.key).content), value: getBestContentType(field.value).content, verified_at: null, })), bot: user.isBot, source: isOwnAccount ? (user.source ?? undefined) : undefined, // TODO: Add static avatar and header avatar_static: this.getAvatarUrl().proxied, header_static: this.getHeaderUrl()?.proxied ?? "", acct: this.getAcct(), // TODO: Add these fields limited: false, moved: null, noindex: !user.isIndexable, suspended: false, discoverable: user.isDiscoverable, mute_expires_at: null, roles: user.roles .map((role) => new Role(role)) .concat(Role.defaultRole) .concat(user.isAdmin ? Role.adminRole : []) .map((r) => r.toApi()), group: false, // TODO last_status_at: null, }; } public toVersia(): VersiaEntities.User { if (this.remote) { throw new Error("Cannot convert remote user to Versia format"); } const user = this.data; return new VersiaEntities.User( { id: user.id, type: "User", bio: { "text/html": { content: user.note, remote: false, }, "text/plain": { content: htmlToText(user.note), remote: false, }, }, created_at: user.createdAt.toISOString(), indexable: this.data.isIndexable, username: user.username, manually_approves_followers: this.data.isLocked, avatar: this.avatar?.toVersia().data as z.infer< typeof ImageContentFormatSchema >, header: this.header?.toVersia().data as z.infer< typeof ImageContentFormatSchema >, display_name: user.displayName, fields: user.fields, extensions: { "pub.versia:custom_emojis": { emojis: user.emojis.map((emoji) => new Emoji(emoji).toVersia(), ), }, }, }, this.data.instance?.domain ?? config.http.base_url.hostname, ); } public toMention(): z.infer { return { url: this.uri.href, username: this.data.username, acct: this.getAcct(), id: this.id, }; } }