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
This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 10:12:05 -07:00
commit e480ad06df
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 1775 additions and 94 deletions

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from 'react-oidc-context';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
@ -8,6 +9,7 @@ import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generato
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const SESSION_STORAGE_KEY = 'kaboot_session';
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
const STATE_SYNC_INTERVAL = 5000;
interface StoredSession {
@ -35,9 +37,33 @@ const clearStoredSession = () => {
localStorage.removeItem(SESSION_STORAGE_KEY);
};
interface DraftQuiz {
quiz: Quiz;
topic?: string;
sourceQuizId?: string;
}
const getDraftQuiz = (): DraftQuiz | null => {
try {
const stored = sessionStorage.getItem(DRAFT_QUIZ_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
};
const storeDraftQuiz = (draft: DraftQuiz) => {
sessionStorage.setItem(DRAFT_QUIZ_KEY, JSON.stringify(draft));
};
const clearDraftQuiz = () => {
sessionStorage.removeItem(DRAFT_QUIZ_KEY);
};
export const useGame = () => {
const navigate = useNavigate();
const location = useLocation();
const auth = useAuth();
const [role, setRole] = useState<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING');
@ -95,9 +121,13 @@ export const useGame = () => {
useEffect(() => {
if (isInitializingFromUrl.current) return;
if (location.pathname === '/callback') return;
if (auth.isLoading) return;
const getTargetPath = () => {
if (location.pathname === '/callback') {
return '/';
}
switch (gameState) {
case 'LANDING':
if (gamePin && location.pathname.startsWith('/play/')) {
@ -108,7 +138,7 @@ export const useGame = () => {
case 'GENERATING':
return '/create';
case 'EDITING':
return '/edit';
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
case 'LOBBY':
case 'COUNTDOWN':
case 'QUESTION':
@ -136,7 +166,19 @@ export const useGame = () => {
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
navigate(targetPath + location.search, { replace: useReplace });
}
}, [gameState, gamePin, role, navigate, location.pathname, location.search]);
}, [gameState, gamePin, role, navigate, location.pathname, location.search, sourceQuizId, auth.isLoading]);
useEffect(() => {
if (auth.isLoading) return;
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(location.pathname) &&
location.pathname !== '/edit/draft';
if (isLibraryQuizRoute && !auth.isAuthenticated) {
clearDraftQuiz();
auth.signinRedirect();
}
}, [auth.isLoading, auth.isAuthenticated, location.pathname]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
@ -486,9 +528,36 @@ export const useGame = () => {
return;
}
if (path === '/edit') {
const session = getStoredSession();
if (!session) {
if (path === '/edit/draft') {
const draft = getDraftQuiz();
if (draft) {
isInitializingFromUrl.current = true;
setRole('HOST');
setQuiz(draft.quiz);
setSourceQuizId(null);
if (draft.topic !== undefined) {
setPendingQuizToSave({ quiz: draft.quiz, topic: draft.topic });
}
setGameState('EDITING');
isInitializingFromUrl.current = false;
} else {
navigate('/', { replace: true });
}
return;
}
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
if (editMatch) {
const quizId = editMatch[1];
const draft = getDraftQuiz();
if (draft && draft.sourceQuizId === quizId) {
isInitializingFromUrl.current = true;
setRole('HOST');
setQuiz(draft.quiz);
setSourceQuizId(quizId);
setGameState('EDITING');
isInitializingFromUrl.current = false;
} else {
navigate('/', { replace: true });
}
return;
@ -554,6 +623,7 @@ export const useGame = () => {
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
setQuiz(generatedQuiz);
storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel });
setGameState('EDITING');
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
@ -586,17 +656,25 @@ export const useGame = () => {
setRole('HOST');
setQuiz(savedQuiz);
setSourceQuizId(quizId || null);
storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId });
setGameState('EDITING');
};
const updateQuizFromEditor = (updatedQuiz: Quiz) => {
setQuiz(updatedQuiz);
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
const currentDraft = getDraftQuiz();
storeDraftQuiz({
quiz: updatedQuiz,
topic: currentDraft?.topic,
sourceQuizId: currentDraft?.sourceQuizId
});
};
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
setQuiz(finalQuiz);
setGameConfig(config);
clearDraftQuiz();
initializeHostGame(finalQuiz, config.hostParticipates);
};
@ -604,6 +682,7 @@ export const useGame = () => {
setQuiz(null);
setPendingQuizToSave(null);
setSourceQuizId(null);
clearDraftQuiz();
setGameState('LANDING');
};

View file

@ -71,8 +71,12 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
: 'Failed to load quiz.';
throw new Error(errorText);
}
const data = await response.json();
toast.success('Quiz loaded!');
return response.json();
return {
...data,
config: data.gameConfig,
};
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load quiz';
if (!message.includes('redirecting')) {