Made app more modular.

Fixed some bugs.
Added some functionality.
This commit is contained in:
m0rph3us1987
2026-01-21 18:59:14 +01:00
parent 5802b80d61
commit 40f496c3f2
18 changed files with 1709 additions and 1535 deletions

482
Admin.tsx Normal file
View 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>
);
};

1248
App.tsx

File diff suppressed because it is too large Load Diff

127
Auth.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
};

View File

@@ -11,9 +11,10 @@ export const INITIAL_CHALLENGES: Challenge[] = [
category: 'REV', category: 'REV',
difficulty: 'Low', difficulty: 'Low',
description: 'The sound of the future is encrypted. Can you find the melody?', 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}', flag: 'CTF{view_source_is_key}',
// Fix: Added missing 'files' property
files: [], files: [],
solves: [] solves: []
}, },
@@ -23,9 +24,10 @@ export const INITIAL_CHALLENGES: Challenge[] = [
category: 'MSC', category: 'MSC',
difficulty: 'Medium', difficulty: 'Medium',
description: 'Wait, this shellcode looks like a poem...', description: 'Wait, this shellcode looks like a poem...',
initialPoints: 300, initialPoints: 500,
minimumPoints: 100,
decaySolves: 20,
flag: 'CTF{xor_is_not_encryption}', flag: 'CTF{xor_is_not_encryption}',
// Fix: Added missing 'files' property
files: [], files: [],
solves: [] solves: []
}, },
@@ -36,8 +38,9 @@ export const INITIAL_CHALLENGES: Challenge[] = [
difficulty: 'High', difficulty: 'High',
description: 'Anxious math leads to anxious flags.', description: 'Anxious math leads to anxious flags.',
initialPoints: 500, initialPoints: 500,
minimumPoints: 100,
decaySolves: 20,
flag: 'CTF{worrier_not_warrior}', flag: 'CTF{worrier_not_warrior}',
// Fix: Added missing 'files' property
files: [], files: [],
solves: [] solves: []
} }

View File

@@ -1,9 +1,15 @@
services: services:
ctf-app: ctf-platform:
build: . build: .
container_name: hipctf container_name: hipctf
ports: 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: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000
restart: unless-stopped restart: unless-stopped

View File

