migrating

This commit is contained in:
Michael Freno
2025-12-17 00:23:13 -05:00
parent b3df3eedd2
commit 81969ae907
79 changed files with 4187 additions and 172 deletions

View File

@@ -3,6 +3,7 @@ import { authRouter } from "./routers/auth";
import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage";
import { miscRouter } from "./routers/misc";
import { userRouter } from "./routers/user";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
@@ -10,7 +11,8 @@ export const appRouter = createTRPCRouter({
auth: authRouter,
database: databaseRouter,
lineage: lineageRouter,
misc: miscRouter
misc: miscRouter,
user: userRouter
});
export type AppRouter = typeof appRouter;

View File

@@ -3,28 +3,51 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "~/env/server";
import { ConnectionFactory } from "~/server/utils";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie } from "vinxi/http";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user";
// Helper to create JWT token
async function createJWT(userId: string): Promise<string> {
async function createJWT(userId: string, expiresIn: string = "14d"): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("14d") // 14 days
.setExpirationTime(expiresIn)
.sign(secret);
return token;
}
// User type for database rows
interface User {
id: string;
email?: string;
display_name?: string;
provider?: string;
image?: string;
email_verified?: boolean;
// Helper to send email via Brevo/SendInBlue
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "no_reply@freno.me",
},
to: [{ email: to }],
htmlContent,
subject,
};
const response = await fetch(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json",
},
body: JSON.stringify(sendinblueData),
});
if (!response.ok) {
throw new Error("Failed to send email");
}
return response;
}
export const authRouter = createTRPCRouter({
@@ -323,4 +346,471 @@ export const authRouter = createTRPCRouter({
});
}
}),
// Email/password registration
emailRegistration: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch",
});
}
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
const userId = uuidV4();
try {
await conn.execute({
sql: "INSERT INTO User (id, email, password_hash, provider) VALUES (?, ?, ?, ?)",
args: [userId, email, passwordHash, "email"],
});
// Create JWT token
const token = await createJWT(userId);
// Set cookie
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
});
return { success: true, message: "success" };
} catch (e) {
console.error("Registration error:", e);
throw new TRPCError({
code: "BAD_REQUEST",
message: "duplicate",
});
}
}),
// Email/password login
emailPasswordLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string(),
rememberMe: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ? AND provider = ?",
args: [email, "email"],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
// Create JWT token with appropriate expiry
const expiresIn = rememberMe ? "14d" : "12h";
const token = await createJWT(user.id, expiresIn);
// Set cookie
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
};
if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
}
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
return { success: true, message: "success" };
}),
// Request email login link
requestEmailLinkLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
rememberMe: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, rememberMe } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailLoginLinkRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Create JWT token for email link (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email, rememberMe: rememberMe ?? false })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to log in</p>
</div>
<br/>
<div class="center">
<a href="${domain}/api/auth/email-login-callback?email=${email}&token=${token}&rememberMe=${rememberMe}" class="button">Log In</a>
</div>
<div class="center">
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
</div>
</body>
</html>`;
await sendEmail(email, "freno.me login link", htmlContent);
// Set rate limit cookie (2 minutes)
const exp = new Date(Date.now() + 2 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "emailLoginLinkRequested", exp.toUTCString(), {
maxAge: 2 * 60,
path: "/",
});
return { success: true, message: "email sent" };
}),
// Request password reset
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "passwordResetRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
// Don't reveal if user exists
return { success: true, message: "email sent" };
}
const user = res.rows[0] as unknown as User;
// Create JWT token with user ID (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: user.id })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to reset password</p>
</div>
<br/>
<div class="center">
<a href="${domain}/login/password-reset?token=${token}" class="button">Reset Password</a>
</div>
<div class="center">
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
</div>
</body>
</html>`;
await sendEmail(email, "password reset", htmlContent);
// Set rate limit cookie (5 minutes)
const exp = new Date(Date.now() + 5 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "passwordResetRequested", exp.toUTCString(), {
maxAge: 5 * 60,
path: "/",
});
return { success: true, message: "email sent" };
}),
// Reset password with token
resetPassword: publicProcedure
.input(
z.object({
token: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const { token, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
try {
// Verify JWT token
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
if (!payload.id || typeof payload.id !== "string") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "bad token",
});
}
const conn = ConnectionFactory();
const passwordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, payload.id],
});
// Clear any session cookies
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
console.error("Password reset error:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "token expired",
});
}
}),
// Resend email verification
resendEmailVerification: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailVerificationRequested");
if (requested) {
const time = parseInt(requested);
const currentTime = Date.now();
const difference = (currentTime - time) / (1000 * 60);
if (difference < 15) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Please wait before requesting another verification email",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Create JWT token (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to verify email</p>
</div>
<br/>
<div class="center">
<a href="${domain}/api/auth/email-verification-callback?email=${email}&token=${token}" class="button">Verify Email</a>
</div>
</body>
</html>`;
await sendEmail(email, "freno.me email verification", htmlContent);
// Set rate limit cookie
setCookie(ctx.event.nativeEvent, "emailVerificationRequested", Date.now().toString(), {
maxAge: 15 * 60,
path: "/",
});
return { success: true, message: "Verification email sent" };
}),
// Sign out
signOut: publicProcedure.mutation(async ({ ctx }) => {
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
return { success: true };
}),
});

View File

@@ -0,0 +1,357 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { env } from "~/env/server";
import { ConnectionFactory, getUserID, hashPassword, checkPassword } from "~/server/utils";
import { setCookie } from "vinxi/http";
import type { User } from "~/types/user";
import { toUserProfile } from "~/types/user";
export const userRouter = createTRPCRouter({
// Get current user profile
getProfile: publicProcedure.query(async ({ ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Update email
updateEmail: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { email } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET email = ?, email_verified = ? WHERE id = ?",
args: [email, 0, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
// Set email cookie for verification flow
setCookie(ctx.event.nativeEvent, "emailToken", email, {
path: "/",
});
return toUserProfile(user);
}),
// Update display name
updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string().min(1).max(50) }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { displayName } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET display_name = ? WHERE id = ?",
args: [displayName, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Update profile image
updateProfileImage: publicProcedure
.input(z.object({ imageUrl: z.string().url() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { imageUrl } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET image = ? WHERE id = ?",
args: [imageUrl, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Change password (requires old password)
changePassword: publicProcedure
.input(
z.object({
oldPassword: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { oldPassword, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No password set",
});
}
const passwordMatch = await checkPassword(oldPassword, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Password did not match record",
});
}
// Update password
const newPasswordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [newPasswordHash, userId],
});
// Clear session cookies (force re-login)
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
}),
// Set password (for OAuth users who don't have password)
setPassword: publicProcedure
.input(
z.object({
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password exists",
});
}
// Set password
const passwordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, userId],
});
// Clear session cookies (force re-login)
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
}),
// Delete account (anonymize data)
deleteAccount: publicProcedure
.input(z.object({ password: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { password } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password required",
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Password Did Not Match",
});
}
// Anonymize user data (don't hard delete)
await conn.execute({
sql: `UPDATE User SET
email = ?,
email_verified = ?,
password_hash = ?,
display_name = ?,
provider = ?,
image = ?
WHERE id = ?`,
args: [null, 0, null, "user deleted", null, null, userId],
});
// Clear session cookies
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "deleted" };
}),
});