better initial load

This commit is contained in:
Michael Freno
2025-12-19 11:48:00 -05:00
parent a8481b8f7c
commit 324141441b
17 changed files with 611 additions and 487 deletions

View File

@@ -3,6 +3,7 @@
"type": "module",
"scripts": {
"dev": "vinxi dev",
"dev-flush": "vinxi dev --env-file=.env",
"build": "vinxi build",
"start": "vinxi start"
},

View File

@@ -161,14 +161,19 @@ function AppLayout(props: { children: any }) {
return (
<>
{/* Fullscreen loading splash until bars are initialized */}
<Show when={!barsInitialized()}>
<div class="bg-base fixed inset-0 z-50">
<div class="bg-base fixed inset-0 z-100">
<TerminalSplash />
</div>
</Show>
<div class="flex max-w-screen flex-row">
<div
class="flex max-w-screen flex-row"
style={{
opacity: barsInitialized() ? "1" : "0",
transition: "opacity 0.3s ease-in-out"
}}
>
<LeftBar />
<div
class="relative min-h-screen rounded-t-lg shadow-2xl"
@@ -177,7 +182,9 @@ function AppLayout(props: { children: any }) {
"margin-left": `${leftBarSize()}px`
}}
>
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
</Show>
</div>
<RightBar />
</div>

View File

@@ -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 (
<div class="text-text flex h-full flex-col gap-6 overflow-y-auto pb-6">
<div class="text-text flex h-full w-min flex-col gap-6 overflow-y-auto pb-6">
<Typewriter keepAlive={false} class="z-50 px-4 pt-4">
<ul class="flex flex-col gap-4">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
@@ -112,7 +113,7 @@ export function RightBarContent() {
{/* Git Activity Section */}
<Suspense fallback={<TerminalSplash />}>
<div class="border-overlay0 flex flex-col gap-6 border-t px-4 pt-6">
<div class="border-overlay0 flex min-w-0 flex-col gap-6 border-t px-4 pt-6">
<RecentCommits
commits={githubCommits()}
title="Recent GitHub Commits"
@@ -122,9 +123,6 @@ export function RightBarContent() {
contributions={githubActivity()}
title="GitHub Activity"
/>
<div>
<a href="https://git.freno.me">Self-hosted Git!</a>
</div>
<RecentCommits
commits={giteaCommits()}
title="Recent Gitea Commits"
@@ -152,7 +150,17 @@ export function LeftBar() {
undefined
);
const [userInfo, setUserInfo] = createSignal<{
email: string | null;
isAuthenticated: boolean;
} | null>(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() {
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
<div class="flex flex-col py-8">
<span class="text-lg font-semibold">Recent Posts</span>
<div class="flex flex-col gap-3 pt-4">
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
<Show when={recentPosts()} fallback={<TerminalSplash />}>
<For each={recentPosts()}>
{(post) => (
@@ -369,7 +401,20 @@ export function LeftBar() {
<a href="/blog">Blog</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/login">Login</a>
<Show
when={isMounted() && userInfo()?.isAuthenticated}
fallback={<a href="/login">Login</a>}
>
<a href="/account">
Account
<Show when={userInfo()?.email}>
<span class="text-subtext0 text-sm font-normal">
{" "}
({userInfo()!.email})
</span>
</Show>
</a>
</Show>
</li>
</ul>
</Typewriter>

View File

@@ -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]"
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="text-text line-clamp-2 flex-1 text-xs leading-tight font-medium">
<Typewriter
speed={100}
keepAlive={false}
class="flex min-w-0 flex-col gap-1"
>
<div class="flex min-w-0 items-start justify-between gap-2">
<span class="text-text line-clamp-2 min-w-0 flex-1 text-xs leading-tight font-medium break-words">
{commit.message}
</span>
<span class="text-subtext1 shrink-0 text-[10px]">
{formatDate(commit.date)}
</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-surface1 rounded px-1.5 py-0.5 font-mono text-[10px]">
<span class="text-subtext1 shrink-0 text-[10px]">
{formatDate(commit.date)}
</span>
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span class="bg-surface1 shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
{commit.sha}
</span>
<span class="text-subtext0 truncate text-[10px]">
<span class="text-subtext0 min-w-0 truncate text-[10px]">
{commit.repo}
</span>
</div>
</div>
</Typewriter>
</a>
)}
</For>

View File

@@ -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);
}

View File

@@ -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

18
src/lib/date-utils.ts Normal file
View File

@@ -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}`;
}

View File

@@ -36,14 +36,17 @@ export function validatePassword(password: string): {
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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
/**
* 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;
}

View File

@@ -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);
}

View File

@@ -2,12 +2,33 @@ import { Typewriter } from "~/components/Typewriter";
export default function Home() {
return (
<Typewriter speed={100} keepAlive={2000}>
<main class="text-subtext0 mx-auto p-4 text-center">
{/* 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
</main>
</Typewriter>
<main class="p-4 text-xl">
<Typewriter speed={30} keepAlive={2000}>
<div class="text-4xl">Hey!</div>
</Typewriter>
<Typewriter speed={80} keepAlive={2000}>
<div>
My name is <span class="text-green">Mike Freno</span>, I'm a{" "}
<span class="text-blue">Software Engineer</span> based in{" "}
<span class="text-yellow">Brooklyn, NY</span>
</div>
</Typewriter>
<Typewriter speed={100}>
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: <a></a>
I'm a huge lover of open source software, and
</Typewriter>
<Typewriter speed={50} keepAlive={false}>
<div>My Collection of By-the-ways:</div>
</Typewriter>
<Typewriter speed={50} keepAlive={false}>
<ul class="list-disc pl-8">
<li>I use Neovim</li>
<li>I use Arch Linux</li>
<li>I use Rust</li>
</ul>
</Typewriter>
</main>
);
}

View File

@@ -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 */}
<div class="pt-24 md:pt-48">
{/* Error message */}
<div class="absolute -mt-12 text-center text-3xl italic text-red-400">
<div class="absolute -mt-12 text-center text-3xl text-red-400 italic">
<Show when={error() === "passwordMismatch"}>
Passwords did not match!
</Show>
@@ -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
</button>
@@ -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
</button>
@@ -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"
>
<Show
@@ -418,7 +442,7 @@ export default function LoginPage() {
</div>
<div
class={`${
showPasswordLengthWarning() ? "" : "select-none opacity-0"
showPasswordLengthWarning() ? "" : "opacity-0 select-none"
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
>
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"
>
<Show
@@ -476,7 +500,7 @@ export default function LoginPage() {
passwordConfRef &&
passwordConfRef.value.length >= 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`}
>
<Show when={showPasswordError()}>
@@ -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"}
</button>
}
>
@@ -579,7 +603,7 @@ export default function LoginPage() {
<div
class={`${
emailSent() ? "" : "user-select opacity-0"
} flex min-h-4 justify-center text-center italic text-green transition-opacity duration-300 ease-in-out`}
} text-green flex min-h-4 justify-center text-center italic transition-opacity duration-300 ease-in-out`}
>
<Show when={emailSent()}>Email Sent!</Show>
</div>

113
src/server/auth.ts Normal file
View File

@@ -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<string | null> {
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<boolean> {
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;
}

169
src/server/database.ts Normal file
View File

@@ -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<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | 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:
"<html><body><p>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.</p></body></html>",
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 };
}
}

95
src/server/email.ts Normal file
View File

@@ -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: `<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/lineage/email/verification/${userEmail}/?token=${token}" class="button">Verify Email</a>
</div>
</body>
</html>
`,
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" };
}
}

16
src/server/password.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as bcrypt from "bcrypt";
export async function hashPassword(password: string): Promise<string> {
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<boolean> {
const match = await bcrypt.compare(password, hash);
return match;
}

View File

@@ -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<string | null> {
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<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | 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:
"<html><body><p>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.</p></body></html>",
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<boolean> {
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<string> {
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<boolean> {
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: `<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/lineage/email/verification/${userEmail}/?token=${token}" class="button">Verify Email</a>
</div>
</body>
</html>
`,
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";

View File

@@ -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;
}