Remove decryption and api key storage
This commit is contained in:
parent
2e12edc249
commit
15b76f330b
8 changed files with 326 additions and 233 deletions
|
|
@ -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<UserPreferences, 'geminiApiKey' | 'openRouterApiKey' | 'openAIApiKey'>;
|
||||
|
||||
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<string, unknown>;
|
||||
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();
|
||||
|
|
|
|||
100
scripts/migrate-default-config.js
Normal file
100
scripts/migrate-default-config.js
Normal file
|
|
@ -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.');
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>(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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue