- 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
This commit is contained in:
59
Admin.tsx
59
Admin.tsx
@@ -1,13 +1,14 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
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 { useCTF } from './CTFContext';
|
||||||
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
|
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
|
||||||
import { Button, Countdown, CategoryIcon } from './UIComponents';
|
import { Button, Countdown, CategoryIcon } from './UIComponents';
|
||||||
import { CATEGORIES, DIFFICULTIES } from './constants';
|
import { CATEGORIES, DIFFICULTIES } from './constants';
|
||||||
|
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
||||||
|
|
||||||
export const Admin: React.FC = () => {
|
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<Partial<Challenge> | null>(null);
|
const [editingChallenge, setEditingChallenge] = useState<Partial<Challenge> | null>(null);
|
||||||
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
|
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
|
||||||
@@ -460,6 +461,60 @@ export const Admin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full py-4 uppercase">Update Identity</Button>
|
<Button type="submit" className="w-full py-4 uppercase">Update Identity</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="mt-8 border-t-2 border-[#333] pt-6">
|
||||||
|
<h4 className="text-xl font-black italic text-[#bf00ff] mb-4 uppercase">SOLVES</h4>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-2">
|
||||||
|
{state.solves.filter(s => s.teamId === editingTeam.id).length === 0 ? (
|
||||||
|
<p className="text-slate-500 italic text-sm font-black">NO_SOLVES_FOUND</p>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div key={solve.challengeId} className="flex justify-between items-center bg-white/5 border border-[#333] p-3 text-sm font-bold">
|
||||||
|
<div className="flex items-center gap-2 truncate flex-1">
|
||||||
|
{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]" />
|
||||||
|
)}
|
||||||
|
<span className={`truncate ${diffColor}`}>{challenge.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white text-xs mr-4">{totalPoints} PTS</span>
|
||||||
|
<button type="button" onClick={() => deleteSolve(editingTeam.id as string, solve.challengeId)} className="text-red-500 hover:text-red-400 transition-colors" title="Delete Solve"><Trash2 size={16}/></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface CTFContextType {
|
|||||||
updateTeam: (id: string, data: any) => Promise<void>;
|
updateTeam: (id: string, data: any) => Promise<void>;
|
||||||
updateProfile: (password?: string) => Promise<void>;
|
updateProfile: (password?: string) => Promise<void>;
|
||||||
deleteTeam: (id: string) => Promise<void>;
|
deleteTeam: (id: string) => Promise<void>;
|
||||||
|
deleteSolve: (teamId: string, challengeId: string) => Promise<void>;
|
||||||
createBlogPost: (data: { title: string, content: string }) => Promise<void>;
|
createBlogPost: (data: { title: string, content: string }) => Promise<void>;
|
||||||
updateBlogPost: (id: string, data: { title: string, content: string }) => Promise<void>;
|
updateBlogPost: (id: string, data: { title: string, content: string }) => Promise<void>;
|
||||||
deleteBlogPost: (id: string) => Promise<void>;
|
deleteBlogPost: (id: string) => Promise<void>;
|
||||||
@@ -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 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 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 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 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 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 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,
|
state, currentUser, login, register, logout, submitFlag, toggleCtf, resetScores,
|
||||||
upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges,
|
upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges,
|
||||||
importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile,
|
importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile,
|
||||||
deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,
|
deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,
|
||||||
refreshState, loading, loadError
|
refreshState, loading, loadError
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
37
changelog.txt
Normal file
37
changelog.txt
Normal file
@@ -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
|
||||||
1
data/secret.key
Normal file
1
data/secret.key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3ca2310b67c3e5a157860fa2a5a3d381f34925ed8ec7988b44ddd7d39506aaf8
|
||||||
10
server.js
10
server.js
@@ -429,11 +429,15 @@ apiRouter.post('/challenges/submit', (req, res) => {
|
|||||||
configRows.forEach(row => { config[row.key] = row.value; });
|
configRows.forEach(row => { config[row.key] = row.value; });
|
||||||
const now = Date.now(), start = parseInt(config.eventStartTime || 0), end = parseInt(config.eventEndTime || Date.now() + 86400000);
|
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' });
|
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' });
|
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) {
|
||||||
|
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 }));
|
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true }));
|
||||||
|
}
|
||||||
} else res.json({ success: false });
|
} else res.json({ success: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 })));
|
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) => {
|
apiRouter.post('/admin/blogs', (req, res) => {
|
||||||
const id = 'blog-' + Math.random().toString(36).substr(2, 9);
|
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 }));
|
db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)", [id, req.body.title, req.body.content, Date.now()], () => res.json({ success: true, id }));
|
||||||
|
|||||||
@@ -134,11 +134,17 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteTeam(id: string): Promise<void> {
|
async deleteTeam(id: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
|
await fetch(`${API_BASE}/admin/teams/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this.getHeaders(),
|
headers: { ...this.getHeaders() }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSolve(teamId: string, challengeId: string): Promise<void> {
|
||||||
|
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 }> {
|
async submitFlag(challengeId: string, flag: string): Promise<{ success: boolean }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user