mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
4 Commits
321fac9aa8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e16c7e71 | ||
|
|
e45ac28169 | ||
|
|
3d578a9eb8 | ||
|
|
db04da6a2c |
3
drizzle/0003_overjoyed_mindworm.sql
Normal file
3
drizzle/0003_overjoyed_mindworm.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `daily_completions` ADD `guesses` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `google_id` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_google_id_unique` ON `user` (`google_id`);
|
||||
296
drizzle/meta/0003_snapshot.json
Normal file
296
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
|
||||
"prevId": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||
"tables": {
|
||||
"daily_completions": {
|
||||
"name": "daily_completions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anonymous_id": {
|
||||
"name": "anonymous_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guess_count": {
|
||||
"name": "guess_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guesses": {
|
||||
"name": "guesses",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"anonymous_id_date_idx": {
|
||||
"name": "anonymous_id_date_idx",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_idx": {
|
||||
"name": "date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_guess_idx": {
|
||||
"name": "date_guess_idx",
|
||||
"columns": [
|
||||
"date",
|
||||
"guess_count"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"daily_completions_anonymous_id_date_unique": {
|
||||
"name": "daily_completions_anonymous_id_date_unique",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"daily_verses": {
|
||||
"name": "daily_verses",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"book_id": {
|
||||
"name": "book_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verse_text": {
|
||||
"name": "verse_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"daily_verses_date_unique": {
|
||||
"name": "daily_verses_date_unique",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"apple_id": {
|
||||
"name": "apple_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_private": {
|
||||
"name": "is_private",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_apple_id_unique": {
|
||||
"name": "user_apple_id_unique",
|
||||
"columns": [
|
||||
"apple_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_google_id_unique": {
|
||||
"name": "user_google_id_unique",
|
||||
"columns": [
|
||||
"google_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1770961427714,
|
||||
"tag": "0002_outstanding_hiroim",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1774416309647,
|
||||
"tag": "0003_overjoyed_mindworm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"drizzle": "^1.4.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"xml2js": "^0.6.2"
|
||||
|
||||
@@ -105,6 +105,23 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="/auth/google">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium mt-3"
|
||||
data-umami-event="Sign in with Google"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center my-4">
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
<span class="px-3 text-sm text-white/60">or</span>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="big-text text-center text-gray-100! mb-6 px-4"
|
||||
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
|
||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||
>
|
||||
{promptText}
|
||||
|
||||
@@ -237,7 +237,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||
<div class="big-text font-black! text-center">Share your result</div>
|
||||
<div class="big-text font-black! text-center text-gray-300!">
|
||||
Share your result
|
||||
</div>
|
||||
<div class="chat-window">
|
||||
<!-- Received bubble: primary action (share / copy) -->
|
||||
<div class="bubble-wrapper received-wrapper">
|
||||
@@ -331,12 +333,19 @@
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<a href="/progress" class="progress-btn"> 📈 See your progress </a>
|
||||
<div class="rainbow-glow w-full">
|
||||
<a
|
||||
href="/progress"
|
||||
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]"
|
||||
>
|
||||
📈 See your progress
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="signin-prompt">
|
||||
<p class="signin-text">
|
||||
Sign in to save your streak & track your progress
|
||||
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||
Create an account (or sign in) to track your progress
|
||||
</p>
|
||||
<form method="POST" action="/auth/apple" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
@@ -357,6 +366,38 @@
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/auth/google" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="google-signin-btn"
|
||||
data-umami-event="Sign in with Google"
|
||||
>
|
||||
<svg
|
||||
class="google-icon"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -640,6 +681,47 @@
|
||||
}
|
||||
|
||||
/* ── Apple Sign In prompt ── */
|
||||
.rainbow-glow {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.rainbow-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 1.25rem;
|
||||
background: conic-gradient(
|
||||
from var(--angle, 0deg),
|
||||
#ff0080,
|
||||
#ff8c00,
|
||||
#ffd700,
|
||||
#00ff88,
|
||||
#00cfff,
|
||||
#a855f7,
|
||||
#ff0080
|
||||
);
|
||||
animation: rainbow-rotate 6s linear infinite;
|
||||
filter: blur(8px);
|
||||
opacity: 0.75;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@property --angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes rainbow-rotate {
|
||||
0% {
|
||||
--angle: 0deg;
|
||||
}
|
||||
100% {
|
||||
--angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.signin-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -650,17 +732,10 @@
|
||||
|
||||
.signin-text {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.signin-text {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -710,42 +785,51 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-btn {
|
||||
.google-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #059669;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
.progress-btn:hover {
|
||||
background: #047857;
|
||||
|
||||
.google-signin-btn:hover {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.progress-btn:active {
|
||||
background: #065f46;
|
||||
|
||||
.google-signin-btn:active {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.progress-btn {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
.google-signin-btn {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.progress-btn:hover {
|
||||
background: #059669;
|
||||
.google-signin-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.progress-btn:active {
|
||||
background: #047857;
|
||||
.google-signin-btn:active {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
googleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
|
||||
@@ -28,7 +28,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, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
||||
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId, googleId: table.user.googleId },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
googleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
@@ -117,6 +118,11 @@ export async function getUserByAppleId(appleId: string) {
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserByGoogleId(googleId: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||
if (!anonymousId || anonymousId === userId) return;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
appleId: text('apple_id').unique(),
|
||||
googleId: text('google_id').unique(),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
|
||||
65
src/lib/server/google-auth.ts
Normal file
65
src/lib/server/google-auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
export function getGoogleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
state,
|
||||
access_type: 'online',
|
||||
prompt: 'select_account'
|
||||
});
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGoogleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
id_token: string;
|
||||
scope: string;
|
||||
}> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Google token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Google's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Google's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeGoogleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
} {
|
||||
const [, payloadB64] = idToken.split('.');
|
||||
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload;
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
|
||||
let isDev = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
isDev =
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
|
||||
// Inject analytics script
|
||||
const script = document.createElement('script');
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = 'https://umami.snail.city/script.js';
|
||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||
script.src = "https://umami.snail.city/script.js";
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
"5b8c31ad-71cd-4317-940b-6bccea732acc",
|
||||
);
|
||||
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
@@ -21,17 +31,28 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Bibdle RSS Feed"
|
||||
href="/feed.xml"
|
||||
/>
|
||||
<meta name="description" content="A daily Bible game" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
|
||||
<div
|
||||
class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950"
|
||||
>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="hidden"><ThemeToggle /></div>
|
||||
{#if isDev}
|
||||
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||
{:else}
|
||||
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -528,7 +528,7 @@
|
||||
.filter(Boolean)
|
||||
.join(" ")}{user.email
|
||||
? ` (${user.email})`
|
||||
: ""}{user.appleId ? " using Apple" : ""} |
|
||||
: ""}{user.appleId ? " using Apple" : user.googleId ? " using Google" : ""} |
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
|
||||
26
src/routes/auth/google/+page.server.ts
Normal file
26
src/routes/auth/google/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { getGoogleAuthUrl } from '$lib/server/google-auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies, request }) => {
|
||||
const data = await request.formData();
|
||||
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||
|
||||
// Generate CSRF state
|
||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const state = Buffer.from(stateBytes).toString('base64url');
|
||||
|
||||
// sameSite 'lax' is safe here because Google sends a GET redirect back
|
||||
// (unlike Apple which POSTs cross-origin, requiring 'none')
|
||||
cookies.set('google_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
redirect(302, getGoogleAuthUrl(state));
|
||||
}
|
||||
};
|
||||
131
src/routes/auth/google/callback/+server.ts
Normal file
131
src/routes/auth/google/callback/+server.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
|
||||
import * as auth from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user as userTable } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
// User denied access
|
||||
if (errorParam) {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const storedRaw = cookies.get('google_oauth_state');
|
||||
if (!storedRaw || !state || !code) {
|
||||
throw error(400, 'Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||
if (stored.state !== state) {
|
||||
throw error(400, 'State mismatch');
|
||||
}
|
||||
cookies.delete('google_oauth_state', { path: '/' });
|
||||
|
||||
const anonId = stored.anonymousId;
|
||||
if (!anonId) {
|
||||
console.error('[Google auth] Missing anonymousId in state cookie');
|
||||
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await exchangeGoogleCode(
|
||||
code,
|
||||
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
|
||||
);
|
||||
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||
const googleId = claims.sub;
|
||||
|
||||
// --- User resolution ---
|
||||
let userId: string;
|
||||
|
||||
// 1. Check if a user with this googleId already exists (returning user)
|
||||
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
|
||||
|
||||
if (existingGoogleUser) {
|
||||
userId = existingGoogleUser.id;
|
||||
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else if (claims.email) {
|
||||
// 2. Check if email matches an existing email/password or Apple user
|
||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||
if (existingEmailUser) {
|
||||
// Link Google account to existing user
|
||||
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||
userId = existingEmailUser.id;
|
||||
console.log(`[Google auth] Linked Google to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||
userId = anonId;
|
||||
console.log(`[Google auth] New user (has email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: claims.email,
|
||||
passwordHash: null,
|
||||
appleId: null,
|
||||
googleId,
|
||||
firstName: claims.given_name || null,
|
||||
lastName: claims.family_name || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Handle race condition: if googleId was inserted between our check and insert
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Google auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No email from Google (edge case — Google almost always returns email)
|
||||
userId = anonId;
|
||||
console.log(`[Google auth] New user (no email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
appleId: null,
|
||||
googleId,
|
||||
firstName: claims.given_name || null,
|
||||
lastName: claims.family_name || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Google auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
36
todo.md
36
todo.md
@@ -59,6 +59,42 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## march 25th
|
||||
|
||||
- Added Sign In with Google (OAuth)
|
||||
- Added Google sign-in button to win screen and footer provider label
|
||||
- Added rainbow glow effect
|
||||
|
||||
## march 24th
|
||||
|
||||
- Added achievements system, hint overlay, and progress page polish
|
||||
|
||||
## march 23rd
|
||||
|
||||
- Extracted CollapsibleTable component and fixed show more behavior
|
||||
|
||||
## march 22nd
|
||||
|
||||
- Added `/api/send-daily-verse` endpoint for daily Discord verse posting
|
||||
- Improved guesses collapse timing, win screen CTA, and progress page polish
|
||||
- Fixed Discord message format (italic date + bold verse)
|
||||
|
||||
## march 21st
|
||||
|
||||
- Added progress page with activity calendar, book grid, and insights
|
||||
|
||||
## march 19th
|
||||
|
||||
- Added Discord link and shrunk guesses grid for more than three guesses
|
||||
- Added MAU section with projection to global stats
|
||||
- Added survival curve metrics and table minimizing to global stats
|
||||
|
||||
## march 15th–16th
|
||||
|
||||
- Fixed instructions, added color border based on closeness between guess and target
|
||||
- Added return rate and retention metrics to global stats
|
||||
- Added WAU history table, fixed retention metric, added new logos and favicon
|
||||
|
||||
## march 14th
|
||||
|
||||
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
|
||||
|
||||
Reference in New Issue
Block a user