import { getBestContentType, urlToContentFormat } from "@content_types"; import { dualLogger } from "@loggers"; import { addUserToMeilisearch } from "@meilisearch"; import { type Config, config } from "config-manager"; import { type InferSelectModel, and, eq, sql } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import type * as Lysand from "lysand-types"; import { db } from "~drizzle/db"; import { Applications, EmojiToUser, Instances, Notifications, Relationships, Tokens, Users, } from "~drizzle/schema"; import { LogLevel } from "~packages/log-manager"; import type { Account as APIAccount } from "~types/mastodon/account"; import type { Mention as APIMention } from "~types/mastodon/mention"; import type { Source as APISource } from "~types/mastodon/source"; import type { Application } from "./Application"; import { type EmojiWithInstance, emojiToAPI, emojiToLysand, fetchEmoji, } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; import { addInstanceIfNotExists } from "./Instance"; import { createNewRelationship } from "./Relationship"; import type { Token } from "./Token"; export type User = InferSelectModel; export type UserWithInstance = User & { instance: InferSelectModel | null; }; export type UserWithRelations = User & { instance: InferSelectModel | null; emojis: EmojiWithInstance[]; followerCount: number; followingCount: number; statusCount: number; }; export type UserWithRelationsAndRelationships = UserWithRelations & { relationships: InferSelectModel[]; relationshipSubjects: InferSelectModel[]; }; export const userRelations: { instance: true; emojis: { with: { emoji: { with: { instance: true; }; }; }; }; } = { instance: true, emojis: { with: { emoji: { with: { instance: true, }, }, }, }, }; export const userExtras = { followerCount: sql`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "Users".id AND "relationships"."following" = true))`.as( "follower_count", ), followingCount: sql`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "Users".id AND "relationshipSubjects"."following" = true))`.as( "following_count", ), statusCount: sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "Users".id)`.as( "status_count", ), }; export const userExtrasTemplate = (name: string) => ({ // @ts-ignore followerCount: sql([ `(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "${name}".id AND "relationships"."following" = true))`, ]).as("follower_count"), // @ts-ignore followingCount: sql([ `(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "${name}".id AND "relationshipSubjects"."following" = true))`, ]).as("following_count"), // @ts-ignore statusCount: sql([ `(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "${name}".id)`, ]).as("status_count"), }); export interface AuthData { user: UserWithRelations | null; token: string; application: Application | null; } /** * Get the user's avatar in raw URL format * @param config The config to use * @returns The raw URL for the user's avatar */ export const getAvatarUrl = (user: User, config: Config) => { if (!user.avatar) return ( config.defaults.avatar || `https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${user.username}` ); return user.avatar; }; /** * Get the user's header in raw URL format * @param config The config to use * @returns The raw URL for the user's header */ export const getHeaderUrl = (user: User, config: Config) => { if (!user.header) return config.defaults.header; return user.header; }; export const getFromRequest = async (req: Request): Promise => { // Check auth token const token = req.headers.get("Authorization")?.split(" ")[1] || ""; const { user, application } = await retrieveUserAndApplicationFromToken(token); return { user, token, application }; }; export const followRequestUser = async ( follower: User, followee: User, relationshipId: string, reblogs = false, notify = false, languages: string[] = [], ): Promise> => { const isRemote = followee.instanceId !== null; const updatedRelationship = ( await db .update(Relationships) .set({ following: isRemote ? false : !followee.isLocked, requested: isRemote ? true : followee.isLocked, showingReblogs: reblogs, notifying: notify, languages: languages, }) .where(eq(Relationships.id, relationshipId)) .returning() )[0]; if (isRemote) { // Federate // TODO: Make database job const request = await objectToInboxRequest( followRequestToLysand(follower, followee), follower, followee, ); // Send request const response = await fetch(request); if (!response.ok) { dualLogger.log( LogLevel.DEBUG, "Federation.FollowRequest", await response.text(), ); dualLogger.log( LogLevel.ERROR, "Federation.FollowRequest", `Failed to federate follow request from ${follower.id} to ${followee.uri}`, ); return ( await db .update(Relationships) .set({ following: false, requested: false, }) .where(eq(Relationships.id, relationshipId)) .returning() )[0]; } } else { await db.insert(Notifications).values({ accountId: follower.id, type: followee.isLocked ? "follow_request" : "follow", notifiedId: followee.id, }); } return updatedRelationship; }; export const sendFollowAccept = async (follower: User, followee: User) => { // TODO: Make database job const request = await objectToInboxRequest( followAcceptToLysand(follower, followee), followee, follower, ); // Send request const response = await fetch(request); if (!response.ok) { dualLogger.log( LogLevel.DEBUG, "Federation.FollowAccept", await response.text(), ); dualLogger.log( LogLevel.ERROR, "Federation.FollowAccept", `Failed to federate follow accept from ${followee.id} to ${follower.uri}`, ); } }; export const sendFollowReject = async (follower: User, followee: User) => { // TODO: Make database job const request = await objectToInboxRequest( followRejectToLysand(follower, followee), followee, follower, ); // Send request const response = await fetch(request); if (!response.ok) { dualLogger.log( LogLevel.DEBUG, "Federation.FollowReject", await response.text(), ); dualLogger.log( LogLevel.ERROR, "Federation.FollowReject", `Failed to federate follow reject from ${followee.id} to ${follower.uri}`, ); } }; export const transformOutputToUserWithRelations = ( user: Omit & { followerCount: unknown; followingCount: unknown; statusCount: unknown; emojis: { userId: string; emojiId: string; emoji?: EmojiWithInstance; }[]; instance: InferSelectModel | null; endpoints: unknown; }, ): UserWithRelations => { return { ...user, followerCount: Number(user.followerCount), followingCount: Number(user.followingCount), statusCount: Number(user.statusCount), endpoints: user.endpoints ?? ({} as Partial<{ dislikes: string; featured: string; likes: string; followers: string; following: string; inbox: string; outbox: string; }>), emojis: user.emojis.map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), }; }; export const findManyUsers = async ( query: Parameters[0], ): Promise => { const output = await db.query.Users.findMany({ ...query, with: { ...userRelations, ...query?.with, }, extras: { ...userExtras, ...query?.extras, }, }); return output.map((user) => transformOutputToUserWithRelations(user)); }; export const findFirstUser = async ( query: Parameters[0], ): Promise => { const output = await db.query.Users.findFirst({ ...query, with: { ...userRelations, ...query?.with, }, extras: { ...userExtras, ...query?.extras, }, }); if (!output) return null; return transformOutputToUserWithRelations(output); }; export const resolveUser = async ( uri: string, ): Promise => { // Check if user not already in database const foundUser = await findFirstUser({ where: (user, { eq }) => eq(user.uri, uri), }); if (foundUser) return foundUser; // Check if URI is of a local user if (uri.startsWith(config.http.base_url)) { const uuid = uri.match( /[0-9A-F]{8}-[0-9A-F]{4}-[7][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, ); if (!uuid) { throw new Error( `URI ${uri} is of a local user, but it could not be parsed`, ); } const foundLocalUser = await findFirstUser({ where: (user, { eq }) => eq(user.id, uuid[0]), }); return foundLocalUser || null; } if (!URL.canParse(uri)) { throw new Error(`Invalid URI to parse ${uri}`); } const response = await fetch(uri, { method: "GET", headers: { Accept: "application/json", }, }); const data = (await response.json()) as Partial; if ( !( data.id && data.username && data.uri && data.created_at && data.dislikes && data.featured && data.likes && data.followers && data.following && data.inbox && data.outbox && data.public_key ) ) { throw new Error("Invalid user data"); } // Parse emojis and add them to database const userEmojis = data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; const instance = await addInstanceIfNotExists(data.uri); const emojis = []; for (const emoji of userEmojis) { emojis.push(await fetchEmoji(emoji)); } const newUser = ( await db .insert(Users) .values({ username: data.username, uri: data.uri, createdAt: new Date(data.created_at).toISOString(), endpoints: { dislikes: data.dislikes, featured: data.featured, likes: data.likes, followers: data.followers, following: data.following, inbox: data.inbox, outbox: data.outbox, }, updatedAt: new Date(data.created_at).toISOString(), instanceId: instance.id, avatar: data.avatar ? Object.entries(data.avatar)[0][1].content : "", header: data.header ? Object.entries(data.header)[0][1].content : "", displayName: data.display_name ?? "", note: getBestContentType(data.bio).content, publicKey: data.public_key.public_key, source: { language: null, note: "", privacy: "public", sensitive: false, fields: [], }, }) .returning() )[0]; // Add emojis to user if (emojis.length > 0) { await db.insert(EmojiToUser).values( emojis.map((emoji) => ({ emojiId: emoji.id, userId: newUser.id, })), ); } const finalUser = await findFirstUser({ where: (user, { eq }) => eq(user.id, newUser.id), }); if (!finalUser) return null; // Add to Meilisearch await addUserToMeilisearch(finalUser); return finalUser; }; export const getUserUri = (user: User) => { return ( user.uri || new URL(`/users/${user.id}`, config.http.base_url).toString() ); }; /** * Resolves a WebFinger identifier to a user. * @param identifier Either a UUID or a username */ export const resolveWebFinger = async ( identifier: string, host: string, ): Promise => { // Check if user not already in database const foundUser = await db .select() .from(Users) .innerJoin(Instances, eq(Users.instanceId, Instances.id)) .where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host))) .limit(1); if (foundUser[0]) return ( (await findFirstUser({ where: (user, { eq }) => eq(user.id, foundUser[0].Users.id), })) || null ); const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`; const response = await fetch( new URL( `/.well-known/webfinger?${new URLSearchParams({ resource: `acct:${identifier}@${host}`, })}`, hostWithProtocol, ), { method: "GET", headers: { Accept: "application/json", }, }, ); if (response.status === 404) { return null; } const data = (await response.json()) as { subject: string; links: { rel: string; type: string; href: string; }[]; }; if (!data.subject || !data.links) { throw new Error( "Invalid WebFinger data (missing subject or links from response)", ); } const relevantLink = data.links.find((link) => link.rel === "self"); if (!relevantLink) { throw new Error( "Invalid WebFinger data (missing link with rel: 'self')", ); } return resolveUser(relevantLink.href); }; /** * Fetches the list of followers associated with the actor and updates the user's followers */ export const fetchFollowers = () => { // }; /** * Creates a new LOCAL user. * @param data The data for the new user. * @returns The newly created user. */ export const createNewLocalUser = async (data: { username: string; display_name?: string; password: string; email: string; bio?: string; avatar?: string; header?: string; admin?: boolean; skipPasswordHash?: boolean; }): Promise => { const keys = await generateUserKeys(); const newUser = ( await db .insert(Users) .values({ username: data.username, displayName: data.display_name ?? data.username, password: data.skipPasswordHash ? data.password : await Bun.password.hash(data.password), email: data.email, note: data.bio ?? "", avatar: data.avatar ?? config.defaults.avatar, header: data.header ?? config.defaults.avatar, isAdmin: data.admin ?? false, publicKey: keys.public_key, privateKey: keys.private_key, updatedAt: new Date().toISOString(), source: { language: null, note: "", privacy: "public", sensitive: false, fields: [], }, }) .returning() )[0]; const finalUser = await findFirstUser({ where: (user, { eq }) => eq(user.id, newUser.id), }); if (!finalUser) return null; // Add to Meilisearch await addUserToMeilisearch(finalUser); return finalUser; }; /** * Parses mentions from a list of URIs */ export const parseMentionsUris = async ( mentions: string[], ): Promise => { return await findManyUsers({ where: (user, { inArray }) => inArray(user.uri, mentions), with: userRelations, }); }; /** * Retrieves a user from a token. * @param access_token The access token to retrieve the user from. * @returns The user associated with the given access token. */ export const retrieveUserFromToken = async ( access_token: string, ): Promise => { if (!access_token) return null; const token = await retrieveToken(access_token); if (!token || !token.userId) return null; const user = await findFirstUser({ where: (user, { eq }) => eq(user.id, token.userId ?? ""), }); return user; }; export const retrieveUserAndApplicationFromToken = async ( access_token: string, ): Promise<{ user: UserWithRelations | null; application: Application | null; }> => { if (!access_token) return { user: null, application: null }; const output = ( await db .select({ token: Tokens, application: Applications, }) .from(Tokens) .leftJoin(Applications, eq(Tokens.applicationId, Applications.id)) .where(eq(Tokens.accessToken, access_token)) .limit(1) )[0]; if (!output?.token.userId) return { user: null, application: null }; const user = await findFirstUser({ where: (user, { eq }) => eq(user.id, output.token.userId ?? ""), }); return { user, application: output.application ?? null }; }; export const retrieveToken = async ( access_token: string, ): Promise => { if (!access_token) return null; return ( (await db.query.Tokens.findFirst({ where: (tokens, { eq }) => eq(tokens.accessToken, access_token), })) ?? null ); }; /** * Gets the relationship to another user. * @param other The other user to get the relationship to. * @returns The relationship to the other user. */ export const getRelationshipToOtherUser = async ( user: UserWithRelations, other: User, ): Promise> => { const foundRelationship = await db.query.Relationships.findFirst({ where: (relationship, { and, eq }) => and( eq(relationship.ownerId, user.id), eq(relationship.subjectId, other.id), ), }); if (!foundRelationship) { // Create new relationship const newRelationship = await createNewRelationship(user, other); return newRelationship; } return foundRelationship; }; /** * Generates keys for the user. */ export const generateUserKeys = async () => { const keys = await crypto.subtle.generateKey("Ed25519", true, [ "sign", "verify", ]); const privateKey = Buffer.from( await crypto.subtle.exportKey("pkcs8", keys.privateKey), ).toString("base64"); const publicKey = Buffer.from( await crypto.subtle.exportKey("spki", keys.publicKey), ).toString("base64"); // Add header, footer and newlines later on // These keys are base64 encrypted return { private_key: privateKey, public_key: publicKey, }; }; export const userToMention = (user: UserWithInstance): APIMention => ({ url: getUserUri(user), username: user.username, acct: user.instance === null ? user.username : `${user.username}@${user.instance.baseUrl}`, id: user.id, }); export const userToAPI = ( userToConvert: UserWithRelations, isOwnAccount = false, ): APIAccount => { return { id: userToConvert.id, username: userToConvert.username, display_name: userToConvert.displayName, note: userToConvert.note, url: userToConvert.uri || new URL( `/@${userToConvert.username}`, config.http.base_url, ).toString(), avatar: getAvatarUrl(userToConvert, config), header: getHeaderUrl(userToConvert, config), locked: userToConvert.isLocked, created_at: new Date(userToConvert.createdAt).toISOString(), followers_count: userToConvert.followerCount, following_count: userToConvert.followingCount, statuses_count: userToConvert.statusCount, emojis: userToConvert.emojis.map((emoji) => emojiToAPI(emoji)), // TODO: Add fields fields: [], bot: userToConvert.isBot, source: isOwnAccount && userToConvert.source ? (userToConvert.source as APISource) : undefined, // TODO: Add static avatar and header avatar_static: getAvatarUrl(userToConvert, config), header_static: getHeaderUrl(userToConvert, config), acct: userToConvert.instance === null ? userToConvert.username : `${userToConvert.username}@${userToConvert.instance.baseUrl}`, // TODO: Add these fields limited: false, moved: null, noindex: false, suspended: false, discoverable: undefined, mute_expires_at: undefined, group: false, // @ts-expect-error Pleroma extension pleroma: { is_admin: userToConvert.isAdmin, is_moderator: userToConvert.isAdmin, }, }; }; /** * Should only return local users */ export const userToLysand = (user: UserWithRelations): Lysand.User => { if (user.instanceId !== null) { throw new Error("Cannot convert remote user to Lysand format"); } return { id: user.id, type: "User", uri: getUserUri(user), bio: { "text/html": { content: user.note, }, "text/plain": { content: htmlToText(user.note), }, }, created_at: new Date(user.createdAt).toISOString(), dislikes: new URL( `/users/${user.id}/dislikes`, config.http.base_url, ).toString(), featured: new URL( `/users/${user.id}/featured`, config.http.base_url, ).toString(), likes: new URL( `/users/${user.id}/likes`, config.http.base_url, ).toString(), followers: new URL( `/users/${user.id}/followers`, config.http.base_url, ).toString(), following: new URL( `/users/${user.id}/following`, config.http.base_url, ).toString(), inbox: new URL( `/users/${user.id}/inbox`, config.http.base_url, ).toString(), outbox: new URL( `/users/${user.id}/outbox`, config.http.base_url, ).toString(), indexable: false, username: user.username, avatar: urlToContentFormat(getAvatarUrl(user, config)) ?? undefined, header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined, display_name: user.displayName, fields: (user.source as APISource).fields.map((field) => ({ key: { "text/html": { content: field.name, }, "text/plain": { content: htmlToText(field.name), }, }, value: { "text/html": { content: field.value, }, "text/plain": { content: htmlToText(field.value), }, }, })), public_key: { actor: new URL( `/users/${user.id}`, config.http.base_url, ).toString(), public_key: user.publicKey, }, extensions: { "org.lysand:custom_emojis": { emojis: user.emojis.map((emoji) => emojiToLysand(emoji)), }, }, }; }; export const followRequestToLysand = ( follower: User, followee: User, ): Lysand.Follow => { if (follower.instanceId) { throw new Error("Follower must be a local user"); } if (!followee.instanceId) { throw new Error("Followee must be a remote user"); } if (!followee.uri) { throw new Error("Followee must have a URI in database"); } const id = crypto.randomUUID(); return { type: "Follow", id: id, author: getUserUri(follower), followee: followee.uri, created_at: new Date().toISOString(), uri: new URL(`/follows/${id}`, config.http.base_url).toString(), }; }; export const followAcceptToLysand = ( follower: User, followee: User, ): Lysand.FollowAccept => { if (!follower.instanceId) { throw new Error("Follower must be a remote user"); } if (followee.instanceId) { throw new Error("Followee must be a local user"); } if (!follower.uri) { throw new Error("Follower must have a URI in database"); } const id = crypto.randomUUID(); return { type: "FollowAccept", id: id, author: getUserUri(followee), created_at: new Date().toISOString(), follower: follower.uri, uri: new URL(`/follows/${id}`, config.http.base_url).toString(), }; }; export const followRejectToLysand = ( follower: User, followee: User, ): Lysand.FollowReject => { return { ...followAcceptToLysand(follower, followee), type: "FollowReject", }; };