better initial load
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
|
"dev-flush": "vinxi dev --env-file=.env",
|
||||||
"build": "vinxi build",
|
"build": "vinxi build",
|
||||||
"start": "vinxi start"
|
"start": "vinxi start"
|
||||||
},
|
},
|
||||||
|
|||||||
13
src/app.tsx
13
src/app.tsx
@@ -161,14 +161,19 @@ function AppLayout(props: { children: any }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Fullscreen loading splash until bars are initialized */}
|
|
||||||
<Show when={!barsInitialized()}>
|
<Show when={!barsInitialized()}>
|
||||||
<div class="bg-base fixed inset-0 z-50">
|
<div class="bg-base fixed inset-0 z-100">
|
||||||
<TerminalSplash />
|
<TerminalSplash />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 />
|
<LeftBar />
|
||||||
<div
|
<div
|
||||||
class="relative min-h-screen rounded-t-lg shadow-2xl"
|
class="relative min-h-screen rounded-t-lg shadow-2xl"
|
||||||
@@ -177,7 +182,9 @@ function AppLayout(props: { children: any }) {
|
|||||||
"margin-left": `${leftBarSize()}px`
|
"margin-left": `${leftBarSize()}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={barsInitialized()} fallback={<TerminalSplash />}>
|
||||||
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
|
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<RightBar />
|
<RightBar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
Show,
|
Show,
|
||||||
For,
|
For,
|
||||||
Suspense
|
Suspense,
|
||||||
|
createMemo
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { TerminalSplash } from "./TerminalSplash";
|
import { TerminalSplash } from "./TerminalSplash";
|
||||||
@@ -56,7 +57,7 @@ export function RightBarContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<Typewriter keepAlive={false} class="z-50 px-4 pt-4">
|
||||||
<ul class="flex flex-col gap-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">
|
<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 */}
|
{/* Git Activity Section */}
|
||||||
<Suspense fallback={<TerminalSplash />}>
|
<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
|
<RecentCommits
|
||||||
commits={githubCommits()}
|
commits={githubCommits()}
|
||||||
title="Recent GitHub Commits"
|
title="Recent GitHub Commits"
|
||||||
@@ -122,9 +123,6 @@ export function RightBarContent() {
|
|||||||
contributions={githubActivity()}
|
contributions={githubActivity()}
|
||||||
title="GitHub Activity"
|
title="GitHub Activity"
|
||||||
/>
|
/>
|
||||||
<div>
|
|
||||||
<a href="https://git.freno.me">Self-hosted Git!</a>
|
|
||||||
</div>
|
|
||||||
<RecentCommits
|
<RecentCommits
|
||||||
commits={giteaCommits()}
|
commits={giteaCommits()}
|
||||||
title="Recent Gitea Commits"
|
title="Recent Gitea Commits"
|
||||||
@@ -152,7 +150,17 @@ export function LeftBar() {
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [userInfo, setUserInfo] = createSignal<{
|
||||||
|
email: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = createSignal(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Mark as mounted to avoid hydration mismatch
|
||||||
|
setIsMounted(true);
|
||||||
|
|
||||||
// Fetch recent posts only on client side to avoid hydration mismatch
|
// Fetch recent posts only on client side to avoid hydration mismatch
|
||||||
try {
|
try {
|
||||||
const posts = await api.blog.getRecentPosts.query();
|
const posts = await api.blog.getRecentPosts.query();
|
||||||
@@ -162,6 +170,30 @@ export function LeftBar() {
|
|||||||
setRecentPosts([]);
|
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) {
|
if (ref) {
|
||||||
const updateSize = () => {
|
const updateSize = () => {
|
||||||
actualWidth = ref?.offsetWidth || 0;
|
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="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
||||||
<div class="flex flex-col py-8">
|
<div class="flex flex-col py-8">
|
||||||
<span class="text-lg font-semibold">Recent Posts</span>
|
<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 />}>
|
<Show when={recentPosts()} fallback={<TerminalSplash />}>
|
||||||
<For each={recentPosts()}>
|
<For each={recentPosts()}>
|
||||||
{(post) => (
|
{(post) => (
|
||||||
@@ -369,7 +401,20 @@ export function LeftBar() {
|
|||||||
<a href="/blog">Blog</a>
|
<a href="/blog">Blog</a>
|
||||||
</li>
|
</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">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Typewriter>
|
</Typewriter>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, For, Show } from "solid-js";
|
import { Component, For, Show } from "solid-js";
|
||||||
|
import { Typewriter } from "./Typewriter";
|
||||||
|
|
||||||
interface Commit {
|
interface Commit {
|
||||||
sha: string;
|
sha: string;
|
||||||
@@ -54,26 +55,30 @@ export const RecentCommits: Component<{
|
|||||||
href={commit.url}
|
href={commit.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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">
|
<Typewriter
|
||||||
<div class="flex items-start justify-between gap-2">
|
speed={100}
|
||||||
<span class="text-text line-clamp-2 flex-1 text-xs leading-tight font-medium">
|
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}
|
{commit.message}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
<span class="text-subtext1 shrink-0 text-[10px]">
|
<span class="text-subtext1 shrink-0 text-[10px]">
|
||||||
{formatDate(commit.date)}
|
{formatDate(commit.date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
<div class="flex items-center gap-2">
|
<span class="bg-surface1 shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
<span class="bg-surface1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
|
||||||
{commit.sha}
|
{commit.sha}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-subtext0 truncate text-[10px]">
|
<span class="text-subtext0 min-w-0 truncate text-[10px]">
|
||||||
{commit.repo}
|
{commit.repo}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Typewriter>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createHandler, StartServer } from "@solidjs/start/server";
|
|||||||
import { validateServerEnv } from "./env/server";
|
import { validateServerEnv } from "./env/server";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedEnv = validateServerEnv(process.env);
|
validateServerEnv(process.env);
|
||||||
console.log("Environment validation successful");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Environment validation failed:", error);
|
console.error("Environment validation failed:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,15 @@
|
|||||||
* Comment System Utility Functions
|
* Comment System Utility Functions
|
||||||
*
|
*
|
||||||
* Shared utility functions for:
|
* Shared utility functions for:
|
||||||
* - Date formatting
|
|
||||||
* - Comment sorting algorithms
|
* - Comment sorting algorithms
|
||||||
* - Comment filtering and tree building
|
* - Comment filtering and tree building
|
||||||
* - Debouncing
|
* - Debouncing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Comment, CommentReaction, SortingMode } from "~/types/comment";
|
import type { Comment, CommentReaction, SortingMode } from "~/types/comment";
|
||||||
|
import { getSQLFormattedDate } from "./date-utils";
|
||||||
|
|
||||||
// ============================================================================
|
export { getSQLFormattedDate };
|
||||||
// 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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Comment Tree Utilities
|
// 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 {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
errors,
|
errors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two passwords match
|
* 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;
|
return password === confirmation && password.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,43 +56,3 @@ export function passwordsMatch(password: string, confirmation: string): boolean
|
|||||||
export function isValidDisplayName(name: string): boolean {
|
export function isValidDisplayName(name: string): boolean {
|
||||||
return name.trim().length >= 1 && name.trim().length <= 50;
|
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 { 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 Eye from "~/components/icons/Eye";
|
||||||
import EyeSlash from "~/components/icons/EyeSlash";
|
import EyeSlash from "~/components/icons/EyeSlash";
|
||||||
import { validatePassword, isValidEmail } from "~/lib/validation";
|
import { validatePassword, isValidEmail } from "~/lib/validation";
|
||||||
|
import { checkAuthStatus } from "~/server/utils";
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +16,22 @@ type UserProfile = {
|
|||||||
hasPassword: boolean;
|
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() {
|
export default function AccountPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -72,16 +90,10 @@ export default function AccountPage() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.result?.data) {
|
if (result.result?.data) {
|
||||||
setUser(result.result.data);
|
setUser(result.result.data);
|
||||||
} else {
|
|
||||||
// Not logged in, redirect to login
|
|
||||||
navigate("/login");
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
navigate("/login");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch user profile:", err);
|
console.error("Failed to fetch user profile:", err);
|
||||||
navigate("/login");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,33 @@ import { Typewriter } from "~/components/Typewriter";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Typewriter speed={100} keepAlive={2000}>
|
<main class="p-4 text-xl">
|
||||||
<main class="text-subtext0 mx-auto p-4 text-center">
|
<Typewriter speed={30} keepAlive={2000}>
|
||||||
{/* fill in a ipsum lorem */}
|
<div class="text-4xl">Hey!</div>
|
||||||
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>
|
</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 { 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 GoogleLogo from "~/components/icons/GoogleLogo";
|
||||||
import GitHub from "~/components/icons/GitHub";
|
import GitHub from "~/components/icons/GitHub";
|
||||||
import Eye from "~/components/icons/Eye";
|
import Eye from "~/components/icons/Eye";
|
||||||
@@ -7,6 +14,23 @@ import EyeSlash from "~/components/icons/EyeSlash";
|
|||||||
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||||
import { isValidEmail, validatePassword } from "~/lib/validation";
|
import { isValidEmail, validatePassword } from "~/lib/validation";
|
||||||
import { getClientCookie } from "~/lib/cookies.client";
|
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() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -64,7 +88,7 @@ export default function LoginPage() {
|
|||||||
if (timer) {
|
if (timer) {
|
||||||
timerInterval = setInterval(
|
timerInterval = setInterval(
|
||||||
() => calcRemainder(timer),
|
() => calcRemainder(timer),
|
||||||
1000,
|
1000
|
||||||
) as unknown as number;
|
) as unknown as number;
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
@@ -84,7 +108,7 @@ export default function LoginPage() {
|
|||||||
server_error: "Server error - please try again later",
|
server_error: "Server error - please try again later",
|
||||||
missing_params: "Invalid login link - missing parameters",
|
missing_params: "Invalid login link - missing parameters",
|
||||||
link_expired: "Login link has expired - please request a new one",
|
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");
|
setError(errorMessages[errorParam] || "An error occurred during login");
|
||||||
}
|
}
|
||||||
@@ -138,8 +162,8 @@ export default function LoginPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
passwordConfirmation: passwordConf,
|
passwordConfirmation: passwordConf
|
||||||
}),
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -175,7 +199,7 @@ export default function LoginPage() {
|
|||||||
const response = await fetch("/api/trpc/auth.emailPasswordLogin", {
|
const response = await fetch("/api/trpc/auth.emailPasswordLogin", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password, rememberMe }),
|
body: JSON.stringify({ email, password, rememberMe })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -209,7 +233,7 @@ export default function LoginPage() {
|
|||||||
const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", {
|
const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, rememberMe }),
|
body: JSON.stringify({ email, rememberMe })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -223,7 +247,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
timerInterval = setInterval(
|
timerInterval = setInterval(
|
||||||
() => calcRemainder(timer),
|
() => calcRemainder(timer),
|
||||||
1000,
|
1000
|
||||||
) as unknown as number;
|
) as unknown as number;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -310,7 +334,7 @@ export default function LoginPage() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div class="pt-24 md:pt-48">
|
<div class="pt-24 md:pt-48">
|
||||||
{/* Error message */}
|
{/* 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"}>
|
<Show when={error() === "passwordMismatch"}>
|
||||||
Passwords did not match!
|
Passwords did not match!
|
||||||
</Show>
|
</Show>
|
||||||
@@ -333,7 +357,7 @@ export default function LoginPage() {
|
|||||||
setRegister(false);
|
setRegister(false);
|
||||||
setUsePassword(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
|
Click here to Login
|
||||||
</button>
|
</button>
|
||||||
@@ -347,7 +371,7 @@ export default function LoginPage() {
|
|||||||
setRegister(true);
|
setRegister(true);
|
||||||
setUsePassword(false);
|
setUsePassword(false);
|
||||||
}}
|
}}
|
||||||
class="pl-1 text-blue underline hover:brightness-125"
|
class="text-blue pl-1 underline hover:brightness-125"
|
||||||
>
|
>
|
||||||
Click here to Register
|
Click here to Register
|
||||||
</button>
|
</button>
|
||||||
@@ -393,7 +417,7 @@ export default function LoginPage() {
|
|||||||
setShowPasswordInput(!showPasswordInput());
|
setShowPasswordInput(!showPasswordInput());
|
||||||
passwordRef?.focus();
|
passwordRef?.focus();
|
||||||
}}
|
}}
|
||||||
class="absolute ml-60 mt-14"
|
class="absolute mt-14 ml-60"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
@@ -418,7 +442,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class={`${
|
class={`${
|
||||||
showPasswordLengthWarning() ? "" : "select-none opacity-0"
|
showPasswordLengthWarning() ? "" : "opacity-0 select-none"
|
||||||
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
|
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
|
||||||
>
|
>
|
||||||
Password too short! Min Length: 8
|
Password too short! Min Length: 8
|
||||||
@@ -446,7 +470,7 @@ export default function LoginPage() {
|
|||||||
setShowPasswordConfInput(!showPasswordConfInput());
|
setShowPasswordConfInput(!showPasswordConfInput());
|
||||||
passwordConfRef?.focus();
|
passwordConfRef?.focus();
|
||||||
}}
|
}}
|
||||||
class="absolute ml-60 mt-14"
|
class="absolute mt-14 ml-60"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
@@ -476,7 +500,7 @@ export default function LoginPage() {
|
|||||||
passwordConfRef &&
|
passwordConfRef &&
|
||||||
passwordConfRef.value.length >= 6
|
passwordConfRef.value.length >= 6
|
||||||
? ""
|
? ""
|
||||||
: "select-none opacity-0"
|
: "opacity-0 select-none"
|
||||||
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
|
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
|
||||||
>
|
>
|
||||||
Passwords do not match!
|
Passwords do not match!
|
||||||
@@ -496,7 +520,7 @@ export default function LoginPage() {
|
|||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: showPasswordSuccess()
|
: showPasswordSuccess()
|
||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: "select-none opacity-0"
|
: "opacity-0 select-none"
|
||||||
} flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`}
|
} flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`}
|
||||||
>
|
>
|
||||||
<Show when={showPasswordError()}>
|
<Show when={showPasswordError()}>
|
||||||
@@ -579,7 +603,7 @@ export default function LoginPage() {
|
|||||||
<div
|
<div
|
||||||
class={`${
|
class={`${
|
||||||
emailSent() ? "" : "user-select opacity-0"
|
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>
|
<Show when={emailSent()}>Email Sent!</Show>
|
||||||
</div>
|
</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";
|
export {
|
||||||
import { jwtVerify, type JWTPayload, SignJWT } from "jose";
|
getPrivilegeLevel,
|
||||||
import { env } from "~/env/server";
|
getUserID,
|
||||||
import { createClient, Row } from "@libsql/client/web";
|
checkAuthStatus,
|
||||||
import { v4 as uuid } from "uuid";
|
validateLineageRequest
|
||||||
import { createClient as createAPIClient } from "@tursodatabase/api";
|
} from "./auth";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
|
||||||
import * as bcrypt from "bcrypt";
|
|
||||||
|
|
||||||
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 { hashPassword, checkPassword } from "./password";
|
||||||
export async function getPrivilegeLevel(
|
|
||||||
event: H3Event
|
|
||||||
): Promise<"anonymous" | "admin" | "user"> {
|
|
||||||
try {
|
|
||||||
const userIDToken = getCookie(event, "userIDToken");
|
|
||||||
|
|
||||||
if (userIDToken) {
|
export { sendEmailVerification, LINEAGE_JWT_EXPIRY } from "./email";
|
||||||
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" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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