From 1c756af2386dcbe5764f35e9898f4c34a22ba92d Mon Sep 17 00:00:00 2001 From: m0rph3us1987 Date: Wed, 7 Jan 2026 13:27:11 +0100 Subject: [PATCH] initial commit --- .dockerignore | 7 + .gitignore | 28 + App.tsx | 1249 ++++++++++++ Dockerfile | 44 + README.md | 20 + constants.ts | 51 + docker-compose.yml | 9 + index.html | 64 + index.tsx | 16 + metadata.json | 5 + package-lock.json | 4329 ++++++++++++++++++++++++++++++++++++++++++ package.json | 28 + server.js | 405 ++++ services/api.ts | 179 ++ services/database.ts | 3 + services/scoring.ts | 58 + tsconfig.json | 29 + types.ts | 53 + vite.config.ts | 26 + 19 files changed, 6603 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 constants.ts create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js create mode 100644 services/api.ts create mode 100644 services/database.ts create mode 100644 services/scoring.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f51e060 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +# Exclude these so they are not baked into the image +uploads +ctf.db +.git +.DS_Store \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6827dab --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local files +uploads/* +ctf.db diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..5adae71 --- /dev/null +++ b/App.tsx @@ -0,0 +1,1249 @@ + +import React, { useState, useEffect, createContext, useContext, useMemo, useCallback } from 'react'; +import { HashRouter, Routes, Route, Link, useNavigate, Navigate, useLocation } from 'react-router-dom'; +import { LogIn, UserPlus, Trophy, Flag, Shield, LogOut, Settings, Clock, Plus, Trash2, Edit3, Download, ExternalLink, Menu, X, CheckCircle2, ChevronRight, Layers, Database, RefreshCw, Terminal, Crosshair, Radar, Zap, Box, User, Users, ShieldAlert, ShieldCheck, Heart, Sparkles, Coffee, Table, Paperclip, Newspaper, Image as ImageIcon, Type, Layout, Wand2, Monitor, Globe } from 'lucide-react'; +import { Challenge, Team, Solve, CTFState, Difficulty, ChallengeFile, BlogPost } from './types'; +import { CATEGORIES, DIFFICULTIES } from './constants'; +import { calculateChallengeValue, calculateTeamTotalScore, getFirstBloodBonus } from './services/scoring'; +import { api } from './services/api'; + +// --- Context & State Management --- +interface CTFContextType { + state: CTFState; + currentUser: Team | null; + login: (name: string, pass: string) => Promise; + register: (name: string, pass: string) => Promise; + logout: () => void; + submitFlag: (challengeId: string, flag: string) => Promise; + toggleCtf: () => Promise; + upsertChallenge: (data: FormData, id?: string) => Promise; + deleteChallenge: (id: string) => Promise; + updateTeam: (id: string, name: string, isDisabled: boolean, isAdmin: boolean, password?: string) => Promise; + updateProfile: (password?: string) => Promise; + deleteTeam: (id: string) => Promise; + createBlogPost: (data: { title: string, content: string }) => Promise; + updateBlogPost: (id: string, data: { title: string, content: string }) => Promise; + deleteBlogPost: (id: string) => Promise; + updateConfig: (formData: FormData) => Promise; + refreshState: () => Promise; +} + +const CTFContext = createContext(null); +const useCTF = () => { + const context = useContext(CTFContext); + if (!context) throw new Error('useCTF must be used within provider'); + return context; +}; + +// --- Helper Components --- +const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { currentUser } = useCTF(); + const location = useLocation(); + + if (!currentUser) { + return ; + } + + return <>{children}; +}; + +const CategoryIcon: React.FC<{ category: string; size?: number; color?: string }> = ({ category, size = 32, color = "currentColor" }) => { + switch (category) { + case 'WEB': return ; + case 'PWN': return ; + case 'REV': return ; + case 'CRY': return ; + default: return ; + } +}; + +const getDifficultyColorClass = (difficulty: Difficulty) => { + switch (difficulty) { + case 'Low': return 'text-green-500'; + case 'Medium': return 'text-yellow-500'; + case 'High': return 'text-red-500'; + default: return 'text-slate-400'; + } +}; + +const Button: React.FC & { 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 ( + + ); +}; + +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) { + setMessage({ text: 'COMMUNICATION ERROR', type: 'error' }); + } + }; + + const connectionDetails = useMemo(() => { + const port = Number(challenge.port); + const ip = state.config.dockerIp; + const type = challenge.connectionType || 'nc'; + + if (port > 0) { + if (!ip) { + return currentUser?.isAdmin ? "ERROR: DOCKER_NODE_IP_NOT_CONFIGURED" : null; + } + return type === 'nc' ? `nc ${ip} ${port}` : `http://${ip}:${port}`; + } + return null; + }, [challenge.port, challenge.connectionType, state.config.dockerIp, currentUser?.isAdmin]); + + return ( +
+
+ +
+

