From 324141441bccb2236b1f3e1dcf375c220d8bf055 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 19 Dec 2025 11:48:00 -0500 Subject: [PATCH] better initial load --- package.json | 1 + src/app.tsx | 15 +- src/components/Bars.tsx | 61 +++++- src/components/RecentCommits.tsx | 27 ++- src/entry-server.tsx | 3 +- src/lib/comment-utils.ts | 25 +-- src/lib/date-utils.ts | 18 ++ src/lib/validation.ts | 53 +---- src/routes/account.tsx | 26 ++- src/routes/index.tsx | 35 ++- src/routes/login/index.tsx | 66 ++++-- src/server/auth.ts | 113 ++++++++++ src/server/database.ts | 169 ++++++++++++++ src/server/email.ts | 95 ++++++++ src/server/password.ts | 16 ++ src/server/utils.ts | 363 ++----------------------------- src/utils.ts | 12 - 17 files changed, 611 insertions(+), 487 deletions(-) create mode 100644 src/lib/date-utils.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/database.ts create mode 100644 src/server/email.ts create mode 100644 src/server/password.ts delete mode 100644 src/utils.ts diff --git a/package.json b/package.json index a0628a7..9b07843 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "type": "module", "scripts": { "dev": "vinxi dev", + "dev-flush": "vinxi dev --env-file=.env", "build": "vinxi build", "start": "vinxi start" }, diff --git a/src/app.tsx b/src/app.tsx index 9925990..167e33e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -161,14 +161,19 @@ function AppLayout(props: { children: any }) { return ( <> - {/* Fullscreen loading splash until bars are initialized */} -
+
-
+
- }>{props.children} + }> + }>{props.children} +
diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index b84d39d..54cf0a6 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -7,7 +7,8 @@ import { createResource, Show, For, - Suspense + Suspense, + createMemo } from "solid-js"; import { api } from "~/lib/api"; import { TerminalSplash } from "./TerminalSplash"; @@ -56,7 +57,7 @@ export function RightBarContent() { }); return ( -
+
  • @@ -112,7 +113,7 @@ export function RightBarContent() { {/* Git Activity Section */} }> -
    +
    - (null); + + const [isMounted, setIsMounted] = createSignal(false); + onMount(async () => { + // Mark as mounted to avoid hydration mismatch + setIsMounted(true); + // Fetch recent posts only on client side to avoid hydration mismatch try { const posts = await api.blog.getRecentPosts.query(); @@ -162,6 +170,30 @@ export function LeftBar() { setRecentPosts([]); } + // Fetch user info client-side only to avoid hydration mismatch + try { + const response = await fetch("/api/trpc/user.getProfile", { + method: "GET" + }); + + if (response.ok) { + const result = await response.json(); + if (result.result?.data) { + setUserInfo({ + email: result.result.data.email, + isAuthenticated: true + }); + } else { + setUserInfo({ email: null, isAuthenticated: false }); + } + } else { + setUserInfo({ email: null, isAuthenticated: false }); + } + } catch (error) { + console.error("Failed to fetch user info:", error); + setUserInfo({ email: null, isAuthenticated: false }); + } + if (ref) { const updateSize = () => { actualWidth = ref?.offsetWidth || 0; @@ -323,7 +355,7 @@ export function LeftBar() {
    Recent Posts -
    +
    }> {(post) => ( @@ -369,7 +401,20 @@ export function LeftBar() { Blog
  • - Login + Login} + > + + Account + + + {" "} + ({userInfo()!.email}) + + + +
diff --git a/src/components/RecentCommits.tsx b/src/components/RecentCommits.tsx index 2d838f5..c36142f 100644 --- a/src/components/RecentCommits.tsx +++ b/src/components/RecentCommits.tsx @@ -1,4 +1,5 @@ import { Component, For, Show } from "solid-js"; +import { Typewriter } from "./Typewriter"; interface Commit { sha: string; @@ -54,26 +55,30 @@ export const RecentCommits: Component<{ href={commit.url} target="_blank" rel="noreferrer" - class="hover:bg-surface0 group rounded-md p-2 transition-all duration-200 ease-in-out hover:scale-[1.02]" + class="hover:bg-surface0 group block rounded-md p-2 transition-all duration-200 ease-in-out hover:scale-[1.02]" > -
-
- + +
+ {commit.message} - - {formatDate(commit.date)} -
-
- + + {formatDate(commit.date)} + +
+ {commit.sha} - + {commit.repo}
-
+
)} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index fb9b651..1f76c0e 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -3,8 +3,7 @@ import { createHandler, StartServer } from "@solidjs/start/server"; import { validateServerEnv } from "./env/server"; try { - const validatedEnv = validateServerEnv(process.env); - console.log("Environment validation successful"); + validateServerEnv(process.env); } catch (error) { console.error("Environment validation failed:", error); } diff --git a/src/lib/comment-utils.ts b/src/lib/comment-utils.ts index 368895a..8dc7118 100644 --- a/src/lib/comment-utils.ts +++ b/src/lib/comment-utils.ts @@ -2,36 +2,15 @@ * Comment System Utility Functions * * Shared utility functions for: - * - Date formatting * - Comment sorting algorithms * - Comment filtering and tree building * - Debouncing */ import type { Comment, CommentReaction, SortingMode } from "~/types/comment"; +import { getSQLFormattedDate } from "./date-utils"; -// ============================================================================ -// Date Utilities -// ============================================================================ - -/** - * Formats current date to match SQL datetime format - * Note: Adds 4 hours to match server timezone (EST) - * Returns format: YYYY-MM-DD HH:MM:SS - */ -export function getSQLFormattedDate(): string { - const date = new Date(); - date.setHours(date.getHours() + 4); - - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - const hours = `${date.getHours()}`.padStart(2, "0"); - const minutes = `${date.getMinutes()}`.padStart(2, "0"); - const seconds = `${date.getSeconds()}`.padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} +export { getSQLFormattedDate }; // ============================================================================ // Comment Tree Utilities diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts new file mode 100644 index 0000000..5abbdc6 --- /dev/null +++ b/src/lib/date-utils.ts @@ -0,0 +1,18 @@ +/** + * Formats current date to match SQL datetime format + * Note: Adds 4 hours to match server timezone (EST) + * Returns format: YYYY-MM-DD HH:MM:SS + */ +export function getSQLFormattedDate(): string { + const date = new Date(); + date.setHours(date.getHours() + 4); + + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + const hours = `${date.getHours()}`.padStart(2, "0"); + const minutes = `${date.getMinutes()}`.padStart(2, "0"); + const seconds = `${date.getSeconds()}`.padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index fe9fcd6..8338f41 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -18,11 +18,11 @@ export function validatePassword(password: string): { errors: string[]; } { const errors: string[] = []; - + if (password.length < 8) { errors.push("Password must be at least 8 characters long"); } - + // Optional: Add more password requirements // if (!/[A-Z]/.test(password)) { // errors.push("Password must contain at least one uppercase letter"); @@ -33,17 +33,20 @@ export function validatePassword(password: string): { // if (!/[0-9]/.test(password)) { // errors.push("Password must contain at least one number"); // } - + return { isValid: errors.length === 0, - errors, + errors }; } /** * Check if two passwords match */ -export function passwordsMatch(password: string, confirmation: string): boolean { +export function passwordsMatch( + password: string, + confirmation: string +): boolean { return password === confirmation && password.length > 0; } @@ -53,43 +56,3 @@ export function passwordsMatch(password: string, confirmation: string): boolean export function isValidDisplayName(name: string): boolean { return name.trim().length >= 1 && name.trim().length <= 50; } - -/** - * Sanitize user input (basic XSS prevention) - */ -export function sanitizeInput(input: string): string { - return input - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\//g, "/"); -} - -/** - * Check if string is a valid URL - */ -export function isValidUrl(url: string): boolean { - try { - new URL(url); - return true; - } catch { - return false; - } -} - -/** - * Validate file type for uploads - */ -export function isValidImageType(file: File): boolean { - const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; - return validTypes.includes(file.type); -} - -/** - * Validate file size (in bytes) - */ -export function isValidFileSize(file: File, maxSizeMB: number = 5): boolean { - const maxBytes = maxSizeMB * 1024 * 1024; - return file.size <= maxBytes; -} diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 75f3e0d..593218d 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -1,8 +1,10 @@ import { createSignal, createEffect, Show, onMount } from "solid-js"; -import { useNavigate } from "@solidjs/router"; +import { useNavigate, cache, redirect } from "@solidjs/router"; +import { getEvent } from "vinxi/http"; import Eye from "~/components/icons/Eye"; import EyeSlash from "~/components/icons/EyeSlash"; import { validatePassword, isValidEmail } from "~/lib/validation"; +import { checkAuthStatus } from "~/server/utils"; type UserProfile = { id: string; @@ -14,6 +16,22 @@ type UserProfile = { hasPassword: boolean; }; +const checkAuth = cache(async () => { + "use server"; + const event = getEvent()!; + const { isAuthenticated } = await checkAuthStatus(event); + + if (!isAuthenticated) { + throw redirect("/login"); + } + + return { isAuthenticated }; +}, "accountAuthCheck"); + +export const route = { + load: () => checkAuth() +}; + export default function AccountPage() { const navigate = useNavigate(); @@ -72,16 +90,10 @@ export default function AccountPage() { const result = await response.json(); if (result.result?.data) { setUser(result.result.data); - } else { - // Not logged in, redirect to login - navigate("/login"); } - } else { - navigate("/login"); } } catch (err) { console.error("Failed to fetch user profile:", err); - navigate("/login"); } finally { setLoading(false); } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ce66465..21cd9dc 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,12 +2,33 @@ import { Typewriter } from "~/components/Typewriter"; export default function Home() { return ( - -
- {/* fill in a ipsum lorem */} - ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem - ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem -
-
+
+ +
Hey!
+
+ + +
+ My name is Mike Freno, I'm a{" "} + Software Engineer based in{" "} + Brooklyn, NY +
+
+ + I'm a passionate dev tooling and game developer, recently been working + in the world of Love2D and you can see some of my work here: + I'm a huge lover of open source software, and + + +
My Collection of By-the-ways:
+
+ +
    +
  • I use Neovim
  • +
  • I use Arch Linux
  • +
  • I use Rust
  • +
+
+
); } diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index c4ff221..e5e50aa 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -1,5 +1,12 @@ import { createSignal, createEffect, onCleanup, Show } from "solid-js"; -import { A, useNavigate, useSearchParams } from "@solidjs/router"; +import { + A, + useNavigate, + useSearchParams, + cache, + redirect +} from "@solidjs/router"; +import { getEvent } from "vinxi/http"; import GoogleLogo from "~/components/icons/GoogleLogo"; import GitHub from "~/components/icons/GitHub"; import Eye from "~/components/icons/Eye"; @@ -7,6 +14,23 @@ import EyeSlash from "~/components/icons/EyeSlash"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import { isValidEmail, validatePassword } from "~/lib/validation"; import { getClientCookie } from "~/lib/cookies.client"; +import { checkAuthStatus } from "~/server/utils"; + +const checkAuth = cache(async () => { + "use server"; + const event = getEvent()!; + const { isAuthenticated } = await checkAuthStatus(event); + + if (isAuthenticated) { + throw redirect("/account"); + } + + return { isAuthenticated }; +}, "loginAuthCheck"); + +export const route = { + load: () => checkAuth() +}; export default function LoginPage() { const navigate = useNavigate(); @@ -64,7 +88,7 @@ export default function LoginPage() { if (timer) { timerInterval = setInterval( () => calcRemainder(timer), - 1000, + 1000 ) as unknown as number; onCleanup(() => { if (timerInterval) { @@ -84,7 +108,7 @@ export default function LoginPage() { server_error: "Server error - please try again later", missing_params: "Invalid login link - missing parameters", link_expired: "Login link has expired - please request a new one", - access_denied: "Access denied - you cancelled the login", + access_denied: "Access denied - you cancelled the login" }; setError(errorMessages[errorParam] || "An error occurred during login"); } @@ -138,8 +162,8 @@ export default function LoginPage() { body: JSON.stringify({ email, password, - passwordConfirmation: passwordConf, - }), + passwordConfirmation: passwordConf + }) }); const result = await response.json(); @@ -175,7 +199,7 @@ export default function LoginPage() { const response = await fetch("/api/trpc/auth.emailPasswordLogin", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, rememberMe }), + body: JSON.stringify({ email, password, rememberMe }) }); const result = await response.json(); @@ -209,7 +233,7 @@ export default function LoginPage() { const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, rememberMe }), + body: JSON.stringify({ email, rememberMe }) }); const result = await response.json(); @@ -223,7 +247,7 @@ export default function LoginPage() { } timerInterval = setInterval( () => calcRemainder(timer), - 1000, + 1000 ) as unknown as number; } } else { @@ -310,7 +334,7 @@ export default function LoginPage() { {/* Main content */}
{/* Error message */} -
+
Passwords did not match! @@ -333,7 +357,7 @@ export default function LoginPage() { setRegister(false); setUsePassword(false); }} - class="pl-1 text-blue underline hover:brightness-125" + class="text-blue pl-1 underline hover:brightness-125" > Click here to Login @@ -347,7 +371,7 @@ export default function LoginPage() { setRegister(true); setUsePassword(false); }} - class="pl-1 text-blue underline hover:brightness-125" + class="text-blue pl-1 underline hover:brightness-125" > Click here to Register @@ -393,7 +417,7 @@ export default function LoginPage() { setShowPasswordInput(!showPasswordInput()); passwordRef?.focus(); }} - class="absolute ml-60 mt-14" + class="absolute mt-14 ml-60" type="button" >
Password too short! Min Length: 8 @@ -446,7 +470,7 @@ export default function LoginPage() { setShowPasswordConfInput(!showPasswordConfInput()); passwordConfRef?.focus(); }} - class="absolute ml-60 mt-14" + class="absolute mt-14 ml-60" type="button" > = 6 ? "" - : "select-none opacity-0" + : "opacity-0 select-none" } text-center text-red-500 transition-opacity duration-200 ease-in-out`} > Passwords do not match! @@ -495,8 +519,8 @@ export default function LoginPage() { showPasswordError() ? "text-red-500" : showPasswordSuccess() - ? "text-green-500" - : "select-none opacity-0" + ? "text-green-500" + : "opacity-0 select-none" } flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`} > @@ -519,13 +543,13 @@ export default function LoginPage() { loading() ? "bg-zinc-400" : "bg-blue hover:brightness-125 active:scale-90" - } flex w-36 justify-center rounded py-3 text-white transition-all duration-300 ease-out`} + } flex w-36 justify-center rounded py-3 text-white transition-all duration-300 ease-out`} > {register() ? "Sign Up" : usePassword() - ? "Sign In" - : "Get Link"} + ? "Sign In" + : "Get Link"} } > @@ -579,7 +603,7 @@ export default function LoginPage() {
Email Sent!
diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..9e2b4d3 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,113 @@ +import { getCookie, setCookie, type H3Event } from "vinxi/http"; +import { jwtVerify } from "jose"; +import { OAuth2Client } from "google-auth-library"; +import type { Row } from "@libsql/client/web"; +import { env } from "~/env/server"; + +export async function getPrivilegeLevel( + event: H3Event +): Promise<"anonymous" | "admin" | "user"> { + try { + const userIDToken = getCookie(event, "userIDToken"); + + if (userIDToken) { + try { + const secret = new TextEncoder().encode(env().JWT_SECRET_KEY); + const { payload } = await jwtVerify(userIDToken, secret); + + if (payload.id && typeof payload.id === "string") { + return payload.id === env().ADMIN_ID ? "admin" : "user"; + } + } catch (err) { + console.log("Failed to authenticate token."); + setCookie(event, "userIDToken", "", { + maxAge: 0, + expires: new Date("2016-10-05") + }); + } + } + } catch (e) { + return "anonymous"; + } + return "anonymous"; +} + +export async function getUserID(event: H3Event): Promise { + try { + const userIDToken = getCookie(event, "userIDToken"); + + if (userIDToken) { + try { + const secret = new TextEncoder().encode(env().JWT_SECRET_KEY); + const { payload } = await jwtVerify(userIDToken, secret); + + if (payload.id && typeof payload.id === "string") { + return payload.id; + } + } catch (err) { + console.log("Failed to authenticate token."); + setCookie(event, "userIDToken", "", { + maxAge: 0, + expires: new Date("2016-10-05") + }); + } + } + } catch (e) { + return null; + } + return null; +} + +export async function checkAuthStatus(event: H3Event): Promise<{ + isAuthenticated: boolean; + userId: string | null; +}> { + const userId = await getUserID(event); + return { + isAuthenticated: !!userId, + userId + }; +} + +export async function validateLineageRequest({ + auth_token, + userRow +}: { + auth_token: string; + userRow: Row; +}): Promise { + const { provider, email } = userRow; + if (provider === "email") { + try { + const secret = new TextEncoder().encode(env().JWT_SECRET_KEY); + const { payload } = await jwtVerify(auth_token, secret); + if (email !== payload.email) { + return false; + } + } catch (err) { + return false; + } + } else if (provider == "apple") { + const { apple_user_string } = userRow; + if (apple_user_string !== auth_token) { + return false; + } + } else if (provider == "google") { + const CLIENT_ID = process.env().VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE; + if (!CLIENT_ID) { + console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE"); + return false; + } + const client = new OAuth2Client(CLIENT_ID); + const ticket = await client.verifyIdToken({ + idToken: auth_token, + audience: CLIENT_ID + }); + if (ticket.getPayload()?.email !== email) { + return false; + } + } else { + return false; + } + return true; +} diff --git a/src/server/database.ts b/src/server/database.ts new file mode 100644 index 0000000..a15edaf --- /dev/null +++ b/src/server/database.ts @@ -0,0 +1,169 @@ +import { createClient } from "@libsql/client/web"; +import { createClient as createAPIClient } from "@tursodatabase/api"; +import { v4 as uuid } from "uuid"; +import { env } from "~/env/server"; +import type { H3Event } from "vinxi/http"; + +let mainDBConnection: ReturnType | null = null; +let lineageDBConnection: ReturnType | null = null; + +export function ConnectionFactory() { + if (!mainDBConnection) { + const config = { + url: env().TURSO_DB_URL, + authToken: env().TURSO_DB_TOKEN + }; + mainDBConnection = createClient(config); + } + return mainDBConnection; +} + +export function LineageConnectionFactory() { + if (!lineageDBConnection) { + const config = { + url: env().TURSO_LINEAGE_URL, + authToken: env().TURSO_LINEAGE_TOKEN + }; + lineageDBConnection = createClient(config); + } + return lineageDBConnection; +} + +export async function LineageDBInit() { + const turso = createAPIClient({ + org: "mikefreno", + token: env().TURSO_DB_API_TOKEN + }); + + const db_name = uuid(); + const db = await turso.databases.create(db_name, { group: "default" }); + + const token = await turso.databases.createToken(db_name, { + authorization: "full-access" + }); + + const conn = PerUserDBConnectionFactory(db.name, token.jwt); + await conn.execute(` + CREATE TABLE checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_updated TEXT NOT NULL, + player_age INTEGER NOT NULL, + player_data TEXT, + time_data TEXT, + dungeon_data TEXT, + character_data TEXT, + shops_data TEXT + ) +`); + + return { token: token.jwt, dbName: db.name }; +} + +export function PerUserDBConnectionFactory(dbName: string, token: string) { + const config = { + url: `libsql://${dbName}-mikefreno.turso.io`, + authToken: token + }; + const conn = createClient(config); + return conn; +} + +export async function dumpAndSendDB({ + dbName, + dbToken, + sendTarget +}: { + dbName: string; + dbToken: string; + sendTarget: string; +}): Promise<{ + success: boolean; + reason?: string; +}> { + const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, { + method: "GET", + headers: { + Authorization: `Bearer ${dbToken}` + } + }); + if (!res.ok) { + console.error(res); + return { success: false, reason: "bad dump request response" }; + } + const text = await res.text(); + const base64Content = Buffer.from(text, "utf-8").toString("base64"); + + const apiKey = env().SENDINBLUE_KEY as string; + const apiUrl = "https://api.brevo.com/v3/smtp/email"; + + const emailPayload = { + sender: { + name: "no_reply@freno.me", + email: "no_reply@freno.me" + }, + to: [ + { + email: sendTarget + } + ], + subject: "Your Lineage Database Dump", + htmlContent: + "

Please find the attached database dump. This contains the state of your person remote Lineage remote saves. Should you ever return to Lineage, you can upload this file to reinstate the saves you had.

", + attachment: [ + { + content: base64Content, + name: "database_dump.txt" + } + ] + }; + const sendRes = await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json" + }, + body: JSON.stringify(emailPayload) + }); + + if (!sendRes.ok) { + return { success: false, reason: "email send failure" }; + } else { + return { success: true }; + } +} + +export async function getUserBasicInfo(event: H3Event): Promise<{ + email: string | null; + isAuthenticated: boolean; +} | null> { + const { getUserID } = await import("./auth"); + const userId = await getUserID(event); + + if (!userId) { + return { email: null, isAuthenticated: false }; + } + + try { + const conn = ConnectionFactory(); + const res = await conn.execute({ + sql: "SELECT email FROM User WHERE id = ?", + args: [userId] + }); + + if (res.rows.length === 0) { + return { email: null, isAuthenticated: false }; + } + + const user = res.rows[0] as { email: string | null }; + return { + email: user.email, + isAuthenticated: true + }; + } catch (error) { + console.error("Error fetching user basic info:", error); + return { email: null, isAuthenticated: false }; + } +} diff --git a/src/server/email.ts b/src/server/email.ts new file mode 100644 index 0000000..23ba5a4 --- /dev/null +++ b/src/server/email.ts @@ -0,0 +1,95 @@ +import { SignJWT } from "jose"; +import { env } from "~/env/server"; + +export const LINEAGE_JWT_EXPIRY = "14d"; + +export async function sendEmailVerification(userEmail: string): Promise<{ + success: boolean; + messageId?: string; + message?: string; +}> { + const apiKey = env().SENDINBLUE_KEY; + const apiUrl = "https://api.brevo.com/v3/smtp/email"; + + const secret = new TextEncoder().encode(env().JWT_SECRET_KEY); + const token = await new SignJWT({ email: userEmail }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("15m") + .sign(secret); + + const domain = + env().VITE_DOMAIN || env().NEXT_PUBLIC_DOMAIN || "https://freno.me"; + + const emailPayload = { + sender: { + name: "MikeFreno", + email: "lifeandlineage_no_reply@freno.me" + }, + to: [ + { + email: userEmail + } + ], + htmlContent: ` + + + + +
+

Click the button below to verify email

+
+
+ + + +`, + subject: `Life and Lineage email verification` + }; + + try { + const res = await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json" + }, + body: JSON.stringify(emailPayload) + }); + + if (!res.ok) { + return { success: false, message: "Failed to send email" }; + } + + const json = (await res.json()) as { messageId?: string }; + if (json.messageId) { + return { success: true, messageId: json.messageId }; + } + return { success: false, message: "No messageId in response" }; + } catch (error) { + console.error("Email sending error:", error); + return { success: false, message: "Email service error" }; + } +} diff --git a/src/server/password.ts b/src/server/password.ts new file mode 100644 index 0000000..c708d6c --- /dev/null +++ b/src/server/password.ts @@ -0,0 +1,16 @@ +import * as bcrypt from "bcrypt"; + +export async function hashPassword(password: string): Promise { + const saltRounds = 10; + const salt = await bcrypt.genSalt(saltRounds); + const hashedPassword = await bcrypt.hash(password, salt); + return hashedPassword; +} + +export async function checkPassword( + password: string, + hash: string +): Promise { + const match = await bcrypt.compare(password, hash); + return match; +} diff --git a/src/server/utils.ts b/src/server/utils.ts index 281c382..1279add 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -1,350 +1,19 @@ -import { getCookie, setCookie, type H3Event } from "vinxi/http"; -import { jwtVerify, type JWTPayload, SignJWT } from "jose"; -import { env } from "~/env/server"; -import { createClient, Row } from "@libsql/client/web"; -import { v4 as uuid } from "uuid"; -import { createClient as createAPIClient } from "@tursodatabase/api"; -import { OAuth2Client } from "google-auth-library"; -import * as bcrypt from "bcrypt"; +export { + getPrivilegeLevel, + getUserID, + checkAuthStatus, + validateLineageRequest +} from "./auth"; -export const LINEAGE_JWT_EXPIRY = "14d"; +export { + ConnectionFactory, + LineageConnectionFactory, + LineageDBInit, + PerUserDBConnectionFactory, + dumpAndSendDB, + getUserBasicInfo +} from "./database"; -// Helper function to get privilege level from H3Event (for use outside tRPC) -export async function getPrivilegeLevel( - event: H3Event -): Promise<"anonymous" | "admin" | "user"> { - try { - const userIDToken = getCookie(event, "userIDToken"); +export { hashPassword, checkPassword } from "./password"; - if (userIDToken) { - try { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(userIDToken, secret); - - if (payload.id && typeof payload.id === "string") { - return payload.id === env.ADMIN_ID ? "admin" : "user"; - } - } catch (err) { - console.log("Failed to authenticate token."); - setCookie(event, "userIDToken", "", { - maxAge: 0, - expires: new Date("2016-10-05") - }); - } - } - } catch (e) { - return "anonymous"; - } - return "anonymous"; -} - -// Helper function to get user ID from H3Event (for use outside tRPC) -export async function getUserID(event: H3Event): Promise { - try { - const userIDToken = getCookie(event, "userIDToken"); - - if (userIDToken) { - try { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(userIDToken, secret); - - if (payload.id && typeof payload.id === "string") { - return payload.id; - } - } catch (err) { - console.log("Failed to authenticate token."); - setCookie(event, "userIDToken", "", { - maxAge: 0, - expires: new Date("2016-10-05") - }); - } - } - } catch (e) { - return null; - } - return null; -} - -// Turso - Connection Pooling Implementation -let mainDBConnection: ReturnType | null = null; -let lineageDBConnection: ReturnType | null = null; - -export function ConnectionFactory() { - if (!mainDBConnection) { - const config = { - url: env.TURSO_DB_URL, - authToken: env.TURSO_DB_TOKEN - }; - mainDBConnection = createClient(config); - } - return mainDBConnection; -} - -export function LineageConnectionFactory() { - if (!lineageDBConnection) { - const config = { - url: env.TURSO_LINEAGE_URL, - authToken: env.TURSO_LINEAGE_TOKEN - }; - lineageDBConnection = createClient(config); - } - return lineageDBConnection; -} - -export async function LineageDBInit() { - const turso = createAPIClient({ - org: "mikefreno", - token: env.TURSO_DB_API_TOKEN - }); - - const db_name = uuid(); - const db = await turso.databases.create(db_name, { group: "default" }); - - const token = await turso.databases.createToken(db_name, { - authorization: "full-access" - }); - - const conn = PerUserDBConnectionFactory(db.name, token.jwt); - await conn.execute(` - CREATE TABLE checkpoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - last_updated TEXT NOT NULL, - player_age INTEGER NOT NULL, - player_data TEXT, - time_data TEXT, - dungeon_data TEXT, - character_data TEXT, - shops_data TEXT - ) -`); - - return { token: token.jwt, dbName: db.name }; -} - -export function PerUserDBConnectionFactory(dbName: string, token: string) { - const config = { - url: `libsql://${dbName}-mikefreno.turso.io`, - authToken: token - }; - const conn = createClient(config); - return conn; -} - -export async function dumpAndSendDB({ - dbName, - dbToken, - sendTarget -}: { - dbName: string; - dbToken: string; - sendTarget: string; -}): Promise<{ - success: boolean; - reason?: string; -}> { - const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, { - method: "GET", - headers: { - Authorization: `Bearer ${dbToken}` - } - }); - if (!res.ok) { - console.error(res); - return { success: false, reason: "bad dump request response" }; - } - const text = await res.text(); - const base64Content = Buffer.from(text, "utf-8").toString("base64"); - - const apiKey = env.SENDINBLUE_KEY as string; - const apiUrl = "https://api.brevo.com/v3/smtp/email"; - - const emailPayload = { - sender: { - name: "no_reply@freno.me", - email: "no_reply@freno.me" - }, - to: [ - { - email: sendTarget - } - ], - subject: "Your Lineage Database Dump", - htmlContent: - "

Please find the attached database dump. This contains the state of your person remote Lineage remote saves. Should you ever return to Lineage, you can upload this file to reinstate the saves you had.

", - attachment: [ - { - content: base64Content, - name: "database_dump.txt" - } - ] - }; - const sendRes = await fetch(apiUrl, { - method: "POST", - headers: { - accept: "application/json", - "api-key": apiKey, - "content-type": "application/json" - }, - body: JSON.stringify(emailPayload) - }); - - if (!sendRes.ok) { - return { success: false, reason: "email send failure" }; - } else { - return { success: true }; - } -} - -export async function validateLineageRequest({ - auth_token, - userRow -}: { - auth_token: string; - userRow: Row; -}): Promise { - const { provider, email } = userRow; - if (provider === "email") { - try { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(auth_token, secret); - if (email !== payload.email) { - return false; - } - } catch (err) { - return false; - } - } else if (provider == "apple") { - const { apple_user_string } = userRow; - if (apple_user_string !== auth_token) { - return false; - } - } else if (provider == "google") { - // Note: Using client env var - should be available via import.meta.env in actual runtime - const CLIENT_ID = process.env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE; - if (!CLIENT_ID) { - console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE"); - return false; - } - const client = new OAuth2Client(CLIENT_ID); - const ticket = await client.verifyIdToken({ - idToken: auth_token, - audience: CLIENT_ID - }); - if (ticket.getPayload()?.email !== email) { - return false; - } - } else { - return false; - } - return true; -} - -// Password hashing utilities -export async function hashPassword(password: string): Promise { - const saltRounds = 10; - const salt = await bcrypt.genSalt(saltRounds); - const hashedPassword = await bcrypt.hash(password, salt); - return hashedPassword; -} - -export async function checkPassword( - password: string, - hash: string -): Promise { - const match = await bcrypt.compare(password, hash); - return match; -} - -// Email service utilities -export async function sendEmailVerification(userEmail: string): Promise<{ - success: boolean; - messageId?: string; - message?: string; -}> { - const apiKey = env.SENDINBLUE_KEY; - const apiUrl = "https://api.brevo.com/v3/smtp/email"; - - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const token = await new SignJWT({ email: userEmail }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("15m") - .sign(secret); - - const domain = - env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN || "https://freno.me"; - - const emailPayload = { - sender: { - name: "MikeFreno", - email: "lifeandlineage_no_reply@freno.me" - }, - to: [ - { - email: userEmail - } - ], - htmlContent: ` - - - - -
-

Click the button below to verify email

-
-
- - - -`, - subject: `Life and Lineage email verification` - }; - - try { - const res = await fetch(apiUrl, { - method: "POST", - headers: { - accept: "application/json", - "api-key": apiKey, - "content-type": "application/json" - }, - body: JSON.stringify(emailPayload) - }); - - if (!res.ok) { - return { success: false, message: "Failed to send email" }; - } - - const json = (await res.json()) as { messageId?: string }; - if (json.messageId) { - return { success: true, messageId: json.messageId }; - } - return { success: false, message: "No messageId in response" }; - } catch (error) { - console.error("Email sending error:", error); - return { success: false, message: "Email service error" }; - } -} +export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email"; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index f158b83..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createClient } from "@libsql/client/web"; -import { env } from "~/env/server"; - -export function ConnectionFactory() { - const config = { - url: env.TURSO_DB_URL, - authToken: env.TURSO_DB_TOKEN, - }; - - const conn = createClient(config); - return conn; -}