mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-10 21:15:04 +10:00
223 lines
6.8 KiB
TypeScript
223 lines
6.8 KiB
TypeScript
import type { SQL } from "drizzle-orm";
|
|
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
import { BetterAuthError } from "better-auth";
|
|
import { and, eq, or, sql } from "drizzle-orm";
|
|
import { db } from "@reactive-resume/db/client";
|
|
import * as schema from "@reactive-resume/db/schema";
|
|
import { generateId, toUsername } from "@reactive-resume/utils/string";
|
|
|
|
interface ExistingOAuthUser {
|
|
id: string;
|
|
email: string;
|
|
emailVerified: boolean;
|
|
username: string;
|
|
displayUsername: string;
|
|
name: string;
|
|
image: string | null;
|
|
accountId?: string;
|
|
}
|
|
|
|
function lower<T extends AnyPgColumn>(column: T): SQL<T> {
|
|
return sql`lower(${column})`;
|
|
}
|
|
|
|
async function findExistingUserByEmail(email: string): Promise<ExistingOAuthUser | undefined> {
|
|
const normalizedEmail = email.trim().toLowerCase();
|
|
|
|
const [existingUser] = await db
|
|
.select({
|
|
id: schema.user.id,
|
|
email: schema.user.email,
|
|
emailVerified: schema.user.emailVerified,
|
|
username: schema.user.username,
|
|
displayUsername: schema.user.displayUsername,
|
|
name: schema.user.name,
|
|
image: schema.user.image,
|
|
})
|
|
.from(schema.user)
|
|
.where(eq(lower(schema.user.email), normalizedEmail))
|
|
.limit(1);
|
|
|
|
return existingUser;
|
|
}
|
|
|
|
async function findLegacyGithubUser(
|
|
profile: OAuthProfile,
|
|
context: OAuthMapperContext,
|
|
): Promise<ExistingOAuthUser | undefined> {
|
|
const login = profile.login;
|
|
if (!login) return;
|
|
|
|
const normalizedLogin = toUsername(login);
|
|
|
|
const [legacyAccount] = await db
|
|
.select({
|
|
id: schema.user.id,
|
|
accountId: schema.account.accountId,
|
|
email: schema.user.email,
|
|
emailVerified: schema.user.emailVerified,
|
|
username: schema.user.username,
|
|
displayUsername: schema.user.displayUsername,
|
|
name: schema.user.name,
|
|
image: schema.user.image,
|
|
})
|
|
.from(schema.account)
|
|
.innerJoin(schema.user, eq(schema.account.userId, schema.user.id))
|
|
.where(
|
|
and(
|
|
eq(schema.account.providerId, "github"),
|
|
eq(lower(schema.user.email), context.email),
|
|
or(
|
|
eq(lower(schema.user.username), normalizedLogin),
|
|
eq(schema.user.displayUsername, login),
|
|
eq(lower(schema.user.displayUsername), normalizedLogin),
|
|
),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (legacyAccount?.email.trim().toLowerCase() !== context.email) return;
|
|
|
|
return legacyAccount;
|
|
}
|
|
|
|
async function normalizeExistingUserEmail(userId: string, currentEmail: string, normalizedEmail: string) {
|
|
if (currentEmail === normalizedEmail) return;
|
|
|
|
await db.update(schema.user).set({ email: normalizedEmail }).where(eq(schema.user.id, userId));
|
|
}
|
|
|
|
function getEmailLocalPart(email: string): string {
|
|
return email.split("@", 1)[0] ?? "";
|
|
}
|
|
|
|
function appendUsernameSuffix(base: string, suffix: string): string {
|
|
const maxBaseLength = 64 - suffix.length;
|
|
return `${base.slice(0, maxBaseLength)}${suffix}`;
|
|
}
|
|
|
|
async function isUsernameTaken(candidate: string): Promise<boolean> {
|
|
const normalizedCandidate = candidate.trim().toLowerCase();
|
|
|
|
const [existingUser] = await db
|
|
.select({ id: schema.user.id })
|
|
.from(schema.user)
|
|
.where(
|
|
or(
|
|
eq(lower(schema.user.username), normalizedCandidate),
|
|
eq(lower(schema.user.displayUsername), normalizedCandidate),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return Boolean(existingUser);
|
|
}
|
|
|
|
async function allocateUniqueUsername(email: string, preferredUsername?: string | null): Promise<string> {
|
|
const emailLocalPart = getEmailLocalPart(email);
|
|
const preferred = preferredUsername ? toUsername(preferredUsername) : "";
|
|
const normalizedEmailLocalPart = toUsername(emailLocalPart);
|
|
const baseUsername = preferred || normalizedEmailLocalPart || "user";
|
|
|
|
if (!(await isUsernameTaken(baseUsername))) return baseUsername;
|
|
|
|
const suffixedUsername = await findAvailableUsernameSuffix(baseUsername);
|
|
if (suffixedUsername) return suffixedUsername;
|
|
|
|
return appendUsernameSuffix(baseUsername, `-${generateId().slice(0, 8).toLowerCase()}`);
|
|
}
|
|
|
|
async function findAvailableUsernameSuffix(baseUsername: string, index = 1): Promise<string | null> {
|
|
if (index > 999) return null;
|
|
|
|
const candidate = appendUsernameSuffix(baseUsername, `-${index}`);
|
|
if (!(await isUsernameTaken(candidate))) return candidate;
|
|
|
|
return findAvailableUsernameSuffix(baseUsername, index + 1);
|
|
}
|
|
|
|
interface OAuthProfile {
|
|
email?: string | null;
|
|
id?: string | number | null;
|
|
name?: string | null;
|
|
picture?: string | null;
|
|
image?: string | null;
|
|
avatar_url?: string | null;
|
|
login?: string | null;
|
|
preferred_username?: string | null;
|
|
}
|
|
|
|
interface OAuthMapperContext {
|
|
email: string;
|
|
emailLocalPart: string;
|
|
}
|
|
|
|
interface OAuthMapperOptions<TProfile extends OAuthProfile> {
|
|
providerName: string;
|
|
findExistingUser?: (profile: TProfile, context: OAuthMapperContext) => Promise<ExistingOAuthUser | undefined>;
|
|
getPreferredUsername?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
|
|
getName?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
|
|
getImage?: (profile: TProfile) => string | undefined | null;
|
|
}
|
|
|
|
export function createProfileMapper<TProfile extends OAuthProfile>({
|
|
providerName,
|
|
findExistingUser,
|
|
getPreferredUsername,
|
|
getName,
|
|
getImage,
|
|
}: OAuthMapperOptions<TProfile>) {
|
|
return async (profile: TProfile) => {
|
|
if (!profile.email) {
|
|
throw new BetterAuthError(
|
|
`${providerName} provider did not return an email address. This is required for user creation.`,
|
|
{ cause: "EMAIL_REQUIRED" },
|
|
);
|
|
}
|
|
|
|
const email = profile.email.trim().toLowerCase();
|
|
const emailLocalPart = getEmailLocalPart(email);
|
|
const context = { email, emailLocalPart };
|
|
const existingUser = (await findExistingUser?.(profile, context)) ?? (await findExistingUserByEmail(email));
|
|
const image = getImage?.(profile) ?? undefined;
|
|
|
|
if (existingUser) {
|
|
const existingEmail = existingUser.email.trim().toLowerCase();
|
|
await normalizeExistingUserEmail(existingUser.id, existingUser.email, existingEmail);
|
|
|
|
return {
|
|
...(existingUser.accountId ? { id: existingUser.accountId } : {}),
|
|
name: existingUser.name,
|
|
email: existingEmail,
|
|
image: image ?? existingUser.image,
|
|
username: existingUser.username,
|
|
displayUsername: existingUser.displayUsername,
|
|
emailVerified: existingUser.emailVerified,
|
|
};
|
|
}
|
|
|
|
const preferredUsername = getPreferredUsername?.(profile, context);
|
|
const username = await allocateUniqueUsername(email, preferredUsername);
|
|
const mappedName = getName?.(profile, context)?.trim();
|
|
|
|
return {
|
|
name: mappedName || username || emailLocalPart,
|
|
email,
|
|
image,
|
|
username,
|
|
displayUsername: username,
|
|
emailVerified: true,
|
|
};
|
|
};
|
|
}
|
|
|
|
export function createGithubProfileMapper() {
|
|
return createProfileMapper({
|
|
providerName: "GitHub",
|
|
findExistingUser: findLegacyGithubUser,
|
|
getPreferredUsername: (profile, context) => profile.login ?? context.emailLocalPart,
|
|
getName: (profile, context) => profile.name ?? profile.login ?? context.emailLocalPart,
|
|
getImage: (profile) => profile.avatar_url,
|
|
});
|
|
}
|