diff --git a/hooks/useUserPreferences.ts b/hooks/useUserPreferences.ts index 1e55fe2..bb81d0e 100644 --- a/hooks/useUserPreferences.ts +++ b/hooks/useUserPreferences.ts @@ -4,6 +4,56 @@ import { useAuthenticatedFetch } from './useAuthenticatedFetch'; import type { UserPreferences } from '../types'; import { COLOR_SCHEMES } from '../types'; +const LOCAL_API_KEYS_STORAGE = 'kaboot.apiKeys'; + +type LocalApiKeys = Pick; + +const normalizeApiKey = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +}; + +const readLocalApiKeys = (): LocalApiKeys => { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(LOCAL_API_KEYS_STORAGE); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return { + geminiApiKey: normalizeApiKey(parsed.geminiApiKey), + openRouterApiKey: normalizeApiKey(parsed.openRouterApiKey), + openAIApiKey: normalizeApiKey(parsed.openAIApiKey), + }; + } catch { + return {}; + } +}; + +const writeLocalApiKeys = (keys: LocalApiKeys) => { + if (typeof window === 'undefined') return; + const normalized = { + geminiApiKey: normalizeApiKey(keys.geminiApiKey), + openRouterApiKey: normalizeApiKey(keys.openRouterApiKey), + openAIApiKey: normalizeApiKey(keys.openAIApiKey), + }; + const hasAny = Boolean( + normalized.geminiApiKey || normalized.openRouterApiKey || normalized.openAIApiKey + ); + if (!hasAny) { + window.localStorage.removeItem(LOCAL_API_KEYS_STORAGE); + return; + } + window.localStorage.setItem(LOCAL_API_KEYS_STORAGE, JSON.stringify(normalized)); +}; + +const mergePreferencesWithLocalKeys = (prefs: UserPreferences, localKeys: LocalApiKeys): UserPreferences => ({ + ...prefs, + geminiApiKey: localKeys.geminiApiKey ?? prefs.geminiApiKey, + openRouterApiKey: localKeys.openRouterApiKey ?? prefs.openRouterApiKey, + openAIApiKey: localKeys.openAIApiKey ?? prefs.openAIApiKey, +}); + const DEFAULT_PREFERENCES: UserPreferences = { colorScheme: 'blue', aiProvider: 'gemini', @@ -51,7 +101,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { const response = await authFetch('/api/users/me/preferences'); if (response.ok) { const data = await response.json(); - const prefs: UserPreferences = { + const serverPrefs: UserPreferences = { colorScheme: data.colorScheme || 'blue', aiProvider: data.aiProvider || 'gemini', geminiApiKey: data.geminiApiKey || undefined, @@ -61,9 +111,17 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { openAIApiKey: data.openAIApiKey || undefined, openAIModel: data.openAIModel || undefined, }; - setPreferences(prefs); + const localKeys = readLocalApiKeys(); + const mergedPrefs = mergePreferencesWithLocalKeys(serverPrefs, localKeys); + const mergedLocalKeys: LocalApiKeys = { + geminiApiKey: localKeys.geminiApiKey ?? serverPrefs.geminiApiKey, + openRouterApiKey: localKeys.openRouterApiKey ?? serverPrefs.openRouterApiKey, + openAIApiKey: localKeys.openAIApiKey ?? serverPrefs.openAIApiKey, + }; + writeLocalApiKeys(mergedLocalKeys); + setPreferences(mergedPrefs); setHasAIAccess(data.hasAIAccess || false); - applyColorScheme(prefs.colorScheme); + applyColorScheme(mergedPrefs.colorScheme); const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; try { @@ -91,17 +149,34 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { const savePreferences = useCallback(async (prefs: UserPreferences) => { setSaving(true); try { + const nextPrefs: UserPreferences = { ...preferences, ...prefs }; + const localKeys: LocalApiKeys = { + geminiApiKey: nextPrefs.geminiApiKey, + openRouterApiKey: nextPrefs.openRouterApiKey, + openAIApiKey: nextPrefs.openAIApiKey, + }; + writeLocalApiKeys(localKeys); + + const serverPrefs = { + ...nextPrefs, + geminiApiKey: undefined, + openRouterApiKey: undefined, + openAIApiKey: undefined, + }; + const response = await authFetch('/api/users/me/preferences', { method: 'PUT', - body: JSON.stringify(prefs), + body: JSON.stringify(serverPrefs), }); if (!response.ok) { throw new Error('Failed to save preferences'); } - setPreferences(prefs); - applyColorScheme(prefs.colorScheme); + const normalizedLocalKeys = readLocalApiKeys(); + const mergedPrefs = mergePreferencesWithLocalKeys(nextPrefs, normalizedLocalKeys); + setPreferences(mergedPrefs); + applyColorScheme(mergedPrefs.colorScheme); toast.success('Preferences saved!'); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to save preferences'; @@ -110,7 +185,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { } finally { setSaving(false); } - }, [authFetch]); + }, [authFetch, preferences]); useEffect(() => { fetchPreferences(); diff --git a/scripts/migrate-default-config.js b/scripts/migrate-default-config.js new file mode 100644 index 0000000..7ceef42 --- /dev/null +++ b/scripts/migrate-default-config.js @@ -0,0 +1,100 @@ +import Database from 'better-sqlite3'; +import { createDecipheriv, createHash, hkdfSync } from 'crypto'; +import { join } from 'path'; + +const DB_PATH = process.env.DATABASE_PATH || join(process.cwd(), 'data', 'kaboot.db'); +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const SALT_LENGTH = 16; + +if (!ENCRYPTION_KEY) { + console.error('ENCRYPTION_KEY is required to migrate encrypted default_game_config values.'); + process.exit(1); +} + +const masterKey = createHash('sha256').update(ENCRYPTION_KEY).digest(); + +const isJsonString = (value) => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +const decryptForUser = (ciphertext, userSub) => { + if (!ciphertext) return null; + + try { + const combined = Buffer.from(ciphertext, 'base64'); + if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) { + return null; + } + + const salt = combined.subarray(0, SALT_LENGTH); + const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); + + const key = Buffer.from( + hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32) + ); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } catch { + return null; + } +}; + +const db = new Database(DB_PATH); + +const rows = db.prepare(` + SELECT id, default_game_config as defaultGameConfig + FROM users + WHERE default_game_config IS NOT NULL +`).all(); + +const update = db.prepare('UPDATE users SET default_game_config = ? WHERE id = ?'); + +let migrated = 0; +let alreadyPlaintext = 0; +let failed = 0; + +const migrate = db.transaction(() => { + for (const row of rows) { + const value = row.defaultGameConfig; + if (!value) continue; + + if (isJsonString(value)) { + alreadyPlaintext += 1; + continue; + } + + const decrypted = decryptForUser(value, row.id); + if (decrypted && isJsonString(decrypted)) { + update.run(decrypted, row.id); + migrated += 1; + } else { + failed += 1; + } + } +}); + +migrate(); + +console.log(`Default config migration complete.`); +console.log(`- migrated: ${migrated}`); +console.log(`- already plaintext: ${alreadyPlaintext}`); +console.log(`- failed: ${failed}`); + +if (failed > 0) { + console.warn('Some entries could not be decrypted. Check ENCRYPTION_KEY and retry if needed.'); +} diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index 8c43b2e..1dd8805 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -16,6 +16,13 @@ db.pragma('foreign_keys = ON'); const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); db.exec(schema); +const logOnce = (metaKey: string, message: string) => { + const logged = db.prepare('SELECT 1 FROM meta WHERE meta_key = ?').get(metaKey); + if (logged) return; + db.prepare('INSERT INTO meta (meta_key, value) VALUES (?, ?)').run(metaKey, new Date().toISOString()); + console.log(message); +}; + const runMigrations = () => { const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[]; const hasGameConfig = tableInfo.some(col => col.name === 'game_config'); @@ -32,7 +39,83 @@ const runMigrations = () => { db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT"); console.log("Migration: Added default_game_config to users"); } - + + let userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; + const deprecatedUserColumns = [ + 'gemini_api_key', + 'openrouter_api_key', + 'openai_api_key', + 'username', + 'email', + 'display_name', + ]; + const hasDeprecatedColumns = userTableInfo2.some(col => deprecatedUserColumns.includes(col.name)); + + let didDropApiKeyColumns = false; + if (hasDeprecatedColumns) { + didDropApiKeyColumns = true; + const userColumns = [ + { name: 'id', definition: 'TEXT PRIMARY KEY' }, + { name: 'created_at', definition: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'last_login', definition: 'DATETIME' }, + { name: 'default_game_config', definition: 'TEXT' }, + { name: 'color_scheme', definition: "TEXT DEFAULT 'blue'" }, + { name: 'gemini_model', definition: 'TEXT' }, + { name: 'ai_provider', definition: "TEXT DEFAULT 'gemini'" }, + { name: 'openrouter_model', definition: 'TEXT' }, + { name: 'openai_model', definition: 'TEXT' }, + { name: 'stripe_customer_id', definition: 'TEXT UNIQUE' }, + { name: 'subscription_status', definition: "TEXT DEFAULT 'none'" }, + { name: 'subscription_id', definition: 'TEXT' }, + { name: 'subscription_current_period_end', definition: 'DATETIME' }, + { name: 'generation_count', definition: 'INTEGER DEFAULT 0' }, + { name: 'generation_reset_date', definition: 'DATETIME' }, + ]; + + const existingColumns = new Set(userTableInfo2.map(col => col.name)); + const columnDefaults: Record = { + color_scheme: "'blue'", + ai_provider: "'gemini'", + subscription_status: "'none'", + generation_count: '0', + created_at: 'CURRENT_TIMESTAMP', + }; + + const createColumnsSql = userColumns + .map(col => `${col.name} ${col.definition}`) + .join(',\n '); + const insertColumnsSql = userColumns.map(col => col.name).join(', '); + const selectColumnsSql = userColumns + .map(col => ( + existingColumns.has(col.name) + ? col.name + : `${columnDefaults[col.name] ?? 'NULL'} AS ${col.name}` + )) + .join(', '); + + db.exec('BEGIN'); + db.exec('ALTER TABLE users RENAME TO users_old'); + db.exec(` + CREATE TABLE users ( + ${createColumnsSql} + ); + `); + db.exec(` + INSERT INTO users (${insertColumnsSql}) + SELECT ${selectColumnsSql} + FROM users_old; + `); + db.exec('DROP TABLE users_old'); + db.exec('COMMIT'); + userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; + } + if (didDropApiKeyColumns) { + logOnce( + 'migration.users_drop_api_keys', + 'Migration: Removed deprecated user columns (API keys and identity fields) from users.' + ); + } + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='game_sessions'").get(); if (!tables) { db.exec(` @@ -61,17 +144,11 @@ const runMigrations = () => { console.log("Migration: Added first_correct_player_id to game_sessions"); } - const userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme"); if (!hasColorScheme) { db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'"); console.log("Migration: Added color_scheme to users"); } - const hasGeminiKey = userTableInfo2.some(col => col.name === "gemini_api_key"); - if (!hasGeminiKey) { - db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT"); - console.log("Migration: Added gemini_api_key to users"); - } const hasAiProvider = userTableInfo2.some(col => col.name === "ai_provider"); if (!hasAiProvider) { @@ -79,24 +156,12 @@ const runMigrations = () => { console.log("Migration: Added ai_provider to users"); } - const hasOpenRouterKey = userTableInfo2.some(col => col.name === "openrouter_api_key"); - if (!hasOpenRouterKey) { - db.exec("ALTER TABLE users ADD COLUMN openrouter_api_key TEXT"); - console.log("Migration: Added openrouter_api_key to users"); - } - const hasOpenRouterModel = userTableInfo2.some(col => col.name === "openrouter_model"); if (!hasOpenRouterModel) { db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT"); console.log("Migration: Added openrouter_model to users"); } - const hasOpenAIKey = userTableInfo2.some(col => col.name === "openai_api_key"); - if (!hasOpenAIKey) { - db.exec("ALTER TABLE users ADD COLUMN openai_api_key TEXT"); - console.log("Migration: Added openai_api_key to users"); - } - const hasOpenAIModel = userTableInfo2.some(col => col.name === "openai_model"); if (!hasOpenAIModel) { db.exec("ALTER TABLE users ADD COLUMN openai_model TEXT"); @@ -110,6 +175,11 @@ const runMigrations = () => { } const quizTableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[]; + const hasSharedBy = quizTableInfo.some(col => col.name === "shared_by"); + if (!hasSharedBy) { + db.exec("ALTER TABLE quizzes ADD COLUMN shared_by TEXT"); + console.log("Migration: Added shared_by to quizzes"); + } const hasShareToken = quizTableInfo.some(col => col.name === "share_token"); if (!hasShareToken) { db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT"); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 2c7b471..5c739e6 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -1,13 +1,13 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - username TEXT NOT NULL, - email TEXT, - display_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME, default_game_config TEXT, color_scheme TEXT DEFAULT 'blue', - gemini_api_key TEXT, + gemini_model TEXT, + ai_provider TEXT DEFAULT 'gemini', + openrouter_model TEXT, + openai_model TEXT, -- Stripe subscription fields stripe_customer_id TEXT UNIQUE, subscription_status TEXT DEFAULT 'none', @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS quizzes ( source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')), ai_topic TEXT, game_config TEXT, + shared_by TEXT, share_token TEXT UNIQUE, is_shared INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -85,3 +86,8 @@ CREATE TABLE IF NOT EXISTS payments ( CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id); CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id); + +CREATE TABLE IF NOT EXISTS meta ( + meta_key TEXT PRIMARY KEY, + value TEXT +); diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts index a9eb34e..9677fa9 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -158,12 +158,10 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { const quizId = uuidv4(); const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, last_login) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (id, last_login) + VALUES (?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET - last_login = CURRENT_TIMESTAMP, - email = COALESCE(excluded.email, users.email), - display_name = COALESCE(excluded.display_name, users.display_name) + last_login = CURRENT_TIMESTAMP `); const insertQuiz = db.prepare(` @@ -183,10 +181,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { const transaction = db.transaction(() => { upsertUser.run( - req.user!.sub, - req.user!.preferred_username, - req.user!.email || null, - req.user!.name || null + req.user!.sub ); insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null); @@ -336,10 +331,13 @@ router.post('/:id/share', (req: AuthenticatedRequest, res: Response) => { } const shareToken = randomBytes(16).toString('base64url'); + const sharedBy = req.user!.name || req.user!.preferred_username || null; db.prepare(` - UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(shareToken, quizId); + UPDATE quizzes + SET share_token = ?, shared_by = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(shareToken, sharedBy, quizId); res.json({ shareToken }); }); diff --git a/server/src/routes/shared.ts b/server/src/routes/shared.ts index fe7f2fa..1260a07 100644 --- a/server/src/routes/shared.ts +++ b/server/src/routes/shared.ts @@ -13,8 +13,7 @@ interface QuizRow { gameConfig: string | null; createdAt: string; updatedAt: string; - sharedByUsername: string | null; - sharedByDisplayName: string | null; + sharedBy: string | null; } interface QuestionRow { @@ -41,9 +40,8 @@ router.get('/:token', (req: Request, res: Response) => { SELECT q.id, q.title, q.source, q.ai_topic as aiTopic, q.game_config as gameConfig, q.created_at as createdAt, q.updated_at as updatedAt, - u.username as sharedByUsername, u.display_name as sharedByDisplayName + q.shared_by as sharedBy FROM quizzes q - LEFT JOIN users u ON q.user_id = u.id WHERE q.share_token = ? AND q.is_shared = 1 `).get(token) as QuizRow | undefined; @@ -92,7 +90,7 @@ router.get('/:token', (req: Request, res: Response) => { gameConfig: parsedConfig, questions: questionsWithOptions, questionCount: questions.length, - sharedBy: quiz.sharedByDisplayName || quiz.sharedByUsername || null, + sharedBy: quiz.sharedBy || null, }); }); @@ -126,12 +124,10 @@ router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Respon const newQuizId = uuidv4(); const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, last_login) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (id, last_login) + VALUES (?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET - last_login = CURRENT_TIMESTAMP, - email = COALESCE(excluded.email, users.email), - display_name = COALESCE(excluded.display_name, users.display_name) + last_login = CURRENT_TIMESTAMP `); const insertQuiz = db.prepare(` @@ -151,10 +147,7 @@ router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Respon const transaction = db.transaction(() => { upsertUser.run( - req.user!.sub, - req.user!.preferred_username, - req.user!.email || null, - req.user!.name || null + req.user!.sub ); insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 795ba6f..9b3e580 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -1,7 +1,14 @@ import { Router, Response } from 'express'; import { db } from '../db/connection.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; -import { encryptForUser, decryptForUser, encryptJsonForUser, decryptJsonForUser } from '../services/encryption.js'; +const parseDefaultConfig = (value: string | null): unknown | null => { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +}; const router = Router(); @@ -9,25 +16,17 @@ router.use(requireAuth); interface UserRow { id: string; - username: string; - email: string | null; - displayName: string | null; defaultGameConfig: string | null; colorScheme: string | null; - geminiApiKey: string | null; createdAt: string | null; lastLogin: string | null; } -function decryptUserData(row: UserRow, userSub: string) { +function decryptUserData(row: UserRow) { return { id: row.id, - username: row.username, - email: decryptForUser(row.email, userSub), - displayName: decryptForUser(row.displayName, userSub), - defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub), + defaultGameConfig: parseDefaultConfig(row.defaultGameConfig), colorScheme: row.colorScheme, - geminiApiKey: decryptForUser(row.geminiApiKey, userSub), createdAt: row.createdAt, lastLogin: row.lastLogin, }; @@ -37,8 +36,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; const row = db.prepare(` - SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, - color_scheme as colorScheme, gemini_api_key as geminiApiKey, + SELECT id, default_game_config as defaultGameConfig, color_scheme as colorScheme, created_at as createdAt, last_login as lastLogin FROM users WHERE id = ? @@ -55,7 +53,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { displayName: req.user!.name, defaultGameConfig: null, colorScheme: 'blue', - geminiApiKey: null, hasAIAccess, createdAt: null, lastLogin: null, @@ -64,10 +61,13 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { return; } - const decrypted = decryptUserData(row, userSub); + const decrypted = decryptUserData(row); res.json({ ...decrypted, + username: req.user!.preferred_username, + email: req.user!.email, + displayName: req.user!.name, hasAIAccess, isNew: false }); @@ -77,13 +77,11 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; const { defaultGameConfig } = req.body; - const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub); - const encryptedEmail = encryptForUser(req.user!.email || null, userSub); - const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub); + const configValue = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null; const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, default_game_config, last_login) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (id, default_game_config, last_login) + VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET default_game_config = ?, last_login = CURRENT_TIMESTAMP @@ -91,11 +89,8 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => { upsertUser.run( userSub, - req.user!.preferred_username, - encryptedEmail, - encryptedDisplayName, - encryptedConfig, - encryptedConfig + configValue, + configValue ); res.json({ success: true }); @@ -105,20 +100,17 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; const user = db.prepare(` - SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey, + SELECT color_scheme as colorScheme, gemini_model as geminiModel, ai_provider as aiProvider, - openrouter_api_key as openRouterApiKey, openrouter_model as openRouterModel, - openai_api_key as openAIApiKey, openai_model as openAIModel + openrouter_model as openRouterModel, + openai_model as openAIModel FROM users WHERE id = ? `).get(userSub) as { colorScheme: string | null; - geminiApiKey: string | null; geminiModel: string | null; aiProvider: string | null; - openRouterApiKey: string | null; openRouterModel: string | null; - openAIApiKey: string | null; openAIModel: string | null; } | undefined; @@ -128,11 +120,11 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { res.json({ colorScheme: user?.colorScheme || 'blue', aiProvider: user?.aiProvider || 'gemini', - geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub), + geminiApiKey: null, geminiModel: user?.geminiModel || null, - openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub), + openRouterApiKey: null, openRouterModel: user?.openRouterModel || null, - openAIApiKey: decryptForUser(user?.openAIApiKey || null, userSub), + openAIApiKey: null, openAIModel: user?.openAIModel || null, hasAIAccess, }); @@ -140,49 +132,31 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; - const { colorScheme, geminiApiKey, geminiModel, aiProvider, openRouterApiKey, openRouterModel, openAIApiKey, openAIModel } = req.body; - - const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub); - const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub); - const encryptedOpenAIKey = encryptForUser(openAIApiKey || null, userSub); - const encryptedEmail = encryptForUser(req.user!.email || null, userSub); - const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub); + const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body; const upsertUser = db.prepare(` - INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, gemini_model, ai_provider, openrouter_api_key, openrouter_model, openai_api_key, openai_model, last_login) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET color_scheme = ?, - gemini_api_key = ?, gemini_model = ?, ai_provider = ?, - openrouter_api_key = ?, openrouter_model = ?, - openai_api_key = ?, openai_model = ?, last_login = CURRENT_TIMESTAMP `); upsertUser.run( userSub, - req.user!.preferred_username, - encryptedEmail, - encryptedDisplayName, colorScheme || 'blue', - encryptedGeminiKey, geminiModel || null, aiProvider || 'gemini', - encryptedOpenRouterKey, openRouterModel || null, - encryptedOpenAIKey, openAIModel || null, colorScheme || 'blue', - encryptedGeminiKey, geminiModel || null, aiProvider || 'gemini', - encryptedOpenRouterKey, openRouterModel || null, - encryptedOpenAIKey, openAIModel || null ); diff --git a/server/src/services/encryption.ts b/server/src/services/encryption.ts deleted file mode 100644 index 40d0e7d..0000000 --- a/server/src/services/encryption.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; -const AUTH_TAG_LENGTH = 16; -const SALT_LENGTH = 16; - -let encryptionWarningShown = false; - -function getMasterKey(): Buffer | null { - const key = process.env.ENCRYPTION_KEY; - if (!key) { - if (!encryptionWarningShown) { - console.warn('[SECURITY WARNING] ENCRYPTION_KEY not set. User data will NOT be encrypted.'); - console.warn('Generate one with: openssl rand -base64 36'); - encryptionWarningShown = true; - } - return null; - } - return createHash('sha256').update(key).digest(); -} - -function deriveUserKey(userSub: string, salt: Buffer): Buffer | null { - const masterKey = getMasterKey(); - if (!masterKey) return null; - - return Buffer.from( - hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32) - ); -} - -export function encryptForUser(plaintext: string | null | undefined, userSub: string): string | null { - if (plaintext === null || plaintext === undefined || plaintext === '') { - return null; - } - - const salt = randomBytes(SALT_LENGTH); - const key = deriveUserKey(userSub, salt); - - if (!key) { - return plaintext; - } - - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, key, iv); - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final() - ]); - const authTag = cipher.getAuthTag(); - - const combined = Buffer.concat([salt, iv, authTag, encrypted]); - return combined.toString('base64'); -} - -export function decryptForUser(ciphertext: string | null | undefined, userSub: string): string | null { - if (ciphertext === null || ciphertext === undefined || ciphertext === '') { - return null; - } - - const masterKey = getMasterKey(); - if (!masterKey) { - return ciphertext; - } - - try { - const combined = Buffer.from(ciphertext, 'base64'); - - if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) { - return ciphertext; - } - - const salt = combined.subarray(0, SALT_LENGTH); - const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); - const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); - const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); - - const key = Buffer.from( - hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32) - ); - - const decipher = createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]); - - return decrypted.toString('utf8'); - } catch { - return ciphertext; - } -} - -export function isEncrypted(value: string | null | undefined): boolean { - if (!value) return false; - - try { - const decoded = Buffer.from(value, 'base64'); - return decoded.length >= SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1; - } catch { - return false; - } -} - -export function encryptJsonForUser(data: object | null | undefined, userSub: string): string | null { - if (data === null || data === undefined) { - return null; - } - return encryptForUser(JSON.stringify(data), userSub); -} - -export function decryptJsonForUser(ciphertext: string | null | undefined, userSub: string): T | null { - const plaintext = decryptForUser(ciphertext, userSub); - if (!plaintext) return null; - - try { - return JSON.parse(plaintext) as T; - } catch { - return null; - } -}