diff --git a/Admin.tsx b/Admin.tsx index 506ad98..4290183 100644 --- a/Admin.tsx +++ b/Admin.tsx @@ -1,13 +1,14 @@ 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 { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck, Medal, CheckCircle2 } 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'; +import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring'; 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 { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF(); const [editingChallenge, setEditingChallenge] = useState | null>(null); const [editingTeam, setEditingTeam] = useState & { newPassword?: string } | null>(null); @@ -460,6 +461,60 @@ export const Admin: React.FC = () => { +
+

SOLVES

+
+ {state.solves.filter(s => s.teamId === editingTeam.id).length === 0 ? ( +

NO_SOLVES_FOUND

+ ) : ( + state.solves + .filter(s => s.teamId === editingTeam.id) + .map(solve => ({ + solve, + challenge: state.challenges.find(c => c.id === solve.challengeId) + })) + .sort((a, b) => (a.challenge?.title || '').localeCompare(b.challenge?.title || '')) + .map(({ solve, challenge }) => { + if (!challenge) return null; + + const diffColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]'; + + const challengeSolves = state.solves + .filter(s => s.challengeId === challenge.id) + .sort((a, b) => a.timestamp - b.timestamp); + const rank = challengeSolves.findIndex(s => s.teamId === editingTeam.id); + + const baseValue = calculateChallengeValue( + challenge.initialPoints, + challenge.minimumPoints || 0, + challenge.decaySolves || 1, + (challenge.solves || []).length + ); + const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank)); + const totalPoints = baseValue + bonus; + + return ( +
+
+ {rank === 0 ? ( + + ) : rank === 1 ? ( + + ) : rank === 2 ? ( + + ) : ( + + )} + {challenge.title} +
+ {totalPoints} PTS + +
+ ); + }) + )} +
+
)} diff --git a/CTFContext.tsx b/CTFContext.tsx index 666f31d..ea1cc5d 100644 --- a/CTFContext.tsx +++ b/CTFContext.tsx @@ -21,6 +21,7 @@ interface CTFContextType { updateTeam: (id: string, data: any) => Promise; updateProfile: (password?: string) => Promise; deleteTeam: (id: string) => Promise; + deleteSolve: (teamId: string, challengeId: string) => Promise; createBlogPost: (data: { title: string, content: string }) => Promise; updateBlogPost: (id: string, data: { title: string, content: string }) => Promise; deleteBlogPost: (id: string) => Promise; @@ -98,6 +99,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children 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 deleteSolve = async (teamId: string, challengeId: string) => { if (window.confirm("DELETE_SOLVE?")) { await api.deleteSolve(teamId, challengeId); 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(); }; @@ -108,7 +110,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children state, currentUser, login, register, logout, submitFlag, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges, importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile, - deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, + deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, refreshState, loading, loadError }}> {children} diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..16a0ad4 --- /dev/null +++ b/changelog.txt @@ -0,0 +1,37 @@ +2026-03-07 +- Prevented admin challenge solves from creating score records +- Added operator solves list to the Admin panel profile +- Allowed deletion of specific operator solves from the Admin panel +- Enhanced operator solves list with alphabetical sorting, difficulty colors, and point values +- Added rank medal icons to operator solves in the Admin panel + +2026-02-28 +- Removed the UTC time display from the countdown, leaving only the CET time +- Added logic to display the event start time in Central European Time (CET) on the Challenges list page +- Replaced mock authentication tokens with secure JWT-like signed tokens +- Added robust error handling and type checking for password hashing and validation functions +- Implemented logic to disable the default admin account (admin-0) once another admin is created +- Applied a database schema whitelist to prevent SQL injection during database restores +- Filtered out admin and disabled teams from the public scoreboard state for non-admin users +- Added strict /admin middleware to protect administrative API endpoints by verifying user permissions +- Updated page title to HIP7CTF in index.html +- Enhanced the state endpoint to completely hide challenges if the current time is before the configured event start time + +2026-02-22 +- Modified the /state endpoint to return an empty challenges list if the event has not started and the user is not an admin + +2026-02-05 +- Included authorization headers in the frontend getState API requests +- Added security check in the /state endpoint to filter out the flag from challenge data for non-admin users +- Added a dbGet utility function to the server + +2026-01-21 +- Removed the README.md file +- Added a fix-permissions service to docker-compose.yml to automatically set correct ownership for data and uploads directories +- Modularized the frontend by splitting the monolithic App.tsx into dedicated components (Admin.tsx, Auth.tsx, Blog.tsx, CTFContext.tsx, Challenges.tsx, Home.tsx, Scoreboard.tsx, UIComponents.tsx) +- Restructured API, server, and scoring logic to fit the new modular frontend architecture +- Re-added README.md file temporarily + +2026-01-07 +- Removed the README.md file +- Initial project setup including React frontend, Express backend, Docker configuration, and baseline scoring services \ No newline at end of file diff --git a/data/secret.key b/data/secret.key new file mode 100644 index 0000000..433c66d --- /dev/null +++ b/data/secret.key @@ -0,0 +1 @@ +3ca2310b67c3e5a157860fa2a5a3d381f34925ed8ec7988b44ddd7d39506aaf8 \ No newline at end of file diff --git a/server.js b/server.js index dc5a4a7..78c9507 100644 --- a/server.js +++ b/server.js @@ -429,11 +429,15 @@ apiRouter.post('/challenges/submit', (req, res) => { 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, isAdmin FROM teams WHERE id = ?", [teamId], (err, team) => { if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' }); db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => { if (challenge && challenge.flag === flag) { - db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true })); + if (team?.isAdmin) { + res.json({ success: true }); + } else { + db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true })); + } } else res.json({ success: false }); }); }); @@ -478,6 +482,10 @@ apiRouter.delete('/admin/teams/:id', (req, res) => { db.run("DELETE FROM teams WHERE id = ?", [req.params.id], () => db.run("DELETE FROM solves WHERE teamId = ?", [req.params.id], () => res.json({ success: true }))); }); +apiRouter.delete('/admin/solves/:teamId/:challengeId', (req, res) => { + db.run("DELETE FROM solves WHERE teamId = ? AND challengeId = ?", [req.params.teamId, req.params.challengeId], () => res.json({ success: true })); +}); + apiRouter.post('/admin/blogs', (req, res) => { const id = 'blog-' + Math.random().toString(36).substr(2, 9); db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)", [id, req.body.title, req.body.content, Date.now()], () => res.json({ success: true, id })); diff --git a/services/api.ts b/services/api.ts index 6fdaa15..6bf17c3 100644 --- a/services/api.ts +++ b/services/api.ts @@ -134,11 +134,17 @@ class ApiService { } async deleteTeam(id: string): Promise { - const res = await fetch(`${API_BASE}/admin/teams/${id}`, { + await fetch(`${API_BASE}/admin/teams/${id}`, { method: 'DELETE', - headers: this.getHeaders(), + headers: { ...this.getHeaders() } + }); + } + + async deleteSolve(teamId: string, challengeId: string): Promise { + await fetch(`${API_BASE}/admin/solves/${teamId}/${challengeId}`, { + method: 'DELETE', + headers: { ...this.getHeaders() } }); - if (!res.ok) throw new Error('Failed to delete team'); } async submitFlag(challengeId: string, flag: string): Promise<{ success: boolean }> {