{challenge.title}

+
+ {challenge.category} | {challenge.difficulty} | {calculateChallengeValue(challenge.initialPoints, challenge.solves.length)} PTS +
+
+
{challenge.description}
+ + {connectionDetails && ( +
+

Connect to:

+ + {connectionDetails} + + {connectionDetails.startsWith("ERROR") && ( +

ADMIN_NOTICE: Set Docker Node IP in General Config

+ )} +
+ )} + + {challenge.files && challenge.files.length > 0 && ( +
+

Available Files

+
+ {challenge.files.map((file, idx) => ( + + + {file.name} + + ))} +
+
+ )} + + {currentUser ? ( + !challenge.solves.includes(currentUser.id) ? ( +
+ setFlagInput(e.target.value)} autoFocus /> +
+ + {message &&
{message.text}
} +
+
+ ) : ( +
CHALLENGE SOLVED ✨
+ ) + ) : ( +

PLEASE SIGN IN TO CONTRIBUTE

+ )} +
+

SOLVER_LOG

+
+ {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 bonus = getFirstBloodBonus(idx); + return ( +
+
+ {idx + 1} + {team?.name} + {bonus > 0 && PWNY POWER 🦄 +{Math.round(bonus*100)}%} +
+ {new Date(solve.timestamp).toLocaleTimeString()} +
+ ); + }) + ) : ( +
No solutions discovered yet.
+ )} +
+
+
+
+ ); +}; + +const ProfileSettingsModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { + const { updateProfile } = useCTF(); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirm) return setError('AUTH_MISMATCH: PASSWORDS_DO_NOT_MATCH'); + try { + await updateProfile(password); + setSuccess(true); + setTimeout(onClose, 2000); + } catch (err: any) { + setError(err.message || 'UPDATE_FAILED'); + } + }; + + return ( +
+
+ +

IDENTITY_SETTINGS

+ {success ? ( +
+ CREDENTIALS_UPDATED_SUCCESSFULLY +
+ ) : ( +
+
+ + setPassword(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+ {error &&

{error}

} + +
+ )} +
+
+ ); +}; + +// --- Pages --- + +const Blog: React.FC = () => { + const { state } = useCTF(); + return ( +
+
+

+ BLOG_FEED +

+

Protocol: Global Broadcasts & Updates

