222 lines
13 KiB
TypeScript
222 lines
13 KiB
TypeScript
|
|
import React, { useState, useMemo } from 'react';
|
|
import { X, CheckCircle2, Download, Globe, Sparkles, Heart, Clock, Bell } from 'lucide-react';
|
|
import { useCTF } from './CTFContext';
|
|
import { Challenge, Difficulty } from './types';
|
|
import { Button, CategoryIcon, Countdown } from './UIComponents';
|
|
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
|
|
|
const ChallengeModal: React.FC<{ challenge: Challenge; onClose: () => void; }> = ({ challenge, onClose }) => {
|
|
const { state, currentUser, submitFlag, refreshState } = useCTF();
|
|
const [flagInput, setFlagInput] = useState('');
|
|
const [message, setMessage] = useState<{ text: string, type: 'success' | 'error' } | null>(null);
|
|
|
|
const handleFlagSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const result = await submitFlag(challenge.id, flagInput);
|
|
if (result) {
|
|
setMessage({ text: 'ACCESS GRANTED ✨', type: 'success' });
|
|
setFlagInput('');
|
|
setTimeout(() => { onClose(); setMessage(null); refreshState(); }, 1500);
|
|
} else setMessage({ text: 'RETRY SUGGESTED', type: 'error' });
|
|
} catch (err: any) {
|
|
setMessage({ text: err.message || 'COMMUNICATION ERROR', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const connectionDetails = useMemo(() => {
|
|
const port = Number(challenge.port);
|
|
const ip = challenge.overrideIp || state.config.dockerIp || '127.0.0.1';
|
|
if (port > 0) return (challenge.connectionType || 'nc') === 'nc' ? `nc ${ip} ${port}` : `http://${ip}:${port}`;
|
|
return null;
|
|
}, [challenge.port, challenge.connectionType, challenge.overrideIp, state.config.dockerIp]);
|
|
|
|
const basePoints = calculateChallengeValue(
|
|
challenge.initialPoints,
|
|
challenge.minimumPoints || 0,
|
|
challenge.decaySolves || 1,
|
|
(challenge.solves || []).length
|
|
);
|
|
|
|
const difficultyColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/95 z-[300] flex items-center justify-center p-4">
|
|
<div className="hxp-border border-4 max-w-2xl w-full bg-black p-8 relative overflow-y-auto max-h-[90vh] custom-scrollbar">
|
|
<button onClick={onClose} className="absolute top-4 right-4 bg-[#ff0000] text-black font-black p-2 hover:bg-white transition-colors"><X size={24} /></button>
|
|
<div className="mb-8">
|
|
<h3 className="text-4xl font-black italic tracking-tighter text-white mb-2">{challenge.title}</h3>
|
|
<div className="flex gap-4 font-bold text-xs tracking-widest text-slate-400 uppercase">
|
|
<span className="text-[#bf00ff]">{challenge.category}</span> | <span className={difficultyColor}>{challenge.difficulty}</span> | <span className="text-white">{basePoints} PTS</span>
|
|
</div>
|
|
</div>
|
|
<div className="hxp-border-purple p-6 mb-6 text-[#bf00ff] font-bold text-sm leading-relaxed whitespace-pre-wrap bg-[#bf00ff]/5">{challenge.description}</div>
|
|
|
|
{connectionDetails && (
|
|
<div className="mb-8 p-4 hxp-border border-[#00ccff] bg-[#00ccff]/5">
|
|
<h4 className="text-[10px] font-black text-[#00ccff] uppercase tracking-[0.2em] mb-2 flex items-center gap-2"><Globe size={12}/> Connect to:</h4>
|
|
<code className="block bg-black p-3 text-[#00ccff] font-black text-sm border border-[#00ccff]/30 break-all select-all">{connectionDetails}</code>
|
|
</div>
|
|
)}
|
|
|
|
{challenge.files && challenge.files.length > 0 && (
|
|
<div className="mb-8 space-y-2">
|
|
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Available Files</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{challenge.files.map((file, idx) => (
|
|
<a key={idx} href={file.url} download={file.name} className="flex items-center gap-3 p-3 hxp-border-purple bg-white/5 hover:bg-white/10 transition-colors group">
|
|
<Download size={18} className="text-[#bf00ff]" /><span className="text-xs font-black text-white truncate flex-1">{file.name}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentUser ? (
|
|
!(challenge.solves || []).includes(currentUser.id) ? (
|
|
<form onSubmit={handleFlagSubmit} className="space-y-4 mb-10">
|
|
<input type="text" placeholder="{flag: insert_the_flag}" className="w-full bg-black hxp-border-purple p-4 outline-none focus:border-[#ff0000] text-white font-black" value={flagInput} onChange={(e) => setFlagInput(e.target.value)} />
|
|
<div className="flex gap-4">
|
|
<Button type="submit" className="flex-1 text-xl py-4 font-black">Solve</Button>
|
|
{message && <div className={`flex-1 flex items-center justify-center font-black border-4 ${message.type === 'success' ? 'text-[#00ff00] border-[#00ff00]' : 'text-red-500 border-red-500'}`}>{message.text}</div>}
|
|
</div>
|
|
</form>
|
|
) : <div className="p-6 hxp-border border-[#00ff00] bg-[#00ff00]/10 text-[#00ff00] text-center font-black italic text-2xl animate-pulse mb-10 uppercase tracking-widest">CHALLENGE_SOLVED ✨</div>
|
|
) : <p className="text-center font-bold text-red-500 mb-10 uppercase tracking-widest">PLEASE_SIGN_IN_TO_SOLVE</p>}
|
|
|
|
<div className="border-t-4 border-[#333] pt-6">
|
|
<h4 className="text-sm font-black text-white tracking-[0.2em] mb-4 flex items-center gap-2 uppercase"><Sparkles size={14} /> SOLVER_LOG</h4>
|
|
<div className="space-y-1">
|
|
{(challenge.solves || []).length > 0 ? (
|
|
state.solves.filter(s => s.challengeId === challenge.id).sort((a, b) => a.timestamp - b.timestamp).map((solve, idx) => {
|
|
const team = state.teams.find(t => t.id === solve.teamId);
|
|
const bonusFactor = getFirstBloodBonusFactor(idx);
|
|
const bonus = Math.floor(challenge.initialPoints * bonusFactor);
|
|
const totalGained = basePoints + bonus;
|
|
|
|
return (
|
|
<div key={solve.teamId + idx} className="flex justify-between items-center p-3 bg-white/5 border border-white/10 text-[10px] font-bold">
|
|
<div className="flex items-center gap-3">
|
|
<span className={`w-5 h-5 flex items-center justify-center font-black ${idx === 0 ? 'bg-[#ffaa00] text-black' : idx === 1 ? 'bg-slate-400 text-black' : idx === 2 ? 'bg-[#cd7f32] text-black' : 'bg-white/10 text-slate-500'}`}>{idx + 1}</span>
|
|
<span className="text-white text-xs uppercase italic">{team?.name}</span>
|
|
{bonus > 0 && <span className="text-[#00ff00] animate-pulse">+{bonus} BONUS</span>}
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-white bg-white/10 px-2 py-1 hxp-border-purple border-opacity-30">{totalGained} PTS</span>
|
|
<span className="text-slate-500 font-mono hidden sm:inline">{new Date(solve.timestamp).toLocaleTimeString()}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : <div className="text-center py-4 text-slate-700 font-black italic text-xs uppercase">No solutions discovered yet.</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ChallengeList: React.FC = () => {
|
|
const { state, currentUser } = useCTF();
|
|
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(null);
|
|
const [showRefreshPopup, setShowRefreshPopup] = useState(false);
|
|
const difficultyWeight: Record<Difficulty, number> = { 'Low': 1, 'Medium': 2, 'High': 3 };
|
|
const CATEGORIES = ['WEB', 'PWN', 'REV', 'CRY', 'MSC'];
|
|
|
|
const now = Date.now();
|
|
const startTime = parseInt(state.config.eventStartTime || "0");
|
|
const endTime = parseInt(state.config.eventEndTime || (Date.now() + 86400000).toString());
|
|
const isStartedManual = state.isStarted;
|
|
|
|
if (now < startTime && !currentUser?.isAdmin) {
|
|
const utcStartTime = new Date(startTime).toUTCString();
|
|
return (
|
|
<div className="max-w-4xl mx-auto py-32 text-center hxp-border border-4 p-12">
|
|
<Clock className="w-20 h-20 text-[#bf00ff] mx-auto mb-6 animate-pulse" />
|
|
<h2 className="text-4xl font-black italic mb-4 uppercase tracking-tighter">EVENT_STARTING_IN</h2>
|
|
<div className="p-8 hxp-border-purple bg-white/5 inline-block">
|
|
<Countdown target={startTime} onEnd={() => setShowRefreshPopup(true)} />
|
|
</div>
|
|
<p className="text-slate-500 font-bold tracking-widest uppercase mt-6">Event will start {utcStartTime}</p>
|
|
{showRefreshPopup && (
|
|
<div className="fixed inset-0 bg-black/90 z-[500] flex items-center justify-center p-4">
|
|
<div className="hxp-border border-4 bg-black p-10 text-center max-w-sm">
|
|
<Bell className="w-12 h-12 text-[#00ff00] mx-auto mb-4 animate-bounce" />
|
|
<h3 className="text-2xl font-black text-white italic mb-4 uppercase">SYSTEM_READY</h3>
|
|
<p className="text-slate-400 font-bold text-sm mb-6 uppercase">The event has officially commenced. Refresh your session to sync challenge data.</p>
|
|
<Button onClick={() => window.location.reload()} className="w-full">REFRESH_CORE</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if ((!isStartedManual || now > endTime) && !currentUser?.isAdmin) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto py-32 text-center hxp-border border-4 p-12">
|
|
<Clock className="w-20 h-20 text-slate-500 mx-auto mb-6" />
|
|
<h2 className="text-4xl font-black italic mb-4 uppercase">{now > endTime ? 'MISSION_COMPLETE' : 'BOARD_PAUSED'}</h2>
|
|
<p className="text-slate-500 font-bold tracking-widest uppercase">{now > endTime ? 'The operation timeframe has concluded.' : 'Waiting for HQ clearance...'}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full px-6 py-12">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-8 max-w-[1600px] mx-auto">
|
|
{CATEGORIES.map(category => {
|
|
const categoryChallenges = (state.challenges || [])
|
|
.filter(c => c.category === category)
|
|
.sort((a, b) => difficultyWeight[a.difficulty] - difficultyWeight[b.difficulty]);
|
|
if (categoryChallenges.length === 0) return null;
|
|
return (
|
|
<div key={category} className="flex flex-col gap-6">
|
|
<div className="flex flex-col items-center gap-2 mb-4">
|
|
<div className="p-3 bg-white/5 hxp-border border-[#333] hover:border-[#bf00ff] transition-colors">
|
|
<CategoryIcon category={category} size={48} color={category === 'WEB' ? '#ff0000' : category === 'PWN' ? '#ffaa00' : category === 'REV' ? '#00ccff' : '#bf00ff'} />
|
|
</div>
|
|
<h3 className="text-3xl font-black italic text-white uppercase tracking-tighter">{category}</h3>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
{categoryChallenges.map(c => {
|
|
const isSolved = currentUser && (c.solves || []).includes(currentUser.id);
|
|
const currentPoints = calculateChallengeValue(
|
|
c.initialPoints,
|
|
c.minimumPoints || 0,
|
|
c.decaySolves || 1,
|
|
(c.solves || []).length
|
|
);
|
|
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
|
return (
|
|
<div key={c.id} className={`hxp-card p-4 group cursor-pointer border-2 transition-all flex flex-col gap-2 ${isSolved ? 'border-[#00ff00] bg-[#00ff00]/5' : 'border-[#333] hover:border-[#bf00ff]'}`} onClick={() => setSelectedChallenge(c)}>
|
|
<h4 className="font-black text-center text-[#bf00ff] group-hover:text-white transition-colors tracking-tight uppercase truncate">{c.title}</h4>
|
|
|
|
<div className={`text-[9px] font-black uppercase text-center py-0.5 tracking-[0.2em] border-y border-white/5 ${diffColor}`}>
|
|
{c.difficulty}
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center px-1">
|
|
<div className="flex items-center gap-1">
|
|
{isSolved && <CheckCircle2 size={12} className="text-[#00ff00]" />}
|
|
<span className={`text-xs font-black ${isSolved ? 'text-white' : 'text-slate-400'}`}>{currentPoints}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Heart size={10} className="text-slate-500" />
|
|
<span className="text-[10px] font-bold text-slate-500">{(c.solves || []).length}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{selectedChallenge && <ChallengeModal challenge={selectedChallenge} onClose={() => setSelectedChallenge(null)} />}
|
|
</div>
|
|
);
|
|
};
|