From e480ad06df319120e2e1f78697ba504f6db7f03e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 15 Jan 2026 10:12:05 -0700 Subject: [PATCH 1/2] Add server security hardening and draft quiz persistence Security: - Add AES-256-GCM encryption for user PII (email, API keys, config) - Add rate limiting (helmet + express-rate-limit) - Require auth for file uploads UX: - Persist draft quizzes to sessionStorage (survives refresh) - Add URL-based edit routes (/edit/draft, /edit/:quizId) - Fix QuizEditor async defaultConfig race condition - Fix URL param accumulation in Landing --- .env.example | 1 + components/Landing.tsx | 53 +- components/QuizEditor.tsx | 11 +- docker-compose.prod.yml | 1 + docker-compose.yml | 1 + hooks/useGame.ts | 91 +- hooks/useQuizLibrary.ts | 6 +- scripts/setup.sh | 3 + server/package-lock.json | 50 ++ server/package.json | 3 + server/src/index.ts | 28 + server/src/routes/games.ts | 12 +- server/src/routes/upload.ts | 3 + server/src/routes/users.ts | 87 +- server/src/services/encryption.ts | 123 +++ .../components/QuizEditorAsyncConfig.test.tsx | 495 +++++++++++ tests/hooks/useGame.draftPersistence.test.tsx | 782 ++++++++++++++++++ tests/hooks/useGame.navigation.test.tsx | 119 ++- 18 files changed, 1775 insertions(+), 94 deletions(-) create mode 100644 server/src/services/encryption.ts create mode 100644 tests/components/QuizEditorAsyncConfig.test.tsx create mode 100644 tests/hooks/useGame.draftPersistence.test.tsx diff --git a/.env.example b/.env.example index 898f524..343f7c2 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ # ============================================================================== PG_PASS= AUTHENTIK_SECRET_KEY= +ENCRYPTION_KEY= # ============================================================================== # HOST CONFIGURATION diff --git a/components/Landing.tsx b/components/Landing.tsx index 22a91c1..b9b61b1 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -39,11 +39,13 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const setMode = (newMode: 'HOST' | 'JOIN') => { setModeState(newMode); - const newParams = new URLSearchParams(searchParams); + const newParams = new URLSearchParams(); + const modalParam = searchParams.get('modal'); + if (modalParam) { + newParams.set('modal', modalParam); + } if (newMode === 'HOST') { newParams.set('mode', 'host'); - } else { - newParams.delete('mode'); } setSearchParams(newParams, { replace: true }); }; @@ -58,44 +60,33 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const defaultConfigOpen = modalParam === 'settings'; const accountSettingsOpen = modalParam === 'account'; - const setLibraryOpen = (open: boolean) => { - const newParams = new URLSearchParams(searchParams); - if (open) { - newParams.set('modal', 'library'); - } else { - newParams.delete('modal'); + const buildCleanParams = (updates: Record) => { + const newParams = new URLSearchParams(); + const modeParam = searchParams.get('mode'); + if (modeParam) newParams.set('mode', modeParam); + + for (const [key, value] of Object.entries(updates)) { + if (value !== null) { + newParams.set(key, value); + } } - setSearchParams(newParams); + return newParams; + }; + + const setLibraryOpen = (open: boolean) => { + setSearchParams(buildCleanParams({ modal: open ? 'library' : null })); }; const setPreferencesOpen = (open: boolean) => { - const newParams = new URLSearchParams(searchParams); - if (open) { - newParams.set('modal', 'preferences'); - } else { - newParams.delete('modal'); - } - setSearchParams(newParams); + setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null })); }; const setDefaultConfigOpen = (open: boolean) => { - const newParams = new URLSearchParams(searchParams); - if (open) { - newParams.set('modal', 'settings'); - } else { - newParams.delete('modal'); - } - setSearchParams(newParams); + setSearchParams(buildCleanParams({ modal: open ? 'settings' : null })); }; const setAccountSettingsOpen = (open: boolean) => { - const newParams = new URLSearchParams(searchParams); - if (open) { - newParams.set('modal', 'account'); - } else { - newParams.delete('modal'); - } - setSearchParams(newParams); + setSearchParams(buildCleanParams({ modal: open ? 'account' : null })); }; const [selectedFiles, setSelectedFiles] = useState([]); diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index 2529fcf..d1cd804 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -39,11 +39,20 @@ export const QuizEditor: React.FC = ({ const [config, setConfig] = useState( initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG ); + const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config); + + useEffect(() => { + if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) { + setConfig(defaultConfig); + setHasAppliedDefaultConfig(true); + } + }, [defaultConfig, hasAppliedDefaultConfig]); useBodyScrollLock(!!showDeleteConfirm); const handleConfigChange = useCallback((newConfig: GameConfig) => { setConfig(newConfig); + setHasAppliedDefaultConfig(true); onConfigChange?.(newConfig); }, [onConfigChange]); @@ -191,7 +200,7 @@ export const QuizEditor: React.FC = ({ {showSaveButton && (