diff --git a/drizzle/0002_outstanding_hiroim.sql b/drizzle/0002_outstanding_hiroim.sql new file mode 100644 index 0000000..3cbd0e3 --- /dev/null +++ b/drizzle/0002_outstanding_hiroim.sql @@ -0,0 +1,10 @@ +ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint +ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint +ALTER TABLE `user` ADD `email` text;--> statement-breakpoint +ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint +ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint +ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint +CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7d71863 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,275 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed", + "prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d", + "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 + }, + "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 + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 946f8e8..8d86c46 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1770266674489, "tag": "0001_loose_kree", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1770961427714, + "tag": "0002_outstanding_hiroim", + "breakpoints": true } ] } \ No newline at end of file diff --git a/analyze_top_users.sh b/scripts/analyze_top_users.sh similarity index 100% rename from analyze_top_users.sh rename to scripts/analyze_top_users.sh diff --git a/clear-today-verse.sh b/scripts/clear-today-verse.sh similarity index 100% rename from clear-today-verse.sh rename to scripts/clear-today-verse.sh diff --git a/daily_completions_report.sh b/scripts/daily_completions_report.sh similarity index 100% rename from daily_completions_report.sh rename to scripts/daily_completions_report.sh diff --git a/src/lib/components/AuthModal.svelte b/src/lib/components/AuthModal.svelte index 3f184f2..f6e0787 100644 --- a/src/lib/components/AuthModal.svelte +++ b/src/lib/components/AuthModal.svelte @@ -73,7 +73,7 @@ } - + {#if isOpen}
@@ -91,6 +91,25 @@
+
+ + +
+ +
+
+ or +
+
+
{ + const header = { alg: 'ES256', kid: env.APPLE_KEY_ID! }; + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: env.APPLE_TEAM_ID!, + iat: now, + exp: now + 86400 * 180, + aud: 'https://appleid.apple.com', + sub: env.APPLE_ID! + }; + + const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header))); + const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload))); + const signingInput = `${encodedHeader}.${encodedPayload}`; + + // Import PEM private key + const pemBody = env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '') + .replace(/-----END PRIVATE KEY-----/, '') + .replace(/\s/g, ''); + const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); + + const key = await crypto.subtle.importKey( + 'pkcs8', + keyBuffer, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ); + + const signatureBuffer = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new TextEncoder().encode(signingInput) + ); + + const signature = new Uint8Array(signatureBuffer); + + // crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime + // Raw format is exactly 64 bytes (32-byte r + 32-byte s) + const rawSignature = signature.length === 64 ? signature : derToRaw(signature); + const encodedSignature = encodeBase64url(rawSignature); + + return `${signingInput}.${encodedSignature}`; +} + +/** + * Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256) + */ +function derToRaw(der: Uint8Array): Uint8Array { + // DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] + let offset = 2; // skip 0x30 and total length + + // Read r + if (der[offset] !== 0x02) throw new Error('Invalid DER signature'); + offset++; + const rLen = der[offset]; + offset++; + let r = der.slice(offset, offset + rLen); + offset += rLen; + + // Read s + if (der[offset] !== 0x02) throw new Error('Invalid DER signature'); + offset++; + const sLen = der[offset]; + offset++; + let s = der.slice(offset, offset + sLen); + + // Remove leading zero padding (DER uses it for positive sign) + if (r.length === 33 && r[0] === 0) r = r.slice(1); + if (s.length === 33 && s[0] === 0) s = s.slice(1); + + // Pad to 32 bytes each + const raw = new Uint8Array(64); + raw.set(r, 32 - r.length); + raw.set(s, 64 - s.length); + return raw; +} + +export async function exchangeAppleCode( + code: string, + redirectUri: string +): Promise<{ + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + id_token: string; +}> { + const clientSecret = await generateAppleClientSecret(); + + const params = new URLSearchParams({ + client_id: env.APPLE_ID!, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }); + + const response = await fetch(APPLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Apple token exchange failed: ${error}`); + } + + return await response.json(); +} + +/** + * Decode Apple's id_token JWT payload without signature verification. + * Safe because the token is received directly from Apple's token endpoint over TLS. + */ +export function decodeAppleIdToken(idToken: string): { + sub: string; + email?: string; + email_verified?: string; + is_private_email?: 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; +} diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts index a7e835f..397cdfe 100644 --- a/src/lib/server/auth.test.ts +++ b/src/lib/server/auth.test.ts @@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas id: anonymousId, // Use anonymousId as the user ID to preserve stats email, passwordHash, + appleId: null, firstName: firstName || null, lastName: lastName || null, isPrivate: false diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 0b5fd12..bc8b01f 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas id: anonymousId, // Use anonymousId as the user ID to preserve stats email, passwordHash, + appleId: null, firstName: firstName || null, lastName: lastName || null, isPrivate: false @@ -113,3 +114,48 @@ export async function getUserByEmail(email: string) { const [user] = await db.select().from(table.user).where(eq(table.user.email, email)); return user || null; } + +export async function getUserByAppleId(appleId: string) { + const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId)); + return user || null; +} + +export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) { + if (!anonymousId || anonymousId === userId) return; + + try { + const { dailyCompletions } = await import('$lib/server/db/schema'); + + const anonCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, anonymousId)); + + const userCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const userDates = new Set(userCompletions.map((c) => c.date)); + + let migrated = 0; + let skipped = 0; + + for (const completion of anonCompletions) { + if (!userDates.has(completion.date)) { + await db + .update(dailyCompletions) + .set({ anonymousId: userId }) + .where(eq(dailyCompletions.id, completion.id)); + migrated++; + } else { + await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id)); + skipped++; + } + } + + console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`); + } catch (error) { + console.error('Error migrating anonymous stats:', error); + } +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 5915e30..72f2fcf 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -7,6 +7,7 @@ export const user = sqliteTable('user', { lastName: text('last_name'), email: text('email').unique(), passwordHash: text('password_hash'), + appleId: text('apple_id').unique(), isPrivate: integer('is_private', { mode: 'boolean' }).default(false) }); diff --git a/src/routes/auth/apple/+page.server.ts b/src/routes/auth/apple/+page.server.ts new file mode 100644 index 0000000..3acee75 --- /dev/null +++ b/src/routes/auth/apple/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { getAppleAuthUrl } from '$lib/server/apple-auth'; +import { encodeBase64url } from '@oslojs/encoding'; + +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 = encodeBase64url(stateBytes); + + // Store state + anonymousId in a short-lived cookie + // sameSite 'none' + secure required because Apple POSTs cross-origin + cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'none', + maxAge: 600 + }); + + redirect(302, getAppleAuthUrl(state)); + } +}; diff --git a/src/routes/auth/apple/callback/+server.ts b/src/routes/auth/apple/callback/+server.ts new file mode 100644 index 0000000..78f1b08 --- /dev/null +++ b/src/routes/auth/apple/callback/+server.ts @@ -0,0 +1,123 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth'; +import { env as publicEnv } from '$env/dynamic/public'; +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 POST: RequestHandler = async ({ request, cookies }) => { + const formData = await request.formData(); + const code = formData.get('code')?.toString(); + const state = formData.get('state')?.toString(); + // Apple sends user info as JSON string on FIRST authorization only + const userInfoStr = formData.get('user')?.toString(); + + // Validate CSRF state + const storedRaw = cookies.get('apple_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('apple_oauth_state', { path: '/' }); + + // Exchange authorization code for tokens + const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`); + const claims = decodeAppleIdToken(tokens.id_token); + const appleId = claims.sub; + + // Parse user info (only present on first authorization) + let appleFirstName: string | undefined; + let appleLastName: string | undefined; + if (userInfoStr) { + try { + const userInfo = JSON.parse(userInfoStr); + appleFirstName = userInfo.name?.firstName; + appleLastName = userInfo.name?.lastName; + } catch { + /* ignore parse errors */ + } + } + + // --- User resolution --- + let userId: string; + + // 1. Check if a user with this appleId already exists (returning user) + const existingAppleUser = await auth.getUserByAppleId(appleId); + + if (existingAppleUser) { + userId = existingAppleUser.id; + await auth.migrateAnonymousStats(stored.anonymousId, userId); + } else if (claims.email) { + // 2. Check if email matches an existing email/password user + const existingEmailUser = await auth.getUserByEmail(claims.email); + if (existingEmailUser) { + // Link Apple account to existing user + await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id)); + userId = existingEmailUser.id; + await auth.migrateAnonymousStats(stored.anonymousId, userId); + } else { + // 3. Brand new user — use anonymousId as user ID to preserve local stats + userId = stored.anonymousId || crypto.randomUUID(); + try { + await db.insert(userTable).values({ + id: userId, + email: claims.email, + passwordHash: null, + appleId, + firstName: appleFirstName || null, + lastName: appleLastName || null, + isPrivate: false + }); + } catch (e: any) { + // Handle race condition: if appleId was inserted between our check and insert + if (e?.message?.includes('UNIQUE constraint')) { + const retryUser = await auth.getUserByAppleId(appleId); + if (retryUser) { + userId = retryUser.id; + } else { + throw error(500, 'Failed to create user'); + } + } else { + throw e; + } + } + } + } else { + // No email from Apple — create account with appleId only + userId = stored.anonymousId || crypto.randomUUID(); + try { + await db.insert(userTable).values({ + id: userId, + email: null, + passwordHash: null, + appleId, + firstName: appleFirstName || null, + lastName: appleLastName || null, + isPrivate: false + }); + } catch (e: any) { + if (e?.message?.includes('UNIQUE constraint')) { + const retryUser = await auth.getUserByAppleId(appleId); + if (retryUser) { + userId = retryUser.id; + } 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, '/'); +}; diff --git a/src/routes/auth/signin/+page.server.ts b/src/routes/auth/signin/+page.server.ts index 02c1a7d..1cd91bb 100644 --- a/src/routes/auth/signin/+page.server.ts +++ b/src/routes/auth/signin/+page.server.ts @@ -1,9 +1,6 @@ -import { redirect, fail } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; import type { Actions } from './$types'; import * as auth from '$lib/server/auth'; -import { db } from '$lib/server/db'; -import { dailyCompletions } from '$lib/server/db/schema'; -import { eq, inArray } from 'drizzle-orm'; export const actions: Actions = { default: async ({ request, cookies }) => { @@ -40,49 +37,7 @@ export const actions: Actions = { } // Migrate anonymous stats if different anonymous ID - if (anonymousId && anonymousId !== user.id) { - try { - // Get completions for both the anonymous ID and the user ID - const anonCompletions = await db - .select() - .from(dailyCompletions) - .where(eq(dailyCompletions.anonymousId, anonymousId)); - - const userCompletions = await db - .select() - .from(dailyCompletions) - .where(eq(dailyCompletions.anonymousId, user.id)); - - // Create a set of dates the user already has completions for - const userDates = new Set(userCompletions.map(c => c.date)); - - let migrated = 0; - let skipped = 0; - - // Migrate only non-conflicting completions - for (const completion of anonCompletions) { - if (!userDates.has(completion.date)) { - // No conflict - safe to migrate - await db - .update(dailyCompletions) - .set({ anonymousId: user.id }) - .where(eq(dailyCompletions.id, completion.id)); - migrated++; - } else { - // Conflict exists - delete the anonymous completion (keep user's existing one) - await db - .delete(dailyCompletions) - .where(eq(dailyCompletions.id, completion.id)); - skipped++; - } - } - - console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`); - } catch (error) { - console.error('Error migrating anonymous stats:', error); - // Don't fail the signin if stats migration fails - } - } + await auth.migrateAnonymousStats(anonymousId, user.id); // Create session const sessionToken = auth.generateSessionToken();