diff --git a/bun.lock b/bun.lock index 02ebeda..1218586 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "bibdle", "dependencies": { "@xenova/transformers": "^2.17.2", - "better-sqlite3": "^12.6.2", "fast-xml-parser": "^5.3.3", "xml2js": "^0.6.2", }, @@ -18,11 +17,10 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.7", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", - "svelte": "^5.48.3", + "svelte": "^5.48.5", "svelte-check": "^4.3.5", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 640f2e3..946f8e8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1765934144883, "tag": "0000_clumsy_impossible_man", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1770266674489, + "tag": "0001_loose_kree", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 0d27c1c..e0721dd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "3.0.0alpha", "type": "module", "scripts": { - "dev": "vite dev", + "dev": "bun --bun vite dev", "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", @@ -23,7 +23,6 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.7", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", @@ -35,7 +34,6 @@ }, "dependencies": { "@xenova/transformers": "^2.17.2", - "better-sqlite3": "^12.6.2", "fast-xml-parser": "^5.3.3", "xml2js": "^0.6.2" } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 38c9930..0b5fd12 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -31,7 +31,7 @@ export async function validateSessionToken(token: string) { const [result] = await db .select({ // Adjust user table here to tweak returned data - user: { id: table.user.id, username: table.user.username }, + user: { id: table.user.id, email: table.user.email }, session: table.session }) .from(table.session) @@ -79,3 +79,37 @@ export function deleteSessionTokenCookie(event: RequestEvent) { path: '/' }); } + +export async function hashPassword(password: string): Promise { + return await Bun.password.hash(password, { + algorithm: 'argon2id', + memoryCost: 4, + timeCost: 3 + }); +} + +export async function verifyPassword(password: string, hash: string): Promise { + try { + return await Bun.password.verify(password, hash); + } catch { + return false; + } +} + +export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) { + const user: table.User = { + id: anonymousId, // Use anonymousId as the user ID to preserve stats + email, + passwordHash, + firstName: firstName || null, + lastName: lastName || null, + isPrivate: false + }; + await db.insert(table.user).values(user); + return user; +} + +export async function getUserByEmail(email: string) { + const [user] = await db.select().from(table.user).where(eq(table.user.email, email)); + return user || null; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index b3c877b..92dd866 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,5 +1,5 @@ -import { drizzle } from 'drizzle-orm/better-sqlite3'; -import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { Database } from 'bun:sqlite'; import * as schema from './schema'; import { env } from '$env/dynamic/private'; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 2067242..5ed1455 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -2,7 +2,14 @@ import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-co import { sql } from 'drizzle-orm'; -export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') }); +export const user = sqliteTable('user', { + id: text('id').primaryKey(), + firstName: text('first_name'), + lastName: text('last_name'), + email: text('email').unique(), + passwordHash: text('password_hash'), + isPrivate: integer('is_private', { mode: 'boolean' }).default(false) +}); export const session = sqliteTable('session', { id: text('id').primaryKey(), diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 64aa6a0..bb3cacf 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,7 +11,9 @@ import Credits from "$lib/components/Credits.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import DevButtons from "$lib/components/DevButtons.svelte"; + import AuthModal from "$lib/components/AuthModal.svelte"; import { getGrade } from "$lib/utils/game"; + import { enhance } from '$app/forms'; interface Guess { book: BibleBook; @@ -25,6 +27,8 @@ let dailyVerse = $derived(data.dailyVerse); let correctBookId = $derived(data.correctBookId); + let user = $derived(data.user); + let session = $derived(data.session); let guesses = $state([]); @@ -37,6 +41,7 @@ let anonymousId = $state(""); let statsSubmitted = $state(false); + let authModalOpen = $state(false); let statsData = $state<{ solveRank: number; guessRank: number; @@ -447,13 +452,42 @@ {isDev ? "Dev Edition | " : ""}{currentDate} -
- - 📊 View Stats - +
+
+ + 📊 View Stats + + + {#if user} +
+ +
+ {:else} + + {/if} +
+ + {#if isDev} +
+
Debug Info:
+
User: {user ? `${user.email} (ID: ${user.id})` : 'Not signed in'}
+
Session: {session ? `Expires ${session.expiresAt.toLocaleDateString()}` : 'No session'}
+
Anonymous ID: {anonymousId || 'Not set'}
+
+ {/if}
@@ -502,3 +536,5 @@ {/if} + + diff --git a/src/routes/auth/logout/+page.server.ts b/src/routes/auth/logout/+page.server.ts new file mode 100644 index 0000000..7fccab2 --- /dev/null +++ b/src/routes/auth/logout/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import * as auth from '$lib/server/auth'; + +export const actions: Actions = { + default: async ({ locals, cookies }) => { + if (locals.session) { + await auth.invalidateSession(locals.session.id); + } + auth.deleteSessionTokenCookie({ cookies }); + redirect(302, '/'); + } +}; \ No newline at end of file diff --git a/src/routes/auth/signin/+page.server.ts b/src/routes/auth/signin/+page.server.ts new file mode 100644 index 0000000..95e7fa9 --- /dev/null +++ b/src/routes/auth/signin/+page.server.ts @@ -0,0 +1,49 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import * as auth from '$lib/server/auth'; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const email = data.get('email')?.toString(); + const password = data.get('password')?.toString(); + + if (!email || !password) { + return fail(400, { error: 'Email and password are required' }); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + if (password.length < 6) { + return fail(400, { error: 'Password must be at least 6 characters' }); + } + + try { + // Get user by email + const user = await auth.getUserByEmail(email); + if (!user || !user.passwordHash) { + return fail(400, { error: 'Invalid email or password' }); + } + + // Verify password + const isValidPassword = await auth.verifyPassword(password, user.passwordHash); + if (!isValidPassword) { + return fail(400, { error: 'Invalid email or password' }); + } + + // Create session + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, user.id); + auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt); + + return { success: true }; + } catch (error) { + console.error('Sign in error:', error); + return fail(500, { error: 'An error occurred during sign in' }); + } + } +}; \ No newline at end of file diff --git a/src/routes/auth/signup/+page.server.ts b/src/routes/auth/signup/+page.server.ts new file mode 100644 index 0000000..625e43a --- /dev/null +++ b/src/routes/auth/signup/+page.server.ts @@ -0,0 +1,64 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import * as auth from '$lib/server/auth'; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const email = data.get('email')?.toString(); + const password = data.get('password')?.toString(); + const firstName = data.get('firstName')?.toString(); + const lastName = data.get('lastName')?.toString(); + const anonymousId = data.get('anonymousId')?.toString(); + + if (!email || !password || !anonymousId) { + return fail(400, { error: 'Email, password, and anonymous ID are required' }); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + if (password.length < 6) { + return fail(400, { error: 'Password must be at least 6 characters' }); + } + + try { + // Check if user already exists + const existingUser = await auth.getUserByEmail(email); + if (existingUser) { + return fail(400, { error: 'An account with this email already exists' }); + } + + // Hash password + const passwordHash = await auth.hashPassword(password); + + // Create user with anonymousId as the user ID + const user = await auth.createUser( + anonymousId, + email, + passwordHash, + firstName || undefined, + lastName || undefined + ); + + // Create session + const sessionToken = auth.generateSessionToken(); + const session = await auth.createSession(sessionToken, user.id); + auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt); + + return { success: true }; + } catch (error) { + console.error('Sign up error:', error); + + // Check if it's a unique constraint error (user with this ID already exists) + if (error instanceof Error && error.message.includes('UNIQUE constraint')) { + return fail(400, { error: 'This account is already registered. Please sign in instead.' }); + } + + return fail(500, { error: 'An error occurred during account creation' }); + } + } +}; \ No newline at end of file