better initial load
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"dev-flush": "vinxi dev --env-file=.env",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start"
|
||||
},
|
||||
|
||||
15
src/app.tsx
15
src/app.tsx
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
18
src/lib/date-utils.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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, "<")
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
113
src/server/auth.ts
Normal 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
169
src/server/database.ts
Normal 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
95
src/server/email.ts
Normal 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
16
src/server/password.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
12
src/utils.ts
12
src/utils.ts
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user