@@ -1,5 +1,5 @@
{ {
"name": "HIPCTF Platform", "name": "HIPCTF",
"description": "A comprehensive Capture The Flag platform build for the Hack Im Pott", "description": "A comprehensive Capture The Flag platform with dynamic scoring, team registration, admin management, and real-time scoreboards.",
"requestFramePermissions": [] "requestFramePermissions": []
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "hipctf", "name": "cypherstrike-ctf",
"version": "1.0.0", "version": "1.0.0",
"main": "server.js", "main": "server.js",
"dependencies": { "dependencies": {

449
server.js
View File

@@ -5,14 +5,39 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const cors = require('cors'); const cors = require('cors');
const crypto = require('crypto');
const app = express(); const app = express();
const port = process.env.PORT || 3000; 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 // 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); 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 // Setup Storage
@@ -27,7 +52,6 @@ const upload = multer({ storage });
// Database Schema & Migrations // Database Schema & Migrations
db.serialize(() => { db.serialize(() => {
// Ensure tables exist
db.run(`CREATE TABLE IF NOT EXISTS teams ( db.run(`CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT UNIQUE COLLATE NOCASE, name TEXT UNIQUE COLLATE NOCASE,
@@ -43,29 +67,32 @@ db.serialize(() => {
difficulty TEXT, difficulty TEXT,
description TEXT, description TEXT,
initialPoints INTEGER, initialPoints INTEGER,
minimumPoints INTEGER DEFAULT 0,
decaySolves INTEGER DEFAULT 1,
flag TEXT, flag TEXT,
files TEXT DEFAULT '[]', files TEXT DEFAULT '[]',
port INTEGER, port INTEGER,
connectionType TEXT connectionType TEXT,
overrideIp TEXT
)`); )`);
// Force migration check for existing columns
db.all("PRAGMA table_info(challenges)", (err, rows) => { db.all("PRAGMA table_info(challenges)", (err, rows) => {
if (err) return; if (err) return;
const columns = rows.map(r => r.name); const columns = rows.map(r => r.name);
if (!columns.includes('port')) { if (!columns.includes('port')) db.run("ALTER TABLE challenges ADD COLUMN port INTEGER DEFAULT 0");
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('connectionType')) { if (!columns.includes('minimumPoints')) db.run("ALTER TABLE challenges ADD COLUMN minimumPoints INTEGER DEFAULT 0");
db.run("ALTER TABLE challenges ADD COLUMN connectionType TEXT DEFAULT 'nc'"); if (!columns.includes('decaySolves')) db.run("ALTER TABLE challenges ADD COLUMN decaySolves INTEGER DEFAULT 1");
}
}); });
db.run(`CREATE TABLE IF NOT EXISTS solves ( db.run(`CREATE TABLE IF NOT EXISTS solves (
teamId TEXT, teamId TEXT,
challengeId TEXT, challengeId TEXT,
timestamp INTEGER, 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 ( db.run(`CREATE TABLE IF NOT EXISTS blogs (
@@ -80,55 +107,86 @@ db.serialize(() => {
value TEXT value TEXT
)`); )`);
// Default Configs - Updated with Docker IP const defaults = [
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('isStarted', 'false')`); ['isStarted', 'false'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('conferenceName', 'HIP')`); ['conferenceName', 'HIP'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄')`); ['landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('logoUrl', '')`); ['logoData', ''],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgType', 'color')`); ['bgType', 'color'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgColor', '#000000')`); ['bgColor', '#000000'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgImageUrl', '')`); ['bgImageData', ''],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgOpacity', '0.5')`); ['bgOpacity', '0.5'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgBrightness', '1.0')`); ['bgBrightness', '1.0'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgContrast', '1.0')`); ['bgContrast', '1.0'],
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('dockerIp', '127.0.0.1')`); ['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(cors());
app.use(express.json()); app.use(express.json({ limit: '500mb' }));
app.use('/files', express.static(uploadDir)); app.use('/files', express.static(uploadDir));
// API Router
const apiRouter = express.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) => { apiRouter.get('/state', (req, res) => {
const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {} }; const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {} };
db.all("SELECT key, value FROM config", (err, configRows) => { db.all("SELECT key, value FROM config", (err, configRows) => {
if (err) return res.status(500).json({ error: 'Failed to fetch config' }); 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'; state.isStarted = state.config.isStarted === 'true';
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => { db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
if (err) return res.status(500).json({ error: 'Failed to fetch teams' }); if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
state.teams = teams || []; state.teams = teams || [];
db.all("SELECT * FROM challenges", (err, challenges) => { db.all("SELECT * FROM challenges", (err, challenges) => {
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' }); if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
db.all("SELECT * FROM solves", (err, solves) => { db.all("SELECT * FROM solves", (err, solves) => {
if (err) return res.status(500).json({ error: 'Failed to fetch solves' }); if (err) return res.status(500).json({ error: 'Failed to fetch solves' });
db.all("SELECT * FROM blogs ORDER BY timestamp DESC", (err, blogs) => { db.all("SELECT * FROM blogs ORDER BY timestamp DESC", (err, blogs) => {
if (err) return res.status(500).json({ error: 'Failed to fetch blogs' }); if (err) return res.status(500).json({ error: 'Failed to fetch blogs' });
state.solves = solves || []; state.solves = solves || [];
state.blogs = blogs || []; state.blogs = blogs || [];
state.challenges = (challenges || []).map(c => ({ 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) => { apiRouter.put('/admin/config', upload.fields([{ name: 'logo' }, { name: 'bgImage' }]), (req, res) => {
const updates = { ...req.body }; const updates = { ...req.body };
if (req.files) { if (req.files) {
if (req.files.logo) updates.logoUrl = `/files/${req.files.logo[0].filename}`; if (req.files.logo) {
if (req.files.bgImage) updates.bgImageUrl = `/files/${req.files.bgImage[0].filename}`; 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.serialize(() => {
db.run("BEGIN TRANSACTION");
const stmt = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"); const stmt = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)");
Object.entries(updates).forEach(([key, value]) => { 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 }); 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) => { apiRouter.post('/challenges/submit', (req, res) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null; const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
const { challengeId, flag } = req.body; const { challengeId, flag } = req.body;
if (!teamId) return res.status(401).json({ success: false }); 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) => { db.get("SELECT isDisabled FROM teams WHERE id = ?", [teamId], (err, team) => {
if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' }); if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' });
db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => { db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => {
if (challenge && challenge.flag === flag) { if (challenge && challenge.flag === flag) {
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true }));
[teamId, challengeId, Date.now()], (err) => { } else res.json({ success: false });
res.json({ success: true });
}); });
} 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) => { apiRouter.post('/admin/toggle-ctf', (req, res) => {
db.get("SELECT value FROM config WHERE key = 'isStarted'", (err, row) => { db.get("SELECT value FROM config WHERE key = 'isStarted'", (err, row) => {
const newValue = row?.value === 'true' ? 'false' : 'true'; const newValue = row?.value === 'true' ? 'false' : 'true';
db.run("UPDATE config SET value = ? WHERE key = 'isStarted'", [newValue], () => { db.run("UPDATE config SET value = ? WHERE key = 'isStarted'", [newValue], () => res.json({ success: true, isStarted: newValue === 'true' }));
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) => { apiRouter.put('/profile', (req, res) => {
const authHeader = req.headers.authorization; const teamId = req.headers.authorization?.replace('Bearer mock-token-', '');
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
const { password } = req.body;
if (!teamId) return res.status(401).json({ message: 'Unauthorized' }); 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 = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true }));
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 });
});
}); });
apiRouter.put('/admin/teams/:id', (req, res) => { apiRouter.put('/admin/teams/:id', (req, res) => {
const { name, isDisabled, password, isAdmin } = req.body; const { name, isDisabled, password, isAdmin } = req.body, id = req.params.id;
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);
let query = "UPDATE teams SET name = ?, isDisabled = ?, isAdmin = ?"; let query = "UPDATE teams SET name = ?, isDisabled = ?, isAdmin = ?";
let params = [name, finalIsDisabled, finalIsAdmin]; let params = [name, id === 'admin-0' ? 0 : (isDisabled ? 1 : 0), id === 'admin-0' ? 1 : (isAdmin ? 1 : 0)];
if (password) { if (password) { query += ", password = ?"; params.push(hashPassword(password)); }
query += ", password = ?"; query += " WHERE id = ?"; params.push(id);
params.push(password); db.run(query, params, () => res.json({ success: true }));
}
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 });
});
}); });
apiRouter.delete('/admin/teams/:id', (req, res) => { apiRouter.delete('/admin/teams/:id', (req, res) => {
const id = req.params.id; if (req.params.id === 'admin-0') return res.status(403).json({ message: 'Protected' });
if (id === 'admin-0') return res.status(403).json({ message: 'Cannot delete root admin' }); db.run("DELETE FROM teams WHERE id = ?", [req.params.id], () => db.run("DELETE FROM solves WHERE teamId = ?", [req.params.id], () => res.json({ success: true })));
db.run("DELETE FROM teams WHERE id = ?", [id], () => {
db.run("DELETE FROM solves WHERE teamId = ?", [id], () => {
res.json({ success: true });
});
});
}); });
// Blog Management
apiRouter.post('/admin/blogs', (req, res) => { 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 id = 'blog-' + Math.random().toString(36).substr(2, 9);
const timestamp = Date.now(); db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)", [id, req.body.title, req.body.content, Date.now()], () => res.json({ success: true, id }));
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 });
}
);
}); });
apiRouter.put('/admin/blogs/:id', (req, res) => { 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 })); });
const { title, content } = req.body; apiRouter.delete('/admin/blogs/:id', (req, res) => { db.run("DELETE FROM blogs WHERE id = ?", [req.params.id], () => res.json({ success: true })); });
const { id } = req.params;
if (!title || !content) return res.status(400).json({ message: 'Title and content required' });
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) => { apiRouter.post('/admin/challenges', upload.array('files'), (req, res) => {
const { title, category, difficulty, description, initialPoints, flag, port, connectionType } = req.body; const { title, category, difficulty, description, initialPoints, minimumPoints, decaySolves, flag, port, connectionType, overrideIp } = req.body;
if (!title || !flag) return res.status(400).json({ message: 'Title and flag are required.' });
const id = 'chal-' + Math.random().toString(36).substr(2, 9); const id = 'chal-' + Math.random().toString(36).substr(2, 9);
const files = (req.files || []).map(f => ({ const files = (req.files || []).map(f => ({ name: f.originalname, url: `/files/${f.filename}` }));
name: f.originalname, db.run(`INSERT INTO challenges (id, title, category, difficulty, description, initialPoints, minimumPoints, decaySolves, flag, files, port, connectionType, overrideIp)
url: `/files/${f.filename}` 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 })
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 });
}
); );
}); });
apiRouter.put('/admin/challenges/:id', upload.array('files'), (req, res) => { 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; const id = req.params.id;
if (!id) return res.status(400).json({ message: 'Challenge ID is required for update.' }); db.get("SELECT files FROM challenges WHERE id = ?", [id], (err, row) => {
let files = JSON.parse(req.body.existingFiles || '[]'), oldFiles = JSON.parse(row.files || '[]');
let 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); } });
try { if (req.files) req.files.forEach(f => files.push({ name: f.originalname, url: `/files/${f.filename}` }));
files = JSON.parse(existingFiles || '[]'); const query = `UPDATE challenges SET title=?, category=?, difficulty=?, description=?, initialPoints=?, minimumPoints=?, decaySolves=?, flag=?, files=?, port=?, connectionType=?, overrideIp=? WHERE id=?`;
} catch (e) { 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];
console.error("Error parsing existingFiles:", e); db.run(query, params, () => res.json({ success: true, id }));
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 });
}); });
}); });
apiRouter.delete('/admin/challenges/:id', (req, res) => { apiRouter.delete('/admin/challenges/:id', (req, res) => {
db.run("DELETE FROM challenges WHERE id = ?", [req.params.id], (err) => { db.get("SELECT files FROM challenges WHERE id = ?", [req.params.id], (err, row) => {
if (err) return res.status(500).json({ message: err.message }); if (row) unlinkFiles(row.files);
db.run("DELETE FROM solves WHERE challengeId = ?", [req.params.id], () => { 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 })); });
res.json({ success: true });
});
}); });
}); });
app.use('/api', apiRouter); app.use('/api', apiRouter);
const distPath = path.join(__dirname, 'dist'); const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) { if (fs.existsSync(distPath)) {
app.use(express.static(distPath)); app.use(express.static(distPath));
app.get('*', (req, res) => { 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' }); });
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.listen(port, '0.0.0.0', () => console.log(`CTF Backend running on port ${port}`));
}
app.listen(port, '0.0.0.0', () => {
console.log(`HIP6 CTF Backend Server running at http://127.0.0.1:${port}`);
});

View File

@@ -6,9 +6,7 @@ const API_BASE = '/api';
class ApiService { class ApiService {
private getHeaders() { private getHeaders() {
const session = localStorage.getItem('hip6_session'); const session = localStorage.getItem('hip6_session');
const headers: HeadersInit = { const headers: HeadersInit = { 'Content-Type': 'application/json' };
'Content-Type': 'application/json',
};
if (session) { if (session) {
const { token } = JSON.parse(session); const { token } = JSON.parse(session);
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -17,14 +15,9 @@ class ApiService {
} }
async getState(): Promise<CTFState> { async getState(): Promise<CTFState> {
try {
const res = await fetch(`${API_BASE}/state`); const res = await fetch(`${API_BASE}/state`);
if (!res.ok) throw new Error(`HTTP Error ${res.status}`); if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
return res.json(); return res.json();
} catch (err) {
console.error('API Error (getState):', err);
throw err;
}
} }
async login(name: string, pass: string): Promise<{ team: Team, token: string }> { async login(name: string, pass: string): Promise<{ team: Team, token: string }> {
@@ -58,46 +51,84 @@ class ApiService {
method: 'POST', method: 'POST',
headers: this.getHeaders(), 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> { async updateConfig(formData: FormData): Promise<void> {
const session = localStorage.getItem('hip6_session'); const session = localStorage.getItem('hip6_session');
const headers: HeadersInit = {}; const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
if (session) { const res = await fetch(`${API_BASE}/admin/config`, { method: 'PUT', headers, body: formData });
const { token } = JSON.parse(session); if (!res.ok) throw new Error('Failed to update config');
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');
} }
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}`, { const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
method: 'PUT', method: 'PUT',
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) { if (!res.ok) throw new Error('Failed to update team');
const err = await res.json();
throw new Error(err.message || 'Failed to update team');
}
} }
async updateProfile(data: { password?: string }): Promise<void> { async updateProfile(data: any): Promise<void> {
const res = await fetch(`${API_BASE}/profile`, { const res = await fetch(`${API_BASE}/profile`, {
method: 'PUT', method: 'PUT',
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) { if (!res.ok) throw new Error('Failed to update profile');
const err = await res.json();
throw new Error(err.message || 'Failed to update profile');
}
} }
async deleteTeam(id: string): Promise<void> { async deleteTeam(id: string): Promise<void> {
@@ -114,30 +145,15 @@ class ApiService {
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify({ challengeId, flag }), body: JSON.stringify({ challengeId, flag }),
}); });
if (!res.ok) throw new Error('Submission failed');
return res.json(); return res.json();
} }
async upsertChallenge(formData: FormData, id?: string): Promise<any> { async upsertChallenge(formData: FormData, id?: string): Promise<any> {
const method = id ? 'PUT' : 'POST'; const method = id ? 'PUT' : 'POST';
const url = id ? `${API_BASE}/admin/challenges/${id}` : `${API_BASE}/admin/challenges`; const url = id ? `${API_BASE}/admin/challenges/${id}` : `${API_BASE}/admin/challenges`;
const session = localStorage.getItem('hip6_session'); const session = localStorage.getItem('hip6_session');
const headers: HeadersInit = {}; const headers: HeadersInit = session ? { 'Authorization': `Bearer ${JSON.parse(session).token}` } : {};
if (session) { const res = await fetch(url, { method, headers, body: formData });
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');
}
return res.json(); return res.json();
} }
@@ -149,22 +165,20 @@ class ApiService {
if (!res.ok) throw new Error('Failed to delete challenge'); 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`, { const res = await fetch(`${API_BASE}/admin/blogs`, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify(data), 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}`, { const res = await fetch(`${API_BASE}/admin/blogs/${id}`, {
method: 'PUT', method: 'PUT',
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) throw new Error('Failed to update blog post');
} }
async deleteBlogPost(id: string): Promise<void> { async deleteBlogPost(id: string): Promise<void> {
@@ -172,7 +186,6 @@ class ApiService {
method: 'DELETE', method: 'DELETE',
headers: this.getHeaders(), headers: this.getHeaders(),
}); });
if (!res.ok) throw new Error('Failed to delete blog post');
} }
} }

View File

@@ -1,33 +1,37 @@
/** /**
* Dynamic Scoring Algorithm * Dynamic Scoring Algorithm (Parabolic Decay)
* Logic: * Logic:
* 1. Base Score starts at initialPoints. * 1. Base Value = ((minimum - initial) / (decay^2)) * (solve_count^2) + initial
* 2. As solves increase, the score for EVERYONE who solved it decreases. * 2. If solve_count >= decay, return minimum.
* 3. We use a decay function: currentPoints = max(min_points, initial * decay_factor ^ (solves - 1)) * 3. Fixed Bonuses (based on initial points): 1st: 15%, 2nd: 10%, 3rd: 5%.
* 4. Plus a "First Blood" bonus: 1st solver gets 10% extra, 2nd 5%, 3rd 2%.
*/ */
const MIN_POINTS_PERCENTAGE = 0.2; // Points won't drop below 20% of initial export const calculateChallengeValue = (
const DECAY_CONSTANT = 0.92; // Aggressive decay per solve 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 => { // Parabolic formula: ((min - init) / decay^2) * solveCount^2 + init
if (solveCount === 0) return initialPoints; const value = ((minimum - initial) / (decay * decay)) * (solveCount * solveCount) + initial;
const minPoints = Math.floor(initialPoints * MIN_POINTS_PERCENTAGE);
const decayedPoints = Math.floor(initialPoints * Math.pow(DECAY_CONSTANT, solveCount - 1)); return Math.ceil(value);
return Math.max(minPoints, decayedPoints);
}; };
export const getFirstBloodBonus = (rank: number): number => { export const getFirstBloodBonusFactor = (rank: number): number => {
if (rank === 0) return 0.10; // 1st if (rank === 0) return 0.15; // 1st
if (rank === 1) return 0.05; // 2nd if (rank === 1) return 0.10; // 2nd
if (rank === 2) return 0.02; // 3rd if (rank === 2) return 0.05; // 3rd
return 0; return 0;
}; };
export const calculateTeamTotalScore = ( export const calculateTeamTotalScore = (
teamId: string, teamId: string,
challenges: { id: string, initialPoints: number, solves: string[] }[], challenges: any[],
solves: { teamId: string, challengeId: string, timestamp: number }[] solves: { teamId: string, challengeId: string, timestamp: number }[]
): number => { ): number => {
let total = 0; let total = 0;
@@ -39,17 +43,21 @@ export const calculateTeamTotalScore = (
const challenge = challenges.find(c => c.id === solve.challengeId); const challenge = challenges.find(c => c.id === solve.challengeId);
if (!challenge) return; if (!challenge) return;
// Current value of challenge (shared by all) // Current dynamic base value (retroactive for everyone)
const baseValue = calculateChallengeValue(challenge.initialPoints, challenge.solves.length); const baseValue = calculateChallengeValue(
challenge.initialPoints,
challenge.minimumPoints || 0,
challenge.decaySolves || 1,
challenge.solves.length
);
// Calculate rank for bonus // Calculate rank for fixed bonus based on initial value
// Find all solves for this challenge sorted by time
const challengeSolves = solves const challengeSolves = solves
.filter(s => s.challengeId === solve.challengeId) .filter(s => s.challengeId === solve.challengeId)
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
const rank = challengeSolves.findIndex(s => s.teamId === teamId); 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); total += (baseValue + bonus);
}); });

View File

@@ -13,11 +13,14 @@ export interface Challenge {
difficulty: Difficulty; difficulty: Difficulty;
description: string; description: string;
initialPoints: number; initialPoints: number;
minimumPoints: number; // Lowest possible points
decaySolves: number; // Solves to reach minimum
flag: string; flag: string;
files: ChallengeFile[]; files: ChallengeFile[];
solves: string[]; // Team IDs solves: string[]; // Team IDs
port?: number; port?: number;
connectionType?: 'nc' | 'http'; connectionType?: 'nc' | 'http';
overrideIp?: string;
} }
export interface Team { export interface Team {
@@ -32,7 +35,7 @@ export interface Solve {
teamId: string; teamId: string;
challengeId: string; challengeId: string;
timestamp: number; 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 { export interface BlogPost {