+
+ +
+ {state.blogs.length > 0 ? ( + state.blogs.map((post) => ( +
+
+ {new Date(post.timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} +
+

+ {post.title} +

+
+ {post.content} +
+
+ )) + ) : ( +
+

Awaiting broadcasts from headquarters...

+
+ )} +
+
+ ); +}; + +const Home: React.FC = () => { + const { currentUser, state } = useCTF(); + return ( +
+
+ {/* Logo or fallback generic icon - Replaced text with logo as requested */} +
+ {state.config.logoUrl ? ( + Logo + ) : ( + + )} +
+ +

+ {state.config.conferenceName} +

+
+ +
+
+

{state.config.landingText}

+
+ +
+ {!currentUser ? ( + <> + + + + ) : ( +
+ + +
+ )} +
+
+ ); +}; + +const ChallengeList: React.FC = () => { + const { state, currentUser } = useCTF(); + const [selectedChallenge, setSelectedChallenge] = useState(null); + + const difficultyWeight: Record = { 'Low': 1, 'Medium': 2, 'High': 3 }; + + // Admins can see challenges even if competition is not started/paused + if (!state.isStarted && !currentUser?.isAdmin) { + return ( +
+
+ +

BOARD_PAUSED

+

Waiting for the opening ceremony...

+
+
+ ); + } + + return ( +
+ {!state.isStarted && currentUser?.isAdmin && ( +
+ Competition is currently paused. Preview mode active for Admins. +
+ )} + +
+ {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 ( +
+
+
+ +
+

{category}

+
+ +
+ {categoryChallenges.map(c => { + const isSolved = currentUser && (c.solves || []).includes(currentUser.id); + const currentPoints = calculateChallengeValue(c.initialPoints, (c.solves || []).length); + return ( +
setSelectedChallenge(c)} + > +
+ {c.difficulty} +
+

{c.title}

+
+
+ {isSolved && } + + {currentPoints} + +
+
+ + {(c.solves || []).length} +
+
+
+ ); + })} +
+
+ ); + })} +
+ + {selectedChallenge && ( + setSelectedChallenge(null)} /> + )} +
+ ); +}; + +const Scoreboard: React.FC = () => { + const { state } = useCTF(); + + const rankings = useMemo(() => { + return (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 || a.name.localeCompare(b.name)); + }, [state]); + + return ( +
+
+

LEADERBOARD

+
+

Protocol: Global Standings

+ View_Matrix + + + +
+
+ + + + + + + + + + {rankings.map((team, idx) => ( + + + + + + + ))} + +
RANKTEAM_IDENTIFIERSOLVESTOTAL_POINTS
+
+ {idx + 1} +
+
+
+ {team.name} + {idx === 0 && } +
+
{team.solveCount}{team.score}
+ {rankings.length === 0 && ( +
No team activity detected yet.
+ )} +
+
+ ); +}; + +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]); + + return ( +
+
+

SCORE_MATRIX

+

Protocol: Operational Overview

+
+ +
+ + + + + {state.challenges.map(c => ( + + ))} + + + + {sortedTeams.map(team => ( + + + {state.challenges.map(c => { + const isSolved = c.solves.includes(team.id); + return ( + + ); + })} + + ))} + +
Team / Challenge +
+ + {c.title} + +
+
+ {team.name} + +
+ {isSolved && } +
+
+
+
+ ); +}; + +const Login: React.FC = () => { + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const { login } = useCTF(); + const navigate = useNavigate(); + const location = useLocation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const success = await login(name, password); + if (success) { + const from = (location.state as any)?.from?.pathname || '/'; + navigate(from, { replace: true }); + } else { + setError('ACCESS_DENIED: INVALID_CREDENTIALS'); + } + }; + + return ( +
+
+
+
AUTH_GATE_01
+

SIGN_IN

+
+
+ + setName(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ {error &&

{error}

} + +
+

+ New operator? Register_Identity +

+
+
+
+ ); +}; + +const Register: React.FC = () => { + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [userCaptcha, setUserCaptcha] = useState(''); + const [captchaValue, setCaptchaValue] = useState(''); + const [error, setError] = useState(''); + const { register } = useCTF(); + const navigate = useNavigate(); + + const generateCaptcha = useCallback(() => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + setCaptchaValue(result); + }, []); + + useEffect(() => { + generateCaptcha(); + }, [generateCaptcha]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirm) return setError('AUTH_MISMATCH: PASSWORDS_DO_NOT_MATCH'); + if (userCaptcha.toUpperCase() !== captchaValue) { + setError('AUTH_FAILURE: INVALID_CAPTCHA'); + generateCaptcha(); + setUserCaptcha(''); + return; + } + try { + await register(name, password); + navigate('/challenges'); + } catch (err: any) { + setError(err.message || 'REGISTRATION_FAILED'); + generateCaptcha(); + } + }; + + return ( +
+
+
+
IDENTITY_GEN_01
+

REGISTER

+
+
+ + setName(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+ +
+ +
+
+ + {captchaValue} + + +
+ setUserCaptcha(e.target.value)} required /> +
+
+ + {error &&

{error}

} + +
+
+
+
+ ); +}; + +const Admin: React.FC = () => { + const { state, toggleCtf, upsertChallenge, deleteChallenge, updateTeam, deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, refreshState } = useCTF(); + const [editingChallenge, setEditingChallenge] = useState | null>(null); + const [newFiles, setNewFiles] = useState([]); + const [currentFiles, setCurrentFiles] = useState([]); + const [editingTeam, setEditingTeam] = useState & { newPassword?: string } | null>(null); + const [editingBlogPost, setEditingBlogPost] = useState | null>(null); + + // General Config State + const [confName, setConfName] = useState(state.config.conferenceName || ''); + const [confLanding, setConfLanding] = useState(state.config.landingText || ''); + const [confBgType, setConfBgType] = useState(state.config.bgType || 'color'); + const [confBgColor, setConfBgColor] = useState(state.config.bgColor || '#000000'); + const [confBgOpacity, setConfBgOpacity] = useState(state.config.bgOpacity || '0.5'); + const [confBgBrightness, setConfBgBrightness] = useState(state.config.bgBrightness || '1.0'); + const [confBgContrast, setConfBgContrast] = useState(state.config.bgContrast || '1.0'); + const [confDockerIp, setConfDockerIp] = useState(state.config.dockerIp || ''); + const [newLogo, setNewLogo] = useState(null); + const [newBgImage, setNewBgImage] = useState(null); + + useEffect(() => { + setConfName(state.config.conferenceName || ''); + setConfLanding(state.config.landingText || ''); + setConfBgType(state.config.bgType || 'color'); + setConfBgColor(state.config.bgColor || '#000000'); + setConfBgOpacity(state.config.bgOpacity || '0.5'); + setConfBgBrightness(state.config.bgBrightness || '1.0'); + setConfBgContrast(state.config.bgContrast || '1.0'); + setConfDockerIp(state.config.dockerIp || ''); + }, [state.config]); + + const handleConfigSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(); + fd.append('conferenceName', confName); + fd.append('landingText', confLanding); + fd.append('bgType', confBgType); + fd.append('bgColor', confBgColor); + fd.append('bgOpacity', confBgOpacity); + fd.append('bgBrightness', confBgBrightness); + fd.append('bgContrast', confBgContrast); + fd.append('dockerIp', confDockerIp); + if (newLogo) fd.append('logo', newLogo); + if (newBgImage) fd.append('bgImage', newBgImage); + + try { + await updateConfig(fd); + alert('CONFIGURATION_UPDATED'); + } catch (err) { + alert('CONFIG_UPDATE_FAILED'); + } + }; + + const handleChallengeSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingChallenge) return; + try { + const formData = new FormData(); + Object.entries(editingChallenge).forEach(([key, value]) => { + if (key !== 'files' && key !== 'solves' && value !== undefined && value !== null) { + formData.append(key, value.toString()); + } + }); + formData.append('existingFiles', JSON.stringify(currentFiles)); + newFiles.forEach(file => { formData.append('files', file); }); + await upsertChallenge(formData, editingChallenge.id); + setEditingChallenge(null); + setNewFiles([]); + } catch (err: any) { + alert("Failed to update challenge."); + } + }; + + const handleTeamUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingTeam || !editingTeam.id) return; + await updateTeam(editingTeam.id, editingTeam.name || '', !!editingTeam.isDisabled, !!editingTeam.isAdmin, editingTeam.newPassword); + setEditingTeam(null); + }; + + const handleBlogSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingBlogPost) return; + try { + if (editingBlogPost.id) { + await updateBlogPost(editingBlogPost.id, { title: editingBlogPost.title || '', content: editingBlogPost.content || '' }); + } else { + await createBlogPost({ title: editingBlogPost.title || '', content: editingBlogPost.content || '' }); + } + setEditingBlogPost(null); + } catch (err) { + alert('BLOG_SAVE_FAILED'); + } + }; + + const removeExistingFile = (idx: number) => { + setCurrentFiles(prev => prev.filter((_, i) => i !== idx)); + }; + + return ( +
+
+

ADMIN_OVERRIDE

+ +
+ +
+
+ {/* General Settings */} +
+

+ GENERAL_CONFIG +

+
+
+
+ + setConfName(e.target.value)} /> +
+
+ + setConfDockerIp(e.target.value)} /> +

Stored in DB: {state.config.dockerIp || 'MISSING'}

+
+
+
+ +