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 type { UserPreferences } from '../types';
|
||||||
import { COLOR_SCHEMES } 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 = {
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
colorScheme: 'blue',
|
colorScheme: 'blue',
|
||||||
aiProvider: 'gemini',
|
aiProvider: 'gemini',
|
||||||
|
|
@ -51,7 +101,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||||
const response = await authFetch('/api/users/me/preferences');
|
const response = await authFetch('/api/users/me/preferences');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const prefs: UserPreferences = {
|
const serverPrefs: UserPreferences = {
|
||||||
colorScheme: data.colorScheme || 'blue',
|
colorScheme: data.colorScheme || 'blue',
|
||||||
aiProvider: data.aiProvider || 'gemini',
|
aiProvider: data.aiProvider || 'gemini',
|
||||||
geminiApiKey: data.geminiApiKey || undefined,
|
geminiApiKey: data.geminiApiKey || undefined,
|
||||||
|
|
@ -61,9 +111,17 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||||
openAIApiKey: data.openAIApiKey || undefined,
|
openAIApiKey: data.openAIApiKey || undefined,
|
||||||
openAIModel: data.openAIModel || 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);
|
setHasAIAccess(data.hasAIAccess || false);
|
||||||
applyColorScheme(prefs.colorScheme);
|
applyColorScheme(mergedPrefs.colorScheme);
|
||||||
|
|
||||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||||
try {
|
try {
|
||||||
|
|
@ -91,17 +149,34 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||||
const savePreferences = useCallback(async (prefs: UserPreferences) => {
|
const savePreferences = useCallback(async (prefs: UserPreferences) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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', {
|
const response = await authFetch('/api/users/me/preferences', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(prefs),
|
body: JSON.stringify(serverPrefs),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to save preferences');
|
throw new Error('Failed to save preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreferences(prefs);
|
const normalizedLocalKeys = readLocalApiKeys();
|
||||||
applyColorScheme(prefs.colorScheme);
|
const mergedPrefs = mergePreferencesWithLocalKeys(nextPrefs, normalizedLocalKeys);
|
||||||
|
setPreferences(mergedPrefs);
|
||||||
|
applyColorScheme(mergedPrefs.colorScheme);
|
||||||
toast.success('Preferences saved!');
|
toast.success('Preferences saved!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||||
|
|
@ -110,7 +185,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [authFetch, preferences]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPreferences();
|
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');
|
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
|
||||||
db.exec(schema);
|
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 runMigrations = () => {
|
||||||
const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
|
const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
|
||||||
const hasGameConfig = tableInfo.some(col => col.name === 'game_config');
|
const hasGameConfig = tableInfo.some(col => col.name === 'game_config');
|
||||||
|
|
@ -33,6 +40,82 @@ const runMigrations = () => {
|
||||||
console.log("Migration: Added default_game_config to users");
|
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();
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='game_sessions'").get();
|
||||||
if (!tables) {
|
if (!tables) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|
@ -61,17 +144,11 @@ const runMigrations = () => {
|
||||||
console.log("Migration: Added first_correct_player_id to game_sessions");
|
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");
|
const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
|
||||||
if (!hasColorScheme) {
|
if (!hasColorScheme) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
|
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
|
||||||
console.log("Migration: Added color_scheme to users");
|
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");
|
const hasAiProvider = userTableInfo2.some(col => col.name === "ai_provider");
|
||||||
if (!hasAiProvider) {
|
if (!hasAiProvider) {
|
||||||
|
|
@ -79,24 +156,12 @@ const runMigrations = () => {
|
||||||
console.log("Migration: Added ai_provider to users");
|
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");
|
const hasOpenRouterModel = userTableInfo2.some(col => col.name === "openrouter_model");
|
||||||
if (!hasOpenRouterModel) {
|
if (!hasOpenRouterModel) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT");
|
||||||
console.log("Migration: Added openrouter_model to users");
|
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");
|
const hasOpenAIModel = userTableInfo2.some(col => col.name === "openai_model");
|
||||||
if (!hasOpenAIModel) {
|
if (!hasOpenAIModel) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN openai_model TEXT");
|
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 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");
|
const hasShareToken = quizTableInfo.some(col => col.name === "share_token");
|
||||||
if (!hasShareToken) {
|
if (!hasShareToken) {
|
||||||
db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT");
|
db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT");
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login DATETIME,
|
last_login DATETIME,
|
||||||
default_game_config TEXT,
|
default_game_config TEXT,
|
||||||
color_scheme TEXT DEFAULT 'blue',
|
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 subscription fields
|
||||||
stripe_customer_id TEXT UNIQUE,
|
stripe_customer_id TEXT UNIQUE,
|
||||||
subscription_status TEXT DEFAULT 'none',
|
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')),
|
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
||||||
ai_topic TEXT,
|
ai_topic TEXT,
|
||||||
game_config TEXT,
|
game_config TEXT,
|
||||||
|
shared_by TEXT,
|
||||||
share_token TEXT UNIQUE,
|
share_token TEXT UNIQUE,
|
||||||
is_shared INTEGER DEFAULT 0,
|
is_shared INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
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_payments_user ON payments(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_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 quizId = uuidv4();
|
||||||
|
|
||||||
const upsertUser = db.prepare(`
|
const upsertUser = db.prepare(`
|
||||||
INSERT INTO users (id, username, email, display_name, last_login)
|
INSERT INTO users (id, last_login)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
last_login = CURRENT_TIMESTAMP,
|
last_login = CURRENT_TIMESTAMP
|
||||||
email = COALESCE(excluded.email, users.email),
|
|
||||||
display_name = COALESCE(excluded.display_name, users.display_name)
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertQuiz = db.prepare(`
|
const insertQuiz = db.prepare(`
|
||||||
|
|
@ -183,10 +181,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
const transaction = db.transaction(() => {
|
||||||
upsertUser.run(
|
upsertUser.run(
|
||||||
req.user!.sub,
|
req.user!.sub
|
||||||
req.user!.preferred_username,
|
|
||||||
req.user!.email || null,
|
|
||||||
req.user!.name || null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null);
|
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 shareToken = randomBytes(16).toString('base64url');
|
||||||
|
const sharedBy = req.user!.name || req.user!.preferred_username || null;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
UPDATE quizzes
|
||||||
`).run(shareToken, quizId);
|
SET share_token = ?, shared_by = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(shareToken, sharedBy, quizId);
|
||||||
|
|
||||||
res.json({ shareToken });
|
res.json({ shareToken });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ interface QuizRow {
|
||||||
gameConfig: string | null;
|
gameConfig: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
sharedByUsername: string | null;
|
sharedBy: string | null;
|
||||||
sharedByDisplayName: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuestionRow {
|
interface QuestionRow {
|
||||||
|
|
@ -41,9 +40,8 @@ router.get('/:token', (req: Request, res: Response) => {
|
||||||
SELECT
|
SELECT
|
||||||
q.id, q.title, q.source, q.ai_topic as aiTopic, q.game_config as gameConfig,
|
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,
|
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
|
FROM quizzes q
|
||||||
LEFT JOIN users u ON q.user_id = u.id
|
|
||||||
WHERE q.share_token = ? AND q.is_shared = 1
|
WHERE q.share_token = ? AND q.is_shared = 1
|
||||||
`).get(token) as QuizRow | undefined;
|
`).get(token) as QuizRow | undefined;
|
||||||
|
|
||||||
|
|
@ -92,7 +90,7 @@ router.get('/:token', (req: Request, res: Response) => {
|
||||||
gameConfig: parsedConfig,
|
gameConfig: parsedConfig,
|
||||||
questions: questionsWithOptions,
|
questions: questionsWithOptions,
|
||||||
questionCount: questions.length,
|
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 newQuizId = uuidv4();
|
||||||
|
|
||||||
const upsertUser = db.prepare(`
|
const upsertUser = db.prepare(`
|
||||||
INSERT INTO users (id, username, email, display_name, last_login)
|
INSERT INTO users (id, last_login)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
last_login = CURRENT_TIMESTAMP,
|
last_login = CURRENT_TIMESTAMP
|
||||||
email = COALESCE(excluded.email, users.email),
|
|
||||||
display_name = COALESCE(excluded.display_name, users.display_name)
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertQuiz = db.prepare(`
|
const insertQuiz = db.prepare(`
|
||||||
|
|
@ -151,10 +147,7 @@ router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Respon
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
const transaction = db.transaction(() => {
|
||||||
upsertUser.run(
|
upsertUser.run(
|
||||||
req.user!.sub,
|
req.user!.sub
|
||||||
req.user!.preferred_username,
|
|
||||||
req.user!.email || null,
|
|
||||||
req.user!.name || null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);
|
insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import { db } from '../db/connection.js';
|
import { db } from '../db/connection.js';
|
||||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.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();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -9,25 +16,17 @@ router.use(requireAuth);
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
|
||||||
email: string | null;
|
|
||||||
displayName: string | null;
|
|
||||||
defaultGameConfig: string | null;
|
defaultGameConfig: string | null;
|
||||||
colorScheme: string | null;
|
colorScheme: string | null;
|
||||||
geminiApiKey: string | null;
|
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
lastLogin: string | null;
|
lastLogin: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptUserData(row: UserRow, userSub: string) {
|
function decryptUserData(row: UserRow) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
defaultGameConfig: parseDefaultConfig(row.defaultGameConfig),
|
||||||
email: decryptForUser(row.email, userSub),
|
|
||||||
displayName: decryptForUser(row.displayName, userSub),
|
|
||||||
defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub),
|
|
||||||
colorScheme: row.colorScheme,
|
colorScheme: row.colorScheme,
|
||||||
geminiApiKey: decryptForUser(row.geminiApiKey, userSub),
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
lastLogin: row.lastLogin,
|
lastLogin: row.lastLogin,
|
||||||
};
|
};
|
||||||
|
|
@ -37,8 +36,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
const userSub = req.user!.sub;
|
||||||
|
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
|
SELECT id, default_game_config as defaultGameConfig, color_scheme as colorScheme,
|
||||||
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
|
|
||||||
created_at as createdAt, last_login as lastLogin
|
created_at as createdAt, last_login as lastLogin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|
@ -55,7 +53,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||||
displayName: req.user!.name,
|
displayName: req.user!.name,
|
||||||
defaultGameConfig: null,
|
defaultGameConfig: null,
|
||||||
colorScheme: 'blue',
|
colorScheme: 'blue',
|
||||||
geminiApiKey: null,
|
|
||||||
hasAIAccess,
|
hasAIAccess,
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
|
|
@ -64,10 +61,13 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decrypted = decryptUserData(row, userSub);
|
const decrypted = decryptUserData(row);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...decrypted,
|
...decrypted,
|
||||||
|
username: req.user!.preferred_username,
|
||||||
|
email: req.user!.email,
|
||||||
|
displayName: req.user!.name,
|
||||||
hasAIAccess,
|
hasAIAccess,
|
||||||
isNew: false
|
isNew: false
|
||||||
});
|
});
|
||||||
|
|
@ -77,13 +77,11 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
const userSub = req.user!.sub;
|
||||||
const { defaultGameConfig } = req.body;
|
const { defaultGameConfig } = req.body;
|
||||||
|
|
||||||
const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub);
|
const configValue = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
|
||||||
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
|
|
||||||
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
|
|
||||||
|
|
||||||
const upsertUser = db.prepare(`
|
const upsertUser = db.prepare(`
|
||||||
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
|
INSERT INTO users (id, default_game_config, last_login)
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
default_game_config = ?,
|
default_game_config = ?,
|
||||||
last_login = CURRENT_TIMESTAMP
|
last_login = CURRENT_TIMESTAMP
|
||||||
|
|
@ -91,11 +89,8 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
upsertUser.run(
|
upsertUser.run(
|
||||||
userSub,
|
userSub,
|
||||||
req.user!.preferred_username,
|
configValue,
|
||||||
encryptedEmail,
|
configValue
|
||||||
encryptedDisplayName,
|
|
||||||
encryptedConfig,
|
|
||||||
encryptedConfig
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
@ -105,20 +100,17 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
const userSub = req.user!.sub;
|
||||||
|
|
||||||
const user = db.prepare(`
|
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,
|
gemini_model as geminiModel, ai_provider as aiProvider,
|
||||||
openrouter_api_key as openRouterApiKey, openrouter_model as openRouterModel,
|
openrouter_model as openRouterModel,
|
||||||
openai_api_key as openAIApiKey, openai_model as openAIModel
|
openai_model as openAIModel
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(userSub) as {
|
`).get(userSub) as {
|
||||||
colorScheme: string | null;
|
colorScheme: string | null;
|
||||||
geminiApiKey: string | null;
|
|
||||||
geminiModel: string | null;
|
geminiModel: string | null;
|
||||||
aiProvider: string | null;
|
aiProvider: string | null;
|
||||||
openRouterApiKey: string | null;
|
|
||||||
openRouterModel: string | null;
|
openRouterModel: string | null;
|
||||||
openAIApiKey: string | null;
|
|
||||||
openAIModel: string | null;
|
openAIModel: string | null;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
|
|
@ -128,11 +120,11 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
colorScheme: user?.colorScheme || 'blue',
|
colorScheme: user?.colorScheme || 'blue',
|
||||||
aiProvider: user?.aiProvider || 'gemini',
|
aiProvider: user?.aiProvider || 'gemini',
|
||||||
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
|
geminiApiKey: null,
|
||||||
geminiModel: user?.geminiModel || null,
|
geminiModel: user?.geminiModel || null,
|
||||||
openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub),
|
openRouterApiKey: null,
|
||||||
openRouterModel: user?.openRouterModel || null,
|
openRouterModel: user?.openRouterModel || null,
|
||||||
openAIApiKey: decryptForUser(user?.openAIApiKey || null, userSub),
|
openAIApiKey: null,
|
||||||
openAIModel: user?.openAIModel || null,
|
openAIModel: user?.openAIModel || null,
|
||||||
hasAIAccess,
|
hasAIAccess,
|
||||||
});
|
});
|
||||||
|
|
@ -140,49 +132,31 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userSub = req.user!.sub;
|
const userSub = req.user!.sub;
|
||||||
const { colorScheme, geminiApiKey, geminiModel, aiProvider, openRouterApiKey, openRouterModel, openAIApiKey, openAIModel } = req.body;
|
const { colorScheme, geminiModel, aiProvider, openRouterModel, 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 upsertUser = db.prepare(`
|
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)
|
INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
color_scheme = ?,
|
color_scheme = ?,
|
||||||
gemini_api_key = ?,
|
|
||||||
gemini_model = ?,
|
gemini_model = ?,
|
||||||
ai_provider = ?,
|
ai_provider = ?,
|
||||||
openrouter_api_key = ?,
|
|
||||||
openrouter_model = ?,
|
openrouter_model = ?,
|
||||||
openai_api_key = ?,
|
|
||||||
openai_model = ?,
|
openai_model = ?,
|
||||||
last_login = CURRENT_TIMESTAMP
|
last_login = CURRENT_TIMESTAMP
|
||||||
`);
|
`);
|
||||||
|
|
||||||
upsertUser.run(
|
upsertUser.run(
|
||||||
userSub,
|
userSub,
|
||||||
req.user!.preferred_username,
|
|
||||||
encryptedEmail,
|
|
||||||
encryptedDisplayName,
|
|
||||||
colorScheme || 'blue',
|
colorScheme || 'blue',
|
||||||
encryptedGeminiKey,
|
|
||||||
geminiModel || null,
|
geminiModel || null,
|
||||||
aiProvider || 'gemini',
|
aiProvider || 'gemini',
|
||||||
encryptedOpenRouterKey,
|
|
||||||
openRouterModel || null,
|
openRouterModel || null,
|
||||||
encryptedOpenAIKey,
|
|
||||||
openAIModel || null,
|
openAIModel || null,
|
||||||
colorScheme || 'blue',
|
colorScheme || 'blue',
|
||||||
encryptedGeminiKey,
|
|
||||||
geminiModel || null,
|
geminiModel || null,
|
||||||
aiProvider || 'gemini',
|
aiProvider || 'gemini',
|
||||||
encryptedOpenRouterKey,
|
|
||||||
openRouterModel || null,
|
openRouterModel || null,
|
||||||
encryptedOpenAIKey,
|
|
||||||
openAIModel || null
|
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