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:
parent
75c496e68f
commit
e480ad06df
18 changed files with 1775 additions and 94 deletions
|
|
@ -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');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue