Made app more modular.
Fixed some bugs. Added some functionality.
This commit is contained in:
482
Admin.tsx
Normal file
482
Admin.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
|
||||
import { Button, Countdown, CategoryIcon } from './UIComponents';
|
||||
import { CATEGORIES, DIFFICULTIES } from './constants';
|
||||
|
||||
export const Admin: React.FC = () => {
|
||||
const { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF();
|
||||
|
||||
const [editingChallenge, setEditingChallenge] = useState<Partial<Challenge> | null>(null);
|
||||
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
|
||||
const [editingBlogPost, setEditingBlogPost] = useState<Partial<BlogPost> | null>(null);
|
||||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||
const [currentFiles, setCurrentFiles] = useState<ChallengeFile[]>([]);
|
||||
const [localConf, setLocalConf] = useState<Record<string, string>>({});
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
// Sorted data for display
|
||||
const sortedChallenges = useMemo(() => {
|
||||
return [...state.challenges].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [state.challenges]);
|
||||
|
||||
const sortedOperators = useMemo(() => {
|
||||
return state.teams
|
||||
.filter(t => t.id !== 'admin-0')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [state.teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(state.config).length > 0 && !initialLoadDone.current) {
|
||||
setLocalConf({ ...state.config });
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
}, [state.config]);
|
||||
|
||||
const importChalRef = useRef<HTMLInputElement>(null);
|
||||
const restoreDbRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
Object.entries(localConf).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== 'NaN') fd.append(k, String(v));
|
||||
});
|
||||
const logoInput = (e.target as any).logo;
|
||||
const bgInput = (e.target as any).bgImage;
|
||||
if (logoInput && logoInput.files[0]) fd.append('logo', logoInput.files[0]);
|
||||
if (bgInput && bgInput.files[0]) fd.append('bgImage', bgInput.files[0]);
|
||||
try {
|
||||
await updateConfig(fd);
|
||||
await refreshState();
|
||||
initialLoadDone.current = false;
|
||||
alert('CONFIGURATION_SAVED_SUCCESSFULLY');
|
||||
} catch (err) { alert('SAVE_FAILED_CHECK_CONSOLE'); }
|
||||
};
|
||||
|
||||
const toUTCDisplay = (msStr: string) => {
|
||||
if (!msStr || msStr === 'undefined' || msStr === 'NaN') return "";
|
||||
const ms = parseInt(msStr);
|
||||
if (isNaN(ms)) return "";
|
||||
const d = new Date(ms);
|
||||
return d.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const fromUTCDisplay = (isoStr: string) => {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr + ":00Z");
|
||||
const ms = d.getTime();
|
||||
return isNaN(ms) ? "" : ms.toString();
|
||||
};
|
||||
|
||||
const eventStartTime = parseInt(state.config.eventStartTime || "0");
|
||||
const isBeforeStart = Date.now() < eventStartTime;
|
||||
|
||||
const handleDifficultyChange = (val: Difficulty) => {
|
||||
if (!editingChallenge) return;
|
||||
const points = val === 'Low' ? 100 : val === 'Medium' ? 200 : 300;
|
||||
setEditingChallenge({
|
||||
...editingChallenge,
|
||||
difficulty: val,
|
||||
initialPoints: points,
|
||||
minimumPoints: Math.floor(points / 2)
|
||||
});
|
||||
};
|
||||
|
||||
const handleInitialPointsChange = (p: number) => {
|
||||
if (!editingChallenge) return;
|
||||
setEditingChallenge({
|
||||
...editingChallenge,
|
||||
initialPoints: p,
|
||||
minimumPoints: Math.floor(p / 2)
|
||||
});
|
||||
};
|
||||
|
||||
const quickToggleAdmin = async (team: Team) => {
|
||||
if (team.id === 'admin-0') return;
|
||||
await updateTeam(team.id, { ...team, isAdmin: !team.isAdmin });
|
||||
};
|
||||
|
||||
const quickToggleStatus = async (team: Team) => {
|
||||
if (team.id === 'admin-0') return;
|
||||
await updateTeam(team.id, { ...team, isDisabled: !team.isDisabled });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto py-12 px-6">
|
||||
<div className="flex flex-wrap gap-4 justify-between items-center mb-12 border-b-4 border-[#ff0000] pb-6">
|
||||
<h2 className="text-5xl font-black italic text-white uppercase tracking-tighter">ADMIN_CONSOLE</h2>
|
||||
<div className="flex gap-4 items-center">
|
||||
{isBeforeStart && (
|
||||
<div className="px-4 py-2 hxp-border-purple bg-[#bf00ff]/10 hidden sm:flex items-center gap-3">
|
||||
<Countdown target={eventStartTime} label="STARTING IN" />
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={toggleCtf} variant={state.isStarted ? 'secondary' : 'primary'}>{state.isStarted ? 'PAUSE_BOARD' : 'RESUME_BOARD'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="space-y-12">
|
||||
<section className="space-y-6">
|
||||
<h3 className="text-2xl font-black text-[#ffaa00] border-b-2 border-[#ffaa00] uppercase italic">GENERAL_CONFIG</h3>
|
||||
<form onSubmit={handleConfigSubmit} className="hxp-border p-6 bg-white/5 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Competition Name</label>
|
||||
<input placeholder="Ex: HIPCTF '26" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.conferenceName || ''} onChange={e => setLocalConf({...localConf, conferenceName: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Event Start (UTC)</label>
|
||||
<input type="datetime-local" className="w-full bg-black hxp-border p-3 text-white font-black text-xs" value={toUTCDisplay(localConf.eventStartTime || "")} onChange={e => { const val = fromUTCDisplay(e.target.value); if (val) setLocalConf({...localConf, eventStartTime: val}); }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Event End (UTC)</label>
|
||||
<input type="datetime-local" className="w-full bg-black hxp-border p-3 text-white font-black text-xs" value={toUTCDisplay(localConf.eventEndTime || "")} onChange={e => { const val = fromUTCDisplay(e.target.value); if (val) setLocalConf({...localConf, eventEndTime: val}); }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Central Node IP</label>
|
||||
<input placeholder="Ex: 10.0.0.5" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.dockerIp || ''} onChange={e => setLocalConf({...localConf, dockerIp: e.target.value})} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Landing Page Text</label>
|
||||
<textarea placeholder="Welcome message..." className="w-full bg-black hxp-border p-3 text-white font-black h-24" value={localConf.landingText || ''} onChange={e => setLocalConf({...localConf, landingText: e.target.value})} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Custom Logo</label>
|
||||
<input type="file" name="logo" className="text-xs block w-full bg-black hxp-border p-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-2 border-[#333] pt-6 space-y-4">
|
||||
<h4 className="text-sm font-black text-white italic uppercase tracking-tighter">Background Style</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Type</label>
|
||||
<select className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.bgType || 'color'} onChange={e => setLocalConf({...localConf, bgType: e.target.value})}>
|
||||
<option value="color">SOLID_COLOR</option>
|
||||
<option value="image">IMAGE_DATA</option>
|
||||
</select>
|
||||
</div>
|
||||
{localConf.bgType === 'color' ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Color</label>
|
||||
<input type="color" className="w-full bg-black hxp-border p-1 h-12" value={localConf.bgColor || '#000000'} onChange={e => setLocalConf({...localConf, bgColor: e.target.value})} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Upload</label>
|
||||
<input type="file" name="bgImage" className="text-xs block w-full bg-black hxp-border p-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{localConf.bgType === 'image' && (
|
||||
<div className="space-y-4 p-4 hxp-border-purple bg-white/5">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest flex justify-between">Opacity <span>{localConf.bgOpacity}</span></label>
|
||||
<input type="range" min="0" max="1" step="0.1" className="w-full accent-[#bf00ff]" value={localConf.bgOpacity || '0.5'} onChange={e => setLocalConf({...localConf, bgOpacity: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full py-3">Commit Configuration</Button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="space-y-6">
|
||||
<div className="flex justify-between items-center border-b-2 border-[#bf00ff]"><h3 className="text-2xl font-black text-[#bf00ff] uppercase italic">CHALLENGES</h3><div className="flex gap-2"><Button onClick={() => { setEditingChallenge({ title: '', category: 'WEB', difficulty: 'Low', initialPoints: 100, minimumPoints: 50, decaySolves: 20, flag: '', description: '', files: [], port: 0, connectionType: 'nc', overrideIp: '' }); setCurrentFiles([]); setNewFiles([]); }} className="text-xs">NEW_CHAL</Button></div></div>
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto custom-scrollbar">
|
||||
{sortedChallenges.map(c => {
|
||||
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
||||
return (
|
||||
<div key={c.id} className="hxp-border-purple p-3 flex justify-between items-center bg-white/5 hover:bg-white/10 transition-colors gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0 opacity-70">
|
||||
<CategoryIcon category={c.category} size={14} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||
<span className="font-black text-white uppercase italic whitespace-normal leading-tight">{c.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-black uppercase ${diffColor} px-1.5 border border-current opacity-80 h-4 flex items-center`}>{c.difficulty}</span>
|
||||
<span className="text-[9px] font-black uppercase text-[#00ccff] px-1.5 border border-[#00ccff]/50 h-4 flex items-center bg-[#00ccff]/5">
|
||||
{c.connectionType || 'nc'}{c.port && c.port > 0 ? `:${c.port}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={() => { setEditingChallenge(c); setCurrentFiles(c.files || []); setNewFiles([]); }} className="text-[#bf00ff] hover:text-white transition-colors" title="Edit Challenge"><Edit3 size={16}/></button>
|
||||
<button onClick={() => deleteChallenge(c.id)} className="text-red-500 hover:text-white transition-colors" title="Delete Challenge"><Trash2 size={16}/></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedChallenges.length === 0 && (
|
||||
<div className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No challenges in database.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="space-y-12">
|
||||
<section className="space-y-6">
|
||||
<div className="flex justify-between items-center border-b-2 border-[#ff0000]"><h3 className="text-2xl font-black text-[#ff0000] uppercase italic">BLOGS</h3><Button onClick={() => setEditingBlogPost({ title: '', content: '' })} className="text-xs">NEW_POST</Button></div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar">
|
||||
{state.blogs.map(post => (
|
||||
<div key={post.id} className="hxp-border p-3 flex justify-between bg-white/5 hover:bg-white/10 transition-colors"><span className="truncate w-40 font-black uppercase italic">{post.title}</span><div className="flex gap-2"><button onClick={() => setEditingBlogPost(post)} className="text-[#bf00ff] hover:text-white"><Edit3 size={16}/></button><button onClick={() => deleteBlogPost(post.id)} className="text-red-500 hover:text-white"><Trash2 size={16}/></button></div></div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<h3 className="text-2xl font-black text-[#00ccff] border-b-2 border-[#00ccff] uppercase italic">BACKUP_RESTORE</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Button onClick={async () => {
|
||||
const data = await exportChallenges();
|
||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `challenges-${Date.now()}.json`; a.click();
|
||||
}} className="w-full text-xs py-3">EXPORT_CHAL</Button>
|
||||
<Button onClick={() => importChalRef.current?.click()} className="w-full text-xs py-3" variant="secondary">IMPORT_CHAL</Button>
|
||||
<input type="file" ref={importChalRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0]) { await importChallenges(e.target.files[0]); alert('CHALLENGES_IMPORTED'); } }} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button onClick={async () => {
|
||||
const data = await backupDatabase();
|
||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `ctf_backup-${Date.now()}.json`; a.click();
|
||||
}} className="w-full text-xs py-3 border-[#ffaa00] text-[#ffaa00] hover:bg-[#ffaa00] hover:text-black">BACKUP_DB</Button>
|
||||
<Button onClick={() => restoreDbRef.current?.click()} className="w-full text-xs py-3 border-[#00ff00] text-[#00ff00] hover:bg-[#00ff00] hover:text-black" variant="secondary">RESTORE_DB</Button>
|
||||
<input type="file" ref={restoreDbRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0] && window.confirm("CRITICAL_OVERWRITE: RESTORE WILL WIPE CURRENT DATA. PROCEED?")) { await restoreDatabase(e.target.files[0]); window.location.reload(); } }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<h3 className="text-2xl font-black text-[#bf00ff] border-b-2 border-[#bf00ff] uppercase italic">OPERATORS</h3>
|
||||
<div className="hxp-border border-2 overflow-hidden bg-black">
|
||||
<table className="w-full text-[10px] font-black">
|
||||
<thead className="bg-[#333] uppercase">
|
||||
<tr>
|
||||
<th className="p-3 text-left">TEAM_IDENTIFIER</th>
|
||||
<th className="p-3 text-center">ROLE</th>
|
||||
<th className="p-3 text-center">STATUS</th>
|
||||
<th className="p-3 text-right">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 italic">
|
||||
{sortedOperators.map(team => (
|
||||
<tr key={team.id} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="p-3 text-white text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={14} className="text-slate-500" />
|
||||
{team.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-center uppercase">
|
||||
<button
|
||||
onClick={() => quickToggleAdmin(team)}
|
||||
className={`flex items-center gap-1 mx-auto px-2 py-1 border transition-all ${team.isAdmin ? 'border-[#ffaa00] text-[#ffaa00] bg-[#ffaa00]/10' : 'border-slate-700 text-slate-500 hover:border-[#bf00ff] hover:text-[#bf00ff]'}`}
|
||||
title={team.isAdmin ? "Revoke Admin Privileges" : "Grant Admin Privileges"}
|
||||
>
|
||||
{team.isAdmin ? <ShieldCheck size={12} /> : <Shield size={12} />}
|
||||
<span>{team.isAdmin ? 'ADMIN' : 'OPERATOR'}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center uppercase">
|
||||
<button
|
||||
onClick={() => quickToggleStatus(team)}
|
||||
className={`flex items-center gap-1 mx-auto px-2 py-1 border transition-all ${team.isDisabled ? 'border-red-500 text-red-500 bg-red-500/10' : 'border-slate-700 text-[#00ff00] hover:bg-[#00ff00]/5'}`}
|
||||
title={team.isDisabled ? "Enable Account" : "Disable Account"}
|
||||
>
|
||||
{team.isDisabled ? <UserMinus size={12} /> : <UserCheck size={12} />}
|
||||
<span>{team.isDisabled ? 'BANNED' : 'ACTIVE'}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditingTeam(team)} className="text-[#bf00ff] hover:text-white transition-colors" title="Full Edit"><Edit3 size={16} /></button>
|
||||
<button onClick={() => deleteTeam(team.id)} className="text-red-500 hover:text-white transition-colors" title="Delete Identity"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedOperators.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No registered operators detected.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center gap-3 border-b-2 border-[#ff0000]"><Skull className="text-[#ff0000]" size={24} /><h3 className="text-2xl font-black text-[#ff0000] uppercase italic">DANGER_ZONE</h3></div>
|
||||
<div className="hxp-border border-[#ff0000] bg-red-900/10 p-6 space-y-4">
|
||||
<Button onClick={resetScores} className="w-full text-xs py-3 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">RESET_ALL_SCORES</Button>
|
||||
<Button onClick={deleteAllChallenges} className="w-full text-xs py-3 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">WIPE_ALL_CHALLENGES</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingChallenge && (
|
||||
<div className="fixed inset-0 bg-black/95 z-[400] flex items-center justify-center p-4">
|
||||
<div className="hxp-border border-4 max-w-2xl w-full bg-black p-8 overflow-y-auto max-h-[90vh] custom-scrollbar relative">
|
||||
<button onClick={() => setEditingChallenge(null)} className="absolute top-4 right-4 text-white hover:text-red-500 transition-colors"><X size={24}/></button>
|
||||
<h3 className="text-3xl font-black italic text-white mb-8 uppercase">MANAGE_CHALLENGE</h3>
|
||||
<form onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
Object.entries(editingChallenge).forEach(([k,v]) => { if (v !== null && v !== undefined && typeof v !== 'object') fd.append(k, String(v)); });
|
||||
fd.append('existingFiles', JSON.stringify(currentFiles));
|
||||
newFiles.forEach(f => fd.append('files', f));
|
||||
await upsertChallenge(fd, editingChallenge.id);
|
||||
setEditingChallenge(null);
|
||||
}} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<input placeholder="CHALLENGE_TITLE" className="w-full bg-black hxp-border-purple p-3 text-white font-black uppercase" value={editingChallenge.title} onChange={e => setEditingChallenge({...editingChallenge, title: e.target.value})} required />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase">Category</label>
|
||||
<select className="w-full bg-black hxp-border-purple p-3 text-white font-black uppercase" value={editingChallenge.category} onChange={e => setEditingChallenge({...editingChallenge, category: e.target.value})}>{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase">Difficulty</label>
|
||||
<select className="w-full bg-black hxp-border-purple p-3 text-white font-black uppercase" value={editingChallenge.difficulty} onChange={e => handleDifficultyChange(e.target.value as Difficulty)}>{DIFFICULTIES.map(d => <option key={d} value={d}>{d}</option>)}</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase">Initial Pts</label>
|
||||
<input type="number" className="w-full bg-black hxp-border-purple p-2 text-white font-black" value={editingChallenge.initialPoints} onChange={e => handleInitialPointsChange(parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest block mb-1">Minimum Points (50% Auto)</label>
|
||||
<input type="number" placeholder="MIN POINTS" className="w-full bg-black hxp-border-purple p-3 text-white font-black" value={editingChallenge.minimumPoints} onChange={e => setEditingChallenge({...editingChallenge, minimumPoints: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest block mb-1">Decay Solves</label>
|
||||
<input type="number" placeholder="DECAY LIMIT" className="w-full bg-black hxp-border-purple p-3 text-white font-black" value={editingChallenge.decaySolves} onChange={e => setEditingChallenge({...editingChallenge, decaySolves: parseInt(e.target.value)})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hxp-border border-[#00ccff] p-4 bg-[#00ccff]/5 space-y-4">
|
||||
<h4 className="text-xs font-black text-[#00ccff] uppercase flex items-center gap-2"><Globe size={14}/> Connection Settings</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-[#00ccff]/70 uppercase">Protocol</label>
|
||||
<select className="w-full bg-black hxp-border border-[#00ccff]/30 p-2 text-white font-black" value={editingChallenge.connectionType || 'nc'} onChange={e => setEditingChallenge({...editingChallenge, connectionType: e.target.value as any})}>
|
||||
<option value="nc">NC (NETCAT)</option>
|
||||
<option value="http">HTTP (WEB)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-[#00ccff]/70 uppercase">Port</label>
|
||||
<input type="number" placeholder="0 = None" className="w-full bg-black hxp-border border-[#00ccff]/30 p-2 text-white font-black" value={editingChallenge.port || 0} onChange={e => setEditingChallenge({...editingChallenge, port: parseInt(e.target.value) || 0})} />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2 md:col-span-1">
|
||||
<label className="text-[10px] font-black text-[#00ccff]/70 uppercase">Node IP Override</label>
|
||||
<input placeholder="Optional" className="w-full bg-black hxp-border border-[#00ccff]/30 p-2 text-white font-black" value={editingChallenge.overrideIp || ''} onChange={e => setEditingChallenge({...editingChallenge, overrideIp: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase">Flag Protocol</label>
|
||||
<input placeholder="FLAG_PROTOCOL" className="w-full bg-black hxp-border-purple p-3 text-white font-black" value={editingChallenge.flag} onChange={e => setEditingChallenge({...editingChallenge, flag: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase">Challenge Intel</label>
|
||||
<textarea placeholder="DESCRIPTION" className="w-full bg-black hxp-border-purple p-3 text-white font-black h-32" value={editingChallenge.description} onChange={e => setEditingChallenge({...editingChallenge, description: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t-2 border-[#333] pt-6 space-y-4">
|
||||
<h4 className="text-sm font-black text-white italic uppercase tracking-tighter flex items-center gap-2"><Upload size={14} /> File Management</h4>
|
||||
<div className="space-y-3">
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[8px] font-black text-slate-500 uppercase">Existing Assets</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{currentFiles.map((f, i) => (
|
||||
<div key={i} className="flex justify-between items-center bg-white/5 hxp-border-purple p-2 text-[10px] font-bold">
|
||||
<span className="truncate flex-1 text-slate-300">{f.name}</span>
|
||||
<button type="button" onClick={() => setCurrentFiles(currentFiles.filter((_, idx) => idx !== i))} className="text-red-500 hover:text-white transition-colors ml-2"><Trash2 size={14}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{newFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[8px] font-black text-[#bf00ff] uppercase">Pending Uploads</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{newFiles.map((f, i) => (
|
||||
<div key={i} className="flex justify-between items-center bg-[#bf00ff]/5 border border-[#bf00ff]/30 p-2 text-[10px] font-bold">
|
||||
<span className="truncate flex-1 text-[#bf00ff] italic">{f.name}</span>
|
||||
<button type="button" onClick={() => setNewFiles(newFiles.filter((_, idx) => idx !== i))} className="text-red-500 hover:text-white transition-colors ml-2"><Trash2 size={14}/></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex-1 cursor-pointer bg-white/5 hxp-border-purple border-dashed p-4 flex flex-col items-center justify-center hover:bg-white/10 transition-colors">
|
||||
<Plus size={24} className="text-[#bf00ff] mb-2" />
|
||||
<span className="text-[10px] font-black text-[#bf00ff] uppercase">Select Assets</span>
|
||||
<input type="file" multiple className="hidden" onChange={e => { if (e.target.files) setNewFiles([...newFiles, ...Array.from(e.target.files)]); }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="flex-1 py-4 uppercase text-lg">Commit Challenge</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => { if(window.confirm("RESET_TO_DEFAULTS?")) handleDifficultyChange(editingChallenge.difficulty || 'Low'); }} className="px-4" title="Restore Defaults"><History size={20}/></Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingTeam && (
|
||||
<div className="fixed inset-0 bg-black/95 z-[400] flex items-center justify-center p-4">
|
||||
<div className="hxp-border border-4 max-md w-full max-w-md bg-black p-8 relative">
|
||||
<button onClick={() => setEditingTeam(null)} className="absolute top-4 right-4 text-white hover:text-red-500 transition-colors"><X size={24}/></button>
|
||||
<h3 className="text-3xl font-black italic text-white mb-8 uppercase">OPERATOR_PROFILE</h3>
|
||||
<form onSubmit={async e => { e.preventDefault(); await updateTeam(editingTeam.id as string, { name: editingTeam.name, isDisabled: editingTeam.isDisabled, isAdmin: editingTeam.isAdmin, password: editingTeam.newPassword }); setEditingTeam(null); }} className="space-y-4">
|
||||
<input className="w-full bg-black hxp-border-purple p-3 text-white font-black" value={editingTeam.name} onChange={e => setEditingTeam({...editingTeam, name: e.target.value})} required />
|
||||
<input type="password" placeholder="UPDATE SECRET_KEY (OPTIONAL)" className="w-full bg-black hxp-border-purple p-3 text-white font-black" onChange={e => setEditingTeam({...editingTeam, newPassword: e.target.value})} />
|
||||
<div className="flex gap-8 py-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer font-black text-xs uppercase"><input type="checkbox" className="w-4 h-4 bg-black border-2 border-[#bf00ff]" checked={!!editingTeam.isAdmin} onChange={e => setEditingTeam({...editingTeam, isAdmin: e.target.checked})} /> Elevate to Admin</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer font-black text-xs uppercase"><input type="checkbox" className="w-4 h-4 bg-black border-2 border-[#ff0000]" checked={!!editingTeam.isDisabled} onChange={e => setEditingTeam({...editingTeam, isDisabled: e.target.checked})} /> Disable Identity</label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full py-4 uppercase">Update Identity</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingBlogPost && (
|
||||
<div className="fixed inset-0 bg-black/95 z-[400] flex items-center justify-center p-4">
|
||||
<div className="hxp-border border-4 max-w-2xl w-full bg-black p-8 relative">
|
||||
<button onClick={() => setEditingBlogPost(null)} className="absolute top-4 right-4 text-white hover:text-red-500 transition-colors"><X size={24}/></button>
|
||||
<h3 className="text-3xl font-black italic text-white mb-8 uppercase">GLOBAL_BROADCAST</h3>
|
||||
<form onSubmit={async e => { e.preventDefault(); if (editingBlogPost.id) await updateBlogPost(editingBlogPost.id, editingBlogPost as any); else await createBlogPost(editingBlogPost as any); setEditingBlogPost(null); }} className="space-y-4">
|
||||
<input placeholder="HEADLINE" className="w-full bg-black hxp-border-purple p-3 text-white font-black uppercase" value={editingBlogPost.title} onChange={e => setEditingBlogPost({...editingBlogPost, title: e.target.value})} required />
|
||||
<textarea placeholder="BROADCAST_CONTENT" className="w-full bg-black hxp-border-purple p-3 text-white font-black h-48 italic" value={editingBlogPost.content} onChange={e => setEditingBlogPost({...editingBlogPost, content: e.target.value})} required />
|
||||
<Button type="submit" className="w-full py-4 uppercase">Publish Broadcast</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
Auth.tsx
Normal file
127
Auth.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { Button } from './UIComponents';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const { login } = useCTF();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [name, setName] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (await login(name, pass)) {
|
||||
const from = (location.state as any)?.from?.pathname || '/';
|
||||
navigate(from, { replace: true });
|
||||
} else setError('ACCESS_DENIED: INVALID_CREDENTIALS');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="hxp-border border-4 p-10 bg-black relative">
|
||||
<div className="absolute -top-4 -left-4 bg-[#ff0000] text-black font-black px-4 py-1 text-xs italic">AUTH_GATE_01</div>
|
||||
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">SIGN_IN</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<input placeholder="TEAM_IDENTIFIER" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
|
||||
<input type="password" placeholder="SECRET_KEY" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
|
||||
{error && <p className="text-red-500 font-black italic animate-pulse uppercase">{error}</p>}
|
||||
<Button type="submit" className="w-full py-4 text-xl uppercase">Initiate Session</Button>
|
||||
</form>
|
||||
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">New operator? <Link to="/register" className="text-[#bf00ff] hover:underline">Register_Identity</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Register: React.FC = () => {
|
||||
const { register } = useCTF();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// CAPTCHA State
|
||||
const [captchaQ, setCaptchaQ] = useState('');
|
||||
const [captchaInput, setCaptchaInput] = useState('');
|
||||
|
||||
const generateCaptcha = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
setCaptchaQ(result);
|
||||
setCaptchaInput('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
generateCaptcha();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// CAPTCHA Validation (Case-insensitive)
|
||||
if (captchaInput.toLowerCase() !== captchaQ.toLowerCase()) {
|
||||
setError('VALIDATION_ERROR: HUMAN_VERIFICATION_FAILED');
|
||||
generateCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(name, pass);
|
||||
navigate('/challenges');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'REGISTRATION_FAILED');
|
||||
generateCaptcha();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="hxp-border border-4 p-10 bg-black relative">
|
||||
<div className="absolute -top-4 -left-4 bg-[#bf00ff] text-black font-black px-4 py-1 text-xs italic">IDENTITY_GEN_01</div>
|
||||
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">REGISTER</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Team Identifier</label>
|
||||
<input placeholder="DESIRED_IDENTIFIER" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Secret Key</label>
|
||||
<input type="password" placeholder="SECRET_KEY" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={pass} onChange={e => setPass(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Human Verification: <span className="text-[#00ff00] font-mono tracking-widest bg-white/5 px-2 py-1">{captchaQ}</span></label>
|
||||
<button type="button" onClick={generateCaptcha} className="text-[#bf00ff] hover:text-white transition-colors" title="Regenerate CAPTCHA">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
placeholder="ENTER_CODE"
|
||||
className="w-full bg-black hxp-border-purple p-4 text-white font-black"
|
||||
value={captchaInput}
|
||||
onChange={e => setCaptchaInput(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 font-black italic animate-pulse uppercase text-[10px]">{error}</p>}
|
||||
<Button type="submit" className="w-full py-4 text-xl uppercase">Create Identity</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
Blog.tsx
Normal file
22
Blog.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Newspaper } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
|
||||
export const Blog: React.FC = () => {
|
||||
const { state } = useCTF();
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-20 px-6">
|
||||
<div className="mb-12 border-b-4 border-[#ff0000] pb-6"><h2 className="text-6xl font-black italic text-white uppercase flex items-center gap-4"><Newspaper size={48} className="text-[#ff0000]" /> BLOG_FEED</h2></div>
|
||||
<div className="space-y-12">
|
||||
{state.blogs.map((post) => (
|
||||
<div key={post.id} className="hxp-border-purple bg-white/5 p-8 relative group">
|
||||
<div className="absolute top-0 right-0 bg-[#bf00ff] text-black text-[10px] font-black px-4 py-1">{new Date(post.timestamp).toLocaleString()}</div>
|
||||
<h3 className="text-3xl font-black text-white italic mb-4">{post.title}</h3>
|
||||
<div className="text-slate-400 font-bold text-sm leading-relaxed whitespace-pre-wrap border-l-4 border-[#333] pl-6 py-2">{post.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
CTFContext.tsx
Normal file
117
CTFContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Challenge, Team, Solve, CTFState, BlogPost } from './types';
|
||||
import { api } from './services/api';
|
||||
|
||||
interface CTFContextType {
|
||||
state: CTFState;
|
||||
currentUser: Team | null;
|
||||
login: (name: string, pass: string) => Promise<boolean>;
|
||||
register: (name: string, pass: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
submitFlag: (challengeId: string, flag: string) => Promise<boolean>;
|
||||
toggleCtf: () => Promise<void>;
|
||||
resetScores: () => Promise<void>;
|
||||
upsertChallenge: (data: FormData, id?: string) => Promise<void>;
|
||||
deleteChallenge: (id: string) => Promise<void>;
|
||||
deleteAllChallenges: () => Promise<void>;
|
||||
exportChallenges: () => Promise<{ challenges: any[] }>;
|
||||
importChallenges: (file: File) => Promise<void>;
|
||||
backupDatabase: () => Promise<any>;
|
||||
restoreDatabase: (file: File) => Promise<void>;
|
||||
updateTeam: (id: string, data: any) => Promise<void>;
|
||||
updateProfile: (password?: string) => Promise<void>;
|
||||
deleteTeam: (id: string) => Promise<void>;
|
||||
createBlogPost: (data: { title: string, content: string }) => Promise<void>;
|
||||
updateBlogPost: (id: string, data: { title: string, content: string }) => Promise<void>;
|
||||
deleteBlogPost: (id: string) => Promise<void>;
|
||||
updateConfig: (formData: FormData) => Promise<void>;
|
||||
refreshState: () => Promise<void>;
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
}
|
||||
|
||||
const CTFContext = createContext<CTFContextType | null>(null);
|
||||
|
||||
export const useCTF = () => {
|
||||
const context = useContext(CTFContext);
|
||||
if (!context) throw new Error('useCTF must be used within provider');
|
||||
return context;
|
||||
};
|
||||
|
||||
export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<CTFState>({ isStarted: false, startTime: null, teams: [], challenges: [], solves: [], blogs: [], config: {} });
|
||||
const [currentUser, setCurrentUser] = useState<Team | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Fix: Removed return value to match refreshState: () => Promise<void> interface
|
||||
const refreshState = useCallback(async () => {
|
||||
try {
|
||||
const newState = await api.getState();
|
||||
setState(newState);
|
||||
} catch (err: any) {
|
||||
console.error("State refresh failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
if (session) { try { const { team } = JSON.parse(session); setCurrentUser(team); } catch (e) {} }
|
||||
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
setLoading(false);
|
||||
setLoadError("CONNECTION_TIMED_OUT: RETRYING...");
|
||||
}, 6000);
|
||||
|
||||
try {
|
||||
await refreshState();
|
||||
setLoadError(null);
|
||||
} catch (err: any) {
|
||||
setLoadError("COMMUNICATION_FAULT: RECONNECTING...");
|
||||
} finally {
|
||||
clearTimeout(safetyTimeout);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
const interval = setInterval(() => {
|
||||
refreshState().catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshState]);
|
||||
|
||||
const login = async (n: string, p: string) => { try { const { team, token } = await api.login(n, p); localStorage.setItem('hip6_session', JSON.stringify({ team, token })); setCurrentUser(team); await refreshState(); return true; } catch (e) { return false; } };
|
||||
const register = async (n: string, p: string) => { const { team, token } = await api.register(n, p); localStorage.setItem('hip6_session', JSON.stringify({ team, token })); setCurrentUser(team); await refreshState(); };
|
||||
const logout = () => { localStorage.removeItem('hip6_session'); setCurrentUser(null); };
|
||||
const submitFlag = async (cid: string, f: string) => { const res = await api.submitFlag(cid, f); await refreshState(); return res.success; };
|
||||
const toggleCtf = async () => { await api.toggleCtf(); await refreshState(); };
|
||||
const resetScores = async () => { if (window.confirm("Reset all scores to 0?")) { await api.resetScores(); await refreshState(); }};
|
||||
const upsertChallenge = async (d: FormData, id?: string) => { await api.upsertChallenge(d, id); await refreshState(); };
|
||||
const deleteChallenge = async (id: string) => { if (window.confirm("DELETE_CHALLENGE?")) { await api.deleteChallenge(id); await refreshState(); } };
|
||||
const deleteAllChallenges = async () => { if (window.confirm("Delete all challenges?")) {await api.deleteAllChallenges(); await refreshState(); }};
|
||||
const exportChallenges = async () => await api.exportChallenges();
|
||||
const importChallenges = async (f: File) => { await api.importChallenges(f); await refreshState(); };
|
||||
const backupDatabase = async () => await api.backupDatabase();
|
||||
const restoreDatabase = async (f: File) => await api.restoreDatabase(f);
|
||||
const updateTeam = async (id: string, d: any) => { await api.updateTeam(id, d); await refreshState(); };
|
||||
const updateProfile = async (p?: string) => { await api.updateProfile({ password: p }); await refreshState(); };
|
||||
const deleteTeam = async (id: string) => { if (window.confirm("EXPEL_OPERATOR?")) { await api.deleteTeam(id); await refreshState(); } };
|
||||
const createBlogPost = async (d: any) => { await api.createBlogPost(d); await refreshState(); };
|
||||
const updateBlogPost = async (id: string, d: any) => { await api.updateBlogPost(id, d); await refreshState(); };
|
||||
const deleteBlogPost = async (id: string) => { await api.deleteBlogPost(id); await refreshState(); };
|
||||
const updateConfig = async (d: FormData) => { await api.updateConfig(d); await refreshState(); };
|
||||
|
||||
return (
|
||||
<CTFContext.Provider value={{
|
||||
state, currentUser, login, register, logout, submitFlag, toggleCtf, resetScores,
|
||||
upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges,
|
||||
importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile,
|
||||
deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,
|
||||
refreshState, loading, loadError
|
||||
}}>
|
||||
{children}
|
||||
</CTFContext.Provider>
|
||||
);
|
||||
};
|
||||
221
Challenges.tsx
Normal file
221
Challenges.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
59
Home.tsx
Normal file
59
Home.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Terminal, Newspaper } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { Button } from './UIComponents';
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const { currentUser, state } = useCTF();
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-20 px-4 text-center">
|
||||
<div className="mb-12">
|
||||
{state.config.logoData ? (
|
||||
<img src={state.config.logoData} className="max-h-60 mx-auto mb-4 drop-shadow-[0_0_15px_rgba(255,0,0,0.3)]" />
|
||||
) : (
|
||||
<Terminal size={120} className="text-[#ff0000] mx-auto mb-4 animate-pulse" />
|
||||
)}
|
||||
<h1 className="text-8xl font-black italic text-white tracking-tighter">{state.config.conferenceName}</h1>
|
||||
</div>
|
||||
|
||||
<div className="hxp-border p-8 mb-10 max-w-2xl mx-auto bg-black">
|
||||
<p className="text-xl font-bold uppercase tracking-tight">{state.config.landingText}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{!currentUser ? (
|
||||
<>
|
||||
{/* Pyramid Peak */}
|
||||
<div className="w-full flex justify-center">
|
||||
<Link to="/blog">
|
||||
<Button variant="secondary" className="text-xl px-16 flex items-center gap-3">
|
||||
<Newspaper size={20} /> BLOG
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Pyramid Base */}
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Link to="/register">
|
||||
<Button className="text-xl px-12">Join_Us</Button>
|
||||
</Link>
|
||||
<Link to="/login">
|
||||
<Button variant="secondary" className="text-xl px-12">Sign_In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Link to="/challenges">
|
||||
<Button className="text-xl px-10">Challenges</Button>
|
||||
</Link>
|
||||
<Link to="/blog">
|
||||
<Button variant="secondary" className="text-xl px-10">Read Blog</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1hEzZiQvXvEesEyPd55AgRScM8zJxJvsT
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
165
Scoreboard.tsx
Normal file
165
Scoreboard.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Trophy, Table, CheckCircle2, Medal, SearchX } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { calculateTeamTotalScore, calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
||||
import { Button } from './UIComponents';
|
||||
|
||||
export const Scoreboard: React.FC = () => {
|
||||
const { state } = useCTF();
|
||||
const rankings = useMemo(() => state.teams.filter(t => !t.isAdmin && !t.isDisabled).map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves), solveCount: state.solves.filter(s => s.teamId === team.id).length })).sort((a, b) => b.score - a.score), [state]);
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-12 px-6">
|
||||
<div className="mb-12 border-b-4 border-[#ff0000] pb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-6xl font-black italic text-white uppercase tracking-tighter">SCOREBOARD</h2>
|
||||
</div>
|
||||
<Link to="/matrix" className="text-[10px] font-black text-[#ff0000] hover:underline flex items-center gap-1 uppercase tracking-widest"><Table size={12}/> View_Matrix</Link>
|
||||
</div>
|
||||
<div className="hxp-border border-2 bg-black overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-[#333] text-[10px] font-black uppercase"><tr><th className="p-4">RANK</th><th className="p-4">TEAM_IDENTIFIER</th><th className="p-4 text-center">SOLVES</th><th className="p-4 text-right">TOTAL_POINTS</th></tr></thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{rankings.map((team, idx) => (
|
||||
<tr key={team.id} className="hover:bg-white/5 group">
|
||||
<td className="p-4 font-black">
|
||||
<div className={`w-8 h-8 flex items-center justify-center font-black italic text-lg ${idx === 0 ? 'bg-[#ffaa00] text-black rotate-[-12deg]' : idx === 1 ? 'bg-slate-400 text-black rotate-[6deg]' : idx === 2 ? 'bg-[#cd7f32] text-black rotate-[-6deg]' : 'text-slate-500'}`}>{idx + 1}</div>
|
||||
</td>
|
||||
<td className="p-4 font-black italic text-xl text-white group-hover:text-[#bf00ff] transition-colors">{team.name}</td>
|
||||
<td className="p-4 text-center font-black text-[#bf00ff]">{team.solveCount}</td>
|
||||
<td className="p-4 text-right text-2xl font-black text-white italic">{team.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScoreMatrix: React.FC = () => {
|
||||
const { state } = useCTF();
|
||||
|
||||
const sortedTeams = useMemo(() => {
|
||||
return state.teams
|
||||
.filter(t => !t.isAdmin && !t.isDisabled)
|
||||
.map(t => ({ ...t, score: calculateTeamTotalScore(t.id, state.challenges, state.solves) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}, [state]);
|
||||
|
||||
const sortedChallenges = useMemo(() => {
|
||||
return [...state.challenges].sort((a, b) => (b.solves?.length || 0) - (a.solves?.length || 0));
|
||||
}, [state.challenges]);
|
||||
|
||||
if (state.solves.length === 0) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-32 px-6">
|
||||
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 flex justify-between items-end">
|
||||
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
|
||||
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
|
||||
</div>
|
||||
<div className="text-center hxp-border border-4 p-16 bg-black">
|
||||
<SearchX className="w-20 h-20 text-slate-700 mx-auto mb-6" />
|
||||
<h2 className="text-4xl font-black italic mb-4 uppercase text-white tracking-tighter">No solves yet!</h2>
|
||||
<p className="text-slate-500 font-bold tracking-widest uppercase mb-8">The matrix is currently empty. Be the first to solve a challenge!</p>
|
||||
<Link to="/challenges">
|
||||
<Button className="px-12 py-4 text-lg">Go to Challenges</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto py-12 px-6 custom-scrollbar">
|
||||
<div className="mb-12 border-b-4 border-[#00ff00] pb-6 max-w-6xl mx-auto flex justify-between items-end">
|
||||
<h2 className="text-6xl font-black italic tracking-tighter text-white uppercase leading-none">SCORE_MATRIX</h2>
|
||||
<Link to="/scoreboard" className="text-[10px] font-black text-[#00ff00] hover:underline flex items-center gap-1 uppercase tracking-widest"><Trophy size={12}/> View_Rankings</Link>
|
||||
</div>
|
||||
<div className="inline-block min-w-full bg-black hxp-border overflow-hidden">
|
||||
<table className="border-collapse w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-20 bg-black p-4 text-[10px] font-black uppercase text-slate-500 border-b-2 border-r-2 border-[#333] whitespace-nowrap min-w-[200px]">Team / Challenge</th>
|
||||
{sortedChallenges.map(c => {
|
||||
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
||||
return (
|
||||
<th key={c.id} className="p-0 border-b-2 border-r-2 border-[#333] min-w-[80px] align-bottom relative group/col">
|
||||
{/* Vertical text container using writing-mode so it stretches cell height automatically */}
|
||||
<div className="flex flex-col items-center justify-end pb-12 pt-8">
|
||||
<span className={`[writing-mode:vertical-rl] rotate-180 whitespace-nowrap text-[11px] font-black tracking-[0.2em] transition-colors uppercase ${diffColor} group-hover/col:brightness-125`}>
|
||||
{c.title}
|
||||
</span>
|
||||
</div>
|
||||
{/* Solve count tag at the bottom of the header cell */}
|
||||
<div className="absolute bottom-1 left-0 right-0 text-[8px] text-slate-600 font-bold uppercase text-center py-1 border-t border-[#333] bg-black/50">
|
||||
{(c.solves || []).length} SOLVES
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTeams.map(team => (
|
||||
<tr key={team.id} className="hover:bg-white/5 group">
|
||||
<td className="sticky left-0 z-10 bg-black p-3 font-black italic text-sm border-r-2 border-b-2 border-[#333] whitespace-nowrap group-hover:text-[#bf00ff]">
|
||||
{team.name} <span className="text-white not-italic opacity-40 ml-1 text-[10px] tracking-widest">({team.score})</span>
|
||||
</td>
|
||||
{sortedChallenges.map(c => {
|
||||
const solve = state.solves.find(s => s.challengeId === c.id && s.teamId === team.id);
|
||||
if (!solve) {
|
||||
return (
|
||||
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
||||
<div className="w-full h-12 flex items-center justify-center text-[#1a1a1a]">•</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Find rank for the medal and bonus
|
||||
const challengeSolves = state.solves
|
||||
.filter(s => s.challengeId === c.id)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
const rank = challengeSolves.findIndex(s => s.teamId === team.id);
|
||||
|
||||
// Calculate point gain for this specific solve
|
||||
const baseValue = calculateChallengeValue(
|
||||
c.initialPoints,
|
||||
c.minimumPoints || 0,
|
||||
c.decaySolves || 1,
|
||||
(c.solves || []).length
|
||||
);
|
||||
const bonus = Math.floor(c.initialPoints * getFirstBloodBonusFactor(rank));
|
||||
const totalPoints = baseValue + bonus;
|
||||
|
||||
return (
|
||||
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
||||
<div
|
||||
className={`w-full h-12 flex flex-col items-center justify-center ${rank === 0 ? 'bg-[#ffaa00]/10' : rank === 1 ? 'bg-slate-400/10' : rank === 2 ? 'bg-[#cd7f32]/10' : 'bg-[#00ff00]/5'}`}
|
||||
title={rank === 0 ? "First Blood!" : rank === 1 ? "Second Solver" : rank === 2 ? "Third Solver" : "Solved"}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{rank === 0 ? (
|
||||
<Medal size={16} className="text-[#ffaa00]" />
|
||||
) : rank === 1 ? (
|
||||
<Medal size={16} className="text-slate-400" />
|
||||
) : rank === 2 ? (
|
||||
<Medal size={16} className="text-[#cd7f32]" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} className="text-[#00ff00]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-white/70 mt-1 leading-none">{totalPoints}</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
UIComponents.tsx
Normal file
67
UIComponents.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Terminal, Radar, Zap, RefreshCw, Box } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
|
||||
export const formatDuration = (ms: number) => {
|
||||
if (ms < 0) return "00:00:00";
|
||||
const seconds = Math.floor((ms / 1000) % 60);
|
||||
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentUser } = useCTF();
|
||||
const location = useLocation();
|
||||
if (!currentUser) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const Countdown: React.FC<{ target: number; onEnd?: () => void; label?: string }> = ({ target, onEnd, label }) => {
|
||||
const [timeLeft, setTimeLeft] = useState(target - Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const remaining = target - Date.now();
|
||||
setTimeLeft(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
onEnd?.();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [target, onEnd]);
|
||||
|
||||
if (timeLeft <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{label && <span className="text-[10px] font-black uppercase text-slate-500 tracking-widest">{label}</span>}
|
||||
<span className="font-black text-xl italic tabular-nums text-[#ff0000] drop-shadow-[0_0_5px_rgba(255,0,0,0.5)]">
|
||||
{formatDuration(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CategoryIcon: React.FC<{ category: string; size?: number; color?: string }> = ({ category, size = 32, color = "currentColor" }) => {
|
||||
switch (category) {
|
||||
case 'WEB': return <Radar size={size} color={color} />;
|
||||
case 'PWN': return <Zap size={size} color={color} />;
|
||||
case 'REV': return <RefreshCw size={size} color={color} />;
|
||||
case 'CRY': return <Box size={size} color={color} />;
|
||||
default: return <Terminal size={size} color={color} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const Button: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' }> = ({ children, variant = 'primary', className = "", ...props }) => {
|
||||
const styles = variant === 'primary'
|
||||
? "bg-[#ff0000] text-black border-2 border-[#ff0000] hover:bg-black hover:text-[#ff0000] disabled:opacity-50"
|
||||
: "bg-black text-[#bf00ff] border-2 border-[#bf00ff] hover:bg-[#bf00ff] hover:text-black disabled:opacity-50";
|
||||
return (
|
||||
<button className={`px-4 py-2 font-black tracking-tighter transition-all active:scale-95 ${styles} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
13
constants.ts
13
constants.ts
@@ -11,9 +11,10 @@ export const INITIAL_CHALLENGES: Challenge[] = [
|
||||
category: 'REV',
|
||||
difficulty: 'Low',
|
||||
description: 'The sound of the future is encrypted. Can you find the melody?',
|
||||
initialPoints: 100,
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 20,
|
||||
flag: 'CTF{view_source_is_key}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
},
|
||||
@@ -23,9 +24,10 @@ export const INITIAL_CHALLENGES: Challenge[] = [
|
||||
category: 'MSC',
|
||||
difficulty: 'Medium',
|
||||
description: 'Wait, this shellcode looks like a poem...',
|
||||
initialPoints: 300,
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 20,
|
||||
flag: 'CTF{xor_is_not_encryption}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
},
|
||||
@@ -36,8 +38,9 @@ export const INITIAL_CHALLENGES: Challenge[] = [
|
||||
difficulty: 'High',
|
||||
description: 'Anxious math leads to anxious flags.',
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 20,
|
||||
flag: 'CTF{worrier_not_warrior}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
services:
|
||||
ctf-app:
|
||||
ctf-platform:
|
||||
build: .
|
||||
container_name: hipctf
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
# Map the local data directory to the container's DB path
|
||||
- ./data:/app/data
|
||||
# Map the uploads directory for persistent challenge files
|
||||
- ./uploads:/app/uploads
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
restart: unless-stopped
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "HIPCTF Platform",
|
||||
"description": "A comprehensive Capture The Flag platform build for the Hack Im Pott",
|
||||
"name": "HIPCTF",
|
||||
"description": "A comprehensive Capture The Flag platform with dynamic scoring, team registration, admin management, and real-time scoreboards.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
{
|
||||
"name": "hipctf",
|
||||
"name": "cypherstrike-ctf",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
|
||||
449
server.js
449
server.js
@@ -5,14 +5,39 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const cors = require('cors');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Password Hashing Helpers
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function comparePassword(password, storedPassword) {
|
||||
if (!storedPassword || !storedPassword.includes(':')) return false;
|
||||
const [salt, hash] = storedPassword.split(':');
|
||||
const checkHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return hash === checkHash;
|
||||
}
|
||||
|
||||
// Initialize Database
|
||||
const db = new sqlite3.Database('./ctf.db', (err) => {
|
||||
const db = new sqlite3.Database('./data/ctf.db', (err) => {
|
||||
if (err) console.error('Database connection error:', err.message);
|
||||
else console.log('Connected to the ctf.db SQLite database.');
|
||||
else {
|
||||
console.log('Connected to the ctf.db SQLite database.');
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
});
|
||||
|
||||
const dbAll = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup Storage
|
||||
@@ -27,7 +52,6 @@ const upload = multer({ storage });
|
||||
|
||||
// Database Schema & Migrations
|
||||
db.serialize(() => {
|
||||
// Ensure tables exist
|
||||
db.run(`CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE COLLATE NOCASE,
|
||||
@@ -43,29 +67,32 @@ db.serialize(() => {
|
||||
difficulty TEXT,
|
||||
description TEXT,
|
||||
initialPoints INTEGER,
|
||||
minimumPoints INTEGER DEFAULT 0,
|
||||
decaySolves INTEGER DEFAULT 1,
|
||||
flag TEXT,
|
||||
files TEXT DEFAULT '[]',
|
||||
port INTEGER,
|
||||
connectionType TEXT
|
||||
connectionType TEXT,
|
||||
overrideIp TEXT
|
||||
)`);
|
||||
|
||||
// Force migration check for existing columns
|
||||
db.all("PRAGMA table_info(challenges)", (err, rows) => {
|
||||
if (err) return;
|
||||
const columns = rows.map(r => r.name);
|
||||
if (!columns.includes('port')) {
|
||||
db.run("ALTER TABLE challenges ADD COLUMN port INTEGER DEFAULT 0");
|
||||
}
|
||||
if (!columns.includes('connectionType')) {
|
||||
db.run("ALTER TABLE challenges ADD COLUMN connectionType TEXT DEFAULT 'nc'");
|
||||
}
|
||||
if (!columns.includes('port')) db.run("ALTER TABLE challenges ADD COLUMN port INTEGER DEFAULT 0");
|
||||
if (!columns.includes('connectionType')) db.run("ALTER TABLE challenges ADD COLUMN connectionType TEXT DEFAULT 'nc'");
|
||||
if (!columns.includes('overrideIp')) db.run("ALTER TABLE challenges ADD COLUMN overrideIp TEXT");
|
||||
if (!columns.includes('minimumPoints')) db.run("ALTER TABLE challenges ADD COLUMN minimumPoints INTEGER DEFAULT 0");
|
||||
if (!columns.includes('decaySolves')) db.run("ALTER TABLE challenges ADD COLUMN decaySolves INTEGER DEFAULT 1");
|
||||
});
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS solves (
|
||||
teamId TEXT,
|
||||
challengeId TEXT,
|
||||
timestamp INTEGER,
|
||||
PRIMARY KEY (teamId, challengeId)
|
||||
PRIMARY KEY (teamId, challengeId),
|
||||
FOREIGN KEY (teamId) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (challengeId) REFERENCES challenges(id) ON DELETE CASCADE
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blogs (
|
||||
@@ -80,55 +107,86 @@ db.serialize(() => {
|
||||
value TEXT
|
||||
)`);
|
||||
|
||||
// Default Configs - Updated with Docker IP
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('isStarted', 'false')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('conferenceName', 'HIP')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('logoUrl', '')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgType', 'color')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgColor', '#000000')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgImageUrl', '')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgOpacity', '0.5')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgBrightness', '1.0')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgContrast', '1.0')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('dockerIp', '127.0.0.1')`);
|
||||
const defaults = [
|
||||
['isStarted', 'false'],
|
||||
['conferenceName', 'HIP'],
|
||||
['landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄'],
|
||||
['logoData', ''],
|
||||
['bgType', 'color'],
|
||||
['bgColor', '#000000'],
|
||||
['bgImageData', ''],
|
||||
['bgOpacity', '0.5'],
|
||||
['bgBrightness', '1.0'],
|
||||
['bgContrast', '1.0'],
|
||||
['dockerIp', '127.0.0.1'],
|
||||
['eventStartTime', Date.now().toString()],
|
||||
['eventEndTime', (Date.now() + 86400000).toString()]
|
||||
];
|
||||
|
||||
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', 'admin', 1, 0)`);
|
||||
defaults.forEach(([k, v]) => {
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, [k, v]);
|
||||
});
|
||||
|
||||
// Middleware
|
||||
const adminPass = hashPassword('admin');
|
||||
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', ?, 1, 0)`, [adminPass]);
|
||||
});
|
||||
|
||||
const unlinkFiles = (filesJson) => {
|
||||
try {
|
||||
const files = JSON.parse(filesJson || '[]');
|
||||
files.forEach(f => {
|
||||
const fileName = path.basename(f.url);
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
});
|
||||
} catch (e) { console.error("Error unlinking files:", e); }
|
||||
};
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
app.use('/files', express.static(uploadDir));
|
||||
|
||||
// API Router
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// State endpoint
|
||||
apiRouter.post('/auth/register', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
if (!name || !password) return res.status(400).json({ message: 'Missing credentials' });
|
||||
const id = 'team-' + Math.random().toString(36).substr(2, 9);
|
||||
const hashedPass = hashPassword(password);
|
||||
db.run("INSERT INTO teams (id, name, password, isAdmin, isDisabled) VALUES (?, ?, ?, 0, 0)", [id, name, hashedPass], function(err) {
|
||||
if (err) {
|
||||
if (err.message.includes('UNIQUE')) return res.status(400).json({ message: 'Team name already exists' });
|
||||
return res.status(500).json({ message: 'Registration failed' });
|
||||
}
|
||||
res.json({ team: { id, name, isAdmin: 0, isDisabled: 0 }, token: `mock-token-${id}` });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.post('/auth/login', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
db.get("SELECT * FROM teams WHERE name = ?", [name], (err, team) => {
|
||||
if (err || !team || !comparePassword(password, team.password)) return res.status(401).json({ message: 'Invalid credentials' });
|
||||
if (team.isDisabled) return res.status(403).json({ message: 'Account disabled' });
|
||||
const { password: _, ...teamData } = team;
|
||||
res.json({ team: teamData, token: `mock-token-${team.id}` });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.get('/state', (req, res) => {
|
||||
const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {} };
|
||||
|
||||
db.all("SELECT key, value FROM config", (err, configRows) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch config' });
|
||||
|
||||
configRows.forEach(row => {
|
||||
state.config[row.key] = row.value;
|
||||
});
|
||||
configRows.forEach(row => { state.config[row.key] = row.value; });
|
||||
state.isStarted = state.config.isStarted === 'true';
|
||||
|
||||
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
|
||||
state.teams = teams || [];
|
||||
|
||||
db.all("SELECT * FROM challenges", (err, challenges) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
|
||||
|
||||
db.all("SELECT * FROM solves", (err, solves) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch solves' });
|
||||
|
||||
db.all("SELECT * FROM blogs ORDER BY timestamp DESC", (err, blogs) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch blogs' });
|
||||
|
||||
state.solves = solves || [];
|
||||
state.blogs = blogs || [];
|
||||
state.challenges = (challenges || []).map(c => ({
|
||||
@@ -144,262 +202,193 @@ apiRouter.get('/state', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Auth
|
||||
apiRouter.post('/auth/register', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
if (!name || !password) return res.status(400).json({ message: 'Name and password required' });
|
||||
|
||||
const id = 'team-' + Math.random().toString(36).substr(2, 9);
|
||||
db.run("INSERT INTO teams (id, name, password, isAdmin, isDisabled) VALUES (?, ?, ?, 0, 0)", [id, name, password], function(err) {
|
||||
if (err) {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(400).json({ message: 'This team name is already taken.' });
|
||||
}
|
||||
return res.status(500).json({ message: 'Internal server error during registration.' });
|
||||
}
|
||||
res.json({ team: { id, name, isAdmin: false, isDisabled: false }, token: 'mock-token-' + id });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.post('/auth/login', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
db.get("SELECT * FROM teams WHERE name = ? AND password = ?", [name, password], (err, row) => {
|
||||
if (err || !row) return res.status(401).json({ message: 'Invalid credentials' });
|
||||
if (row.isDisabled) return res.status(403).json({ message: 'Account disabled' });
|
||||
res.json({ team: { id: row.id, name: row.name, isAdmin: !!row.isAdmin, isDisabled: !!row.isDisabled }, token: 'mock-token-' + row.id });
|
||||
});
|
||||
});
|
||||
|
||||
// Update Config
|
||||
apiRouter.put('/admin/config', upload.fields([{ name: 'logo' }, { name: 'bgImage' }]), (req, res) => {
|
||||
const updates = { ...req.body };
|
||||
|
||||
if (req.files) {
|
||||
if (req.files.logo) updates.logoUrl = `/files/${req.files.logo[0].filename}`;
|
||||
if (req.files.bgImage) updates.bgImageUrl = `/files/${req.files.bgImage[0].filename}`;
|
||||
if (req.files.logo) {
|
||||
const file = req.files.logo[0];
|
||||
const data = fs.readFileSync(file.path).toString('base64');
|
||||
updates.logoData = `data:${file.mimetype};base64,${data}`;
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
if (req.files.bgImage) {
|
||||
const file = req.files.bgImage[0];
|
||||
const data = fs.readFileSync(file.path).toString('base64');
|
||||
updates.bgImageData = `data:${file.mimetype};base64,${data}`;
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
db.serialize(() => {
|
||||
db.run("BEGIN TRANSACTION");
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)");
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
stmt.run(key, value);
|
||||
if (!['logo','bgImage','files','isStarted'].includes(key)) stmt.run(key, String(value));
|
||||
});
|
||||
stmt.finalize(() => {
|
||||
stmt.finalize();
|
||||
db.run("COMMIT", (err) => {
|
||||
if (err) return res.status(500).json({ success: false });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Challenges Submit
|
||||
apiRouter.get('/admin/db/export', async (req, res) => {
|
||||
try {
|
||||
const backup = {};
|
||||
const tables = ['teams', 'challenges', 'solves', 'blogs', 'config'];
|
||||
for (const table of tables) {
|
||||
const rows = await dbAll(`SELECT * FROM ${table}`);
|
||||
if (table === 'challenges') {
|
||||
for (const challenge of rows) {
|
||||
const files = JSON.parse(challenge.files || '[]');
|
||||
const enrichedFiles = files.map(file => {
|
||||
const filePath = path.join(uploadDir, path.basename(file.url));
|
||||
if (fs.existsSync(filePath)) return { ...file, base64: fs.readFileSync(filePath).toString('base64') };
|
||||
return file;
|
||||
});
|
||||
challenge.files = JSON.stringify(enrichedFiles);
|
||||
}
|
||||
}
|
||||
backup[table] = rows;
|
||||
}
|
||||
res.json(backup);
|
||||
} catch (err) { res.status(500).json({ error: 'EXPORT_FAILED' }); }
|
||||
});
|
||||
|
||||
apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ message: 'No file' });
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(req.file.path, 'utf8'));
|
||||
fs.unlinkSync(req.file.path);
|
||||
db.serialize(() => {
|
||||
db.run("BEGIN TRANSACTION");
|
||||
const tables = ['teams', 'challenges', 'solves', 'blogs', 'config'];
|
||||
tables.forEach(table => {
|
||||
db.run(`DELETE FROM ${table}`);
|
||||
if (!data[table] || data[table].length === 0) return;
|
||||
const rows = data[table];
|
||||
const cols = Object.keys(rows[0]);
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(()=>'?').join(',')})`);
|
||||
rows.forEach(row => {
|
||||
if (table === 'challenges') {
|
||||
const files = JSON.parse(row.files || '[]');
|
||||
row.files = JSON.stringify(files.map(file => {
|
||||
if (file.base64) {
|
||||
fs.writeFileSync(path.join(uploadDir, path.basename(file.url)), Buffer.from(file.base64, 'base64'));
|
||||
const { base64, ...rest } = file;
|
||||
return rest;
|
||||
}
|
||||
return file;
|
||||
}));
|
||||
}
|
||||
stmt.run(Object.values(row));
|
||||
});
|
||||
stmt.finalize();
|
||||
});
|
||||
db.run("COMMIT", (err) => { if (err) return res.status(500).json({ success: false }); res.json({ success: true }); });
|
||||
});
|
||||
} catch (e) { res.status(500).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
apiRouter.post('/challenges/submit', (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
||||
const { challengeId, flag } = req.body;
|
||||
|
||||
if (!teamId) return res.status(401).json({ success: false });
|
||||
|
||||
db.all("SELECT key, value FROM config", (err, configRows) => {
|
||||
const config = {};
|
||||
configRows.forEach(row => { config[row.key] = row.value; });
|
||||
const now = Date.now(), start = parseInt(config.eventStartTime || 0), end = parseInt(config.eventEndTime || Date.now() + 86400000);
|
||||
if (config.isStarted !== 'true' || now < start || now > end) return res.status(403).json({ success: false, message: 'COMPETITION_NOT_ACTIVE' });
|
||||
db.get("SELECT isDisabled FROM teams WHERE id = ?", [teamId], (err, team) => {
|
||||
if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' });
|
||||
|
||||
db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => {
|
||||
if (challenge && challenge.flag === flag) {
|
||||
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)",
|
||||
[teamId, challengeId, Date.now()], (err) => {
|
||||
res.json({ success: true });
|
||||
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true }));
|
||||
} else res.json({ success: false });
|
||||
});
|
||||
} else {
|
||||
res.json({ success: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
apiRouter.delete('/admin/challenges/all', (req, res) => {
|
||||
db.all("SELECT files FROM challenges", (err, rows) => {
|
||||
rows.forEach(r => unlinkFiles(r.files));
|
||||
db.serialize(() => { db.run("DELETE FROM challenges"); db.run("DELETE FROM solves", () => res.json({ success: true })); });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.post('/admin/toggle-ctf', (req, res) => {
|
||||
db.get("SELECT value FROM config WHERE key = 'isStarted'", (err, row) => {
|
||||
const newValue = row?.value === 'true' ? 'false' : 'true';
|
||||
db.run("UPDATE config SET value = ? WHERE key = 'isStarted'", [newValue], () => {
|
||||
res.json({ success: true, isStarted: newValue === 'true' });
|
||||
});
|
||||
db.run("UPDATE config SET value = ? WHERE key = 'isStarted'", [newValue], () => res.json({ success: true, isStarted: newValue === 'true' }));
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.post('/admin/reset-scores', (req, res) => { db.run("DELETE FROM solves", () => res.json({ success: true })); });
|
||||
|
||||
apiRouter.put('/profile', (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
||||
const { password } = req.body;
|
||||
const teamId = req.headers.authorization?.replace('Bearer mock-token-', '');
|
||||
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
||||
if (!password) return res.status(400).json({ message: 'Password required' });
|
||||
|
||||
db.run("UPDATE teams SET password = ? WHERE id = ?", [password, teamId], function(err) {
|
||||
if (err) return res.status(500).json({ message: 'Update failed' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
db.run("UPDATE teams SET password = ? WHERE id = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true }));
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/teams/:id', (req, res) => {
|
||||
const { name, isDisabled, password, isAdmin } = req.body;
|
||||
const id = req.params.id;
|
||||
|
||||
// Protect root admin from demotion or disabling
|
||||
const finalIsDisabled = id === 'admin-0' ? 0 : (isDisabled ? 1 : 0);
|
||||
const finalIsAdmin = id === 'admin-0' ? 1 : (isAdmin ? 1 : 0);
|
||||
|
||||
const { name, isDisabled, password, isAdmin } = req.body, id = req.params.id;
|
||||
let query = "UPDATE teams SET name = ?, isDisabled = ?, isAdmin = ?";
|
||||
let params = [name, finalIsDisabled, finalIsAdmin];
|
||||
if (password) {
|
||||
query += ", password = ?";
|
||||
params.push(password);
|
||||
}
|
||||
query += " WHERE id = ?";
|
||||
params.push(id);
|
||||
db.run(query, params, function(err) {
|
||||
if (err) return res.status(400).json({ message: 'Update failed.' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
let params = [name, id === 'admin-0' ? 0 : (isDisabled ? 1 : 0), id === 'admin-0' ? 1 : (isAdmin ? 1 : 0)];
|
||||
if (password) { query += ", password = ?"; params.push(hashPassword(password)); }
|
||||
query += " WHERE id = ?"; params.push(id);
|
||||
db.run(query, params, () => res.json({ success: true }));
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/teams/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (id === 'admin-0') return res.status(403).json({ message: 'Cannot delete root admin' });
|
||||
db.run("DELETE FROM teams WHERE id = ?", [id], () => {
|
||||
db.run("DELETE FROM solves WHERE teamId = ?", [id], () => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
if (req.params.id === 'admin-0') return res.status(403).json({ message: 'Protected' });
|
||||
db.run("DELETE FROM teams WHERE id = ?", [req.params.id], () => db.run("DELETE FROM solves WHERE teamId = ?", [req.params.id], () => res.json({ success: true })));
|
||||
});
|
||||
|
||||
// Blog Management
|
||||
apiRouter.post('/admin/blogs', (req, res) => {
|
||||
const { title, content } = req.body;
|
||||
if (!title || !content) return res.status(400).json({ message: 'Title and content required' });
|
||||
const id = 'blog-' + Math.random().toString(36).substr(2, 9);
|
||||
const timestamp = Date.now();
|
||||
|
||||
db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||
[id, title, content, timestamp], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true, id });
|
||||
}
|
||||
);
|
||||
db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)", [id, req.body.title, req.body.content, Date.now()], () => res.json({ success: true, id }));
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/blogs/:id', (req, res) => {
|
||||
const { title, content } = req.body;
|
||||
const { id } = req.params;
|
||||
if (!title || !content) return res.status(400).json({ message: 'Title and content required' });
|
||||
apiRouter.put('/admin/blogs/:id', (req, res) => { db.run("UPDATE blogs SET title = ?, content = ? WHERE id = ?", [req.body.title, req.body.content, req.params.id], () => res.json({ success: true })); });
|
||||
apiRouter.delete('/admin/blogs/:id', (req, res) => { db.run("DELETE FROM blogs WHERE id = ?", [req.params.id], () => res.json({ success: true })); });
|
||||
|
||||
db.run("UPDATE blogs SET title = ?, content = ? WHERE id = ?",
|
||||
[title, content, id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/blogs/:id', (req, res) => {
|
||||
db.run("DELETE FROM blogs WHERE id = ?", [req.params.id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Challenges Management
|
||||
apiRouter.post('/admin/challenges', upload.array('files'), (req, res) => {
|
||||
const { title, category, difficulty, description, initialPoints, flag, port, connectionType } = req.body;
|
||||
if (!title || !flag) return res.status(400).json({ message: 'Title and flag are required.' });
|
||||
|
||||
const { title, category, difficulty, description, initialPoints, minimumPoints, decaySolves, flag, port, connectionType, overrideIp } = req.body;
|
||||
const id = 'chal-' + Math.random().toString(36).substr(2, 9);
|
||||
const files = (req.files || []).map(f => ({
|
||||
name: f.originalname,
|
||||
url: `/files/${f.filename}`
|
||||
}));
|
||||
|
||||
db.run(`INSERT INTO challenges (id, title, category, difficulty, description, initialPoints, flag, files, port, connectionType)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, title, category, difficulty, description, parseInt(initialPoints) || 0, flag, JSON.stringify(files), parseInt(port) || 0, connectionType || 'nc'],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error("Error creating challenge:", err.message);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
res.json({ id, success: true });
|
||||
}
|
||||
const files = (req.files || []).map(f => ({ name: f.originalname, url: `/files/${f.filename}` }));
|
||||
db.run(`INSERT INTO challenges (id, title, category, difficulty, description, initialPoints, minimumPoints, decaySolves, flag, files, port, connectionType, overrideIp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, title, category, difficulty, description, parseInt(initialPoints) || 0, parseInt(minimumPoints) || 0, parseInt(decaySolves) || 1, flag, JSON.stringify(files), parseInt(port) || 0, connectionType || 'nc', overrideIp],
|
||||
() => res.json({ id, success: true })
|
||||
);
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/challenges/:id', upload.array('files'), (req, res) => {
|
||||
const { title, category, difficulty, description, initialPoints, flag, existingFiles, port, connectionType } = req.body;
|
||||
const id = req.params.id;
|
||||
if (!id) return res.status(400).json({ message: 'Challenge ID is required for update.' });
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = JSON.parse(existingFiles || '[]');
|
||||
} catch (e) {
|
||||
console.error("Error parsing existingFiles:", e);
|
||||
files = [];
|
||||
}
|
||||
|
||||
if (req.files) {
|
||||
req.files.forEach(f => {
|
||||
files.push({
|
||||
name: f.originalname,
|
||||
url: `/files/${f.filename}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const query = `UPDATE challenges SET title=?, category=?, difficulty=?, description=?, initialPoints=?, flag=?, files=?, port=?, connectionType=? WHERE id=?`;
|
||||
const params = [
|
||||
title || "",
|
||||
category || "WEB",
|
||||
difficulty || "Low",
|
||||
description || "",
|
||||
parseInt(initialPoints) || 0,
|
||||
flag || "",
|
||||
JSON.stringify(files),
|
||||
port ? parseInt(port) : 0,
|
||||
connectionType || 'nc',
|
||||
id
|
||||
];
|
||||
|
||||
db.run(query, params, function(err) {
|
||||
if (err) {
|
||||
console.error("Error updating challenge:", err.message);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
console.warn(`No challenge found with ID: ${id}`);
|
||||
return res.status(404).json({ message: "Challenge not found." });
|
||||
}
|
||||
res.json({ success: true, id });
|
||||
db.get("SELECT files FROM challenges WHERE id = ?", [id], (err, row) => {
|
||||
let files = JSON.parse(req.body.existingFiles || '[]'), oldFiles = JSON.parse(row.files || '[]');
|
||||
oldFiles.forEach(of => { if (!files.find(f => f.url === of.url)) { const filePath = path.join(uploadDir, path.basename(of.url)); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } });
|
||||
if (req.files) req.files.forEach(f => files.push({ name: f.originalname, url: `/files/${f.filename}` }));
|
||||
const query = `UPDATE challenges SET title=?, category=?, difficulty=?, description=?, initialPoints=?, minimumPoints=?, decaySolves=?, flag=?, files=?, port=?, connectionType=?, overrideIp=? WHERE id=?`;
|
||||
const params = [req.body.title, req.body.category, req.body.difficulty, req.body.description, parseInt(req.body.initialPoints), parseInt(req.body.minimumPoints), parseInt(req.body.decaySolves), req.body.flag, JSON.stringify(files), parseInt(req.body.port), req.body.connectionType, req.body.overrideIp, id];
|
||||
db.run(query, params, () => res.json({ success: true, id }));
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/challenges/:id', (req, res) => {
|
||||
db.run("DELETE FROM challenges WHERE id = ?", [req.params.id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
db.run("DELETE FROM solves WHERE challengeId = ?", [req.params.id], () => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
db.get("SELECT files FROM challenges WHERE id = ?", [req.params.id], (err, row) => {
|
||||
if (row) unlinkFiles(row.files);
|
||||
db.serialize(() => { db.run("DELETE FROM challenges WHERE id = ?", [req.params.id]); db.run("DELETE FROM solves WHERE challengeId = ?", [req.params.id], () => res.json({ success: true })); });
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
const distPath = path.join(__dirname, 'dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/files')) {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
} else {
|
||||
res.status(404).json({ error: 'API resource not found' });
|
||||
app.get('*', (req, res) => { if (!req.path.startsWith('/api') && !req.path.startsWith('/files')) res.sendFile(path.join(distPath, 'index.html')); else res.status(404).json({ error: 'Not found' }); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`HIP6 CTF Backend Server running at http://127.0.0.1:${port}`);
|
||||
});
|
||||
app.listen(port, '0.0.0.0', () => console.log(`CTF Backend running on port ${port}`));
|
||||
|
||||
117
services/api.ts
117
services/api.ts
@@ -6,9 +6,7 @@ const API_BASE = '/api';
|
||||
class ApiService {
|
||||
private getHeaders() {
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
@@ -17,14 +15,9 @@ class ApiService {
|
||||
}
|
||||
|
||||
async getState(): Promise<CTFState> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/state`);
|
||||
if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
console.error('API Error (getState):', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async login(name: string, pass: string): Promise<{ team: Team, token: string }> {
|
||||
@@ -58,46 +51,84 @@ class ApiService {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Unauthorized or failed to toggle CTF');
|
||||
if (!res.ok) throw new Error('Failed to toggle CTF');
|
||||
}
|
||||
|
||||
async resetScores(): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/reset-scores`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to reset scores');
|
||||
}
|
||||
|
||||
async backupDatabase(): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/admin/db/export`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Backup failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async restoreDatabase(file: File): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('restoreFile', file);
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
|
||||
const res = await fetch(`${API_BASE}/admin/db/restore`, { method: 'POST', headers, body: formData });
|
||||
if (!res.ok) throw new Error('Restore failed');
|
||||
}
|
||||
|
||||
async exportChallenges(): Promise<{ challenges: any[] }> {
|
||||
const res = await fetch(`${API_BASE}/admin/challenges/export`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Export failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async importChallenges(file: File): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('importFile', file);
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
|
||||
const res = await fetch(`${API_BASE}/admin/challenges/import`, { method: 'POST', headers, body: formData });
|
||||
if (!res.ok) throw new Error('Import failed');
|
||||
}
|
||||
|
||||
async deleteAllChallenges(): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/challenges/all`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete all challenges');
|
||||
}
|
||||
|
||||
async updateConfig(formData: FormData): Promise<void> {
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {};
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/admin/config`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update configuration');
|
||||
const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
|
||||
const res = await fetch(`${API_BASE}/admin/config`, { method: 'PUT', headers, body: formData });
|
||||
if (!res.ok) throw new Error('Failed to update config');
|
||||
}
|
||||
|
||||
async updateTeam(id: string, data: { name: string, isDisabled: boolean, isAdmin: boolean, password?: string }): Promise<void> {
|
||||
async updateTeam(id: string, data: any): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Failed to update team');
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to update team');
|
||||
}
|
||||
|
||||
async updateProfile(data: { password?: string }): Promise<void> {
|
||||
async updateProfile(data: any): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Failed to update profile');
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to update profile');
|
||||
}
|
||||
|
||||
async deleteTeam(id: string): Promise<void> {
|
||||
@@ -114,30 +145,15 @@ class ApiService {
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ challengeId, flag }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Submission failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async upsertChallenge(formData: FormData, id?: string): Promise<any> {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `${API_BASE}/admin/challenges/${id}` : `${API_BASE}/admin/challenges`;
|
||||
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {};
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Save failed' }));
|
||||
throw new Error(err.message || 'Failed to save challenge');
|
||||
}
|
||||
const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
|
||||
const res = await fetch(url, { method, headers, body: formData });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -149,22 +165,20 @@ class ApiService {
|
||||
if (!res.ok) throw new Error('Failed to delete challenge');
|
||||
}
|
||||
|
||||
async createBlogPost(data: { title: string, content: string }): Promise<void> {
|
||||
async createBlogPost(data: any): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/blogs`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create blog post');
|
||||
}
|
||||
|
||||
async updateBlogPost(id: string, data: { title: string, content: string }): Promise<void> {
|
||||
async updateBlogPost(id: string, data: any): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update blog post');
|
||||
}
|
||||
|
||||
async deleteBlogPost(id: string): Promise<void> {
|
||||
@@ -172,7 +186,6 @@ class ApiService {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete blog post');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
|
||||
/**
|
||||
* Dynamic Scoring Algorithm
|
||||
* Dynamic Scoring Algorithm (Parabolic Decay)
|
||||
* Logic:
|
||||
* 1. Base Score starts at initialPoints.
|
||||
* 2. As solves increase, the score for EVERYONE who solved it decreases.
|
||||
* 3. We use a decay function: currentPoints = max(min_points, initial * decay_factor ^ (solves - 1))
|
||||
* 4. Plus a "First Blood" bonus: 1st solver gets 10% extra, 2nd 5%, 3rd 2%.
|
||||
* 1. Base Value = ((minimum - initial) / (decay^2)) * (solve_count^2) + initial
|
||||
* 2. If solve_count >= decay, return minimum.
|
||||
* 3. Fixed Bonuses (based on initial points): 1st: 15%, 2nd: 10%, 3rd: 5%.
|
||||
*/
|
||||
|
||||
const MIN_POINTS_PERCENTAGE = 0.2; // Points won't drop below 20% of initial
|
||||
const DECAY_CONSTANT = 0.92; // Aggressive decay per solve
|
||||
export const calculateChallengeValue = (
|
||||
initial: number,
|
||||
minimum: number,
|
||||
decay: number,
|
||||
solveCount: number
|
||||
): number => {
|
||||
if (solveCount === 0) return initial;
|
||||
if (decay <= 0 || solveCount >= decay) return minimum;
|
||||
|
||||
export const calculateChallengeValue = (initialPoints: number, solveCount: number): number => {
|
||||
if (solveCount === 0) return initialPoints;
|
||||
const minPoints = Math.floor(initialPoints * MIN_POINTS_PERCENTAGE);
|
||||
const decayedPoints = Math.floor(initialPoints * Math.pow(DECAY_CONSTANT, solveCount - 1));
|
||||
return Math.max(minPoints, decayedPoints);
|
||||
// Parabolic formula: ((min - init) / decay^2) * solveCount^2 + init
|
||||
const value = ((minimum - initial) / (decay * decay)) * (solveCount * solveCount) + initial;
|
||||
|
||||
return Math.ceil(value);
|
||||
};
|
||||
|
||||
export const getFirstBloodBonus = (rank: number): number => {
|
||||
if (rank === 0) return 0.10; // 1st
|
||||
if (rank === 1) return 0.05; // 2nd
|
||||
if (rank === 2) return 0.02; // 3rd
|
||||
export const getFirstBloodBonusFactor = (rank: number): number => {
|
||||
if (rank === 0) return 0.15; // 1st
|
||||
if (rank === 1) return 0.10; // 2nd
|
||||
if (rank === 2) return 0.05; // 3rd
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const calculateTeamTotalScore = (
|
||||
teamId: string,
|
||||
challenges: { id: string, initialPoints: number, solves: string[] }[],
|
||||
challenges: any[],
|
||||
solves: { teamId: string, challengeId: string, timestamp: number }[]
|
||||
): number => {
|
||||
let total = 0;
|
||||
@@ -39,17 +43,21 @@ export const calculateTeamTotalScore = (
|
||||
const challenge = challenges.find(c => c.id === solve.challengeId);
|
||||
if (!challenge) return;
|
||||
|
||||
// Current value of challenge (shared by all)
|
||||
const baseValue = calculateChallengeValue(challenge.initialPoints, challenge.solves.length);
|
||||
// Current dynamic base value (retroactive for everyone)
|
||||
const baseValue = calculateChallengeValue(
|
||||
challenge.initialPoints,
|
||||
challenge.minimumPoints || 0,
|
||||
challenge.decaySolves || 1,
|
||||
challenge.solves.length
|
||||
);
|
||||
|
||||
// Calculate rank for bonus
|
||||
// Find all solves for this challenge sorted by time
|
||||
// Calculate rank for fixed bonus based on initial value
|
||||
const challengeSolves = solves
|
||||
.filter(s => s.challengeId === solve.challengeId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const rank = challengeSolves.findIndex(s => s.teamId === teamId);
|
||||
const bonus = Math.floor(baseValue * getFirstBloodBonus(rank));
|
||||
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
|
||||
|
||||
total += (baseValue + bonus);
|
||||
});
|
||||
|
||||
5
types.ts
5
types.ts
@@ -13,11 +13,14 @@ export interface Challenge {
|
||||
difficulty: Difficulty;
|
||||
description: string;
|
||||
initialPoints: number;
|
||||
minimumPoints: number; // Lowest possible points
|
||||
decaySolves: number; // Solves to reach minimum
|
||||
flag: string;
|
||||
files: ChallengeFile[];
|
||||
solves: string[]; // Team IDs
|
||||
port?: number;
|
||||
connectionType?: 'nc' | 'http';
|
||||
overrideIp?: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
@@ -32,7 +35,7 @@ export interface Solve {
|
||||
teamId: string;
|
||||
challengeId: string;
|
||||
timestamp: number;
|
||||
pointsEarned: number; // Stored at time of solve, but usually recalculated dynamically
|
||||
pointsEarned: number; // Not used for dynamic calc but stored for history
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
|
||||
Reference in New Issue
Block a user