initial commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
# Exclude these so they are not baked into the image
|
||||
uploads
|
||||
ctf.db
|
||||
.git
|
||||
.DS_Store
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -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
|
||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# --- Stage 1: Build Frontend ---
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code (files in .dockerignore are excluded)
|
||||
COPY . .
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Production Server ---
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies for sqlite3
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files and install production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy backend server and config
|
||||
COPY server.js .
|
||||
COPY metadata.json .
|
||||
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 1. Create the uploads folder explicitly
|
||||
# 2. Set ownership of the app directory to the 'node' user
|
||||
RUN mkdir -p uploads && chown -R node:node /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1hEzZiQvXvEesEyPd55AgRScM8zJxJvsT
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
51
constants.ts
Normal file
51
constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
import { Challenge, Team } from './types';
|
||||
|
||||
export const CATEGORIES = ['WEB', 'PWN', 'REV', 'CRY', 'MSC'];
|
||||
export const DIFFICULTIES: ('Low' | 'Medium' | 'High')[] = ['Low', 'Medium', 'High'];
|
||||
|
||||
export const INITIAL_CHALLENGES: Challenge[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'algorave',
|
||||
category: 'REV',
|
||||
difficulty: 'Low',
|
||||
description: 'The sound of the future is encrypted. Can you find the melody?',
|
||||
initialPoints: 100,
|
||||
flag: 'CTF{view_source_is_key}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'shell(de)coding',
|
||||
category: 'MSC',
|
||||
difficulty: 'Medium',
|
||||
description: 'Wait, this shellcode looks like a poem...',
|
||||
initialPoints: 300,
|
||||
flag: 'CTF{xor_is_not_encryption}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'worrier',
|
||||
category: 'CRY',
|
||||
difficulty: 'High',
|
||||
description: 'Anxious math leads to anxious flags.',
|
||||
initialPoints: 500,
|
||||
flag: 'CTF{worrier_not_warrior}',
|
||||
// Fix: Added missing 'files' property
|
||||
files: [],
|
||||
solves: []
|
||||
}
|
||||
];
|
||||
|
||||
export const ADMIN_TEAM: Team = {
|
||||
id: 'admin-0',
|
||||
name: 'admin',
|
||||
password: 'admin',
|
||||
isAdmin: true
|
||||
};
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
ctf-app:
|
||||
build: .
|
||||
container_name: hipctf
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
64
index.html
Normal file
64
index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>hipCTF26</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: #000000;
|
||||
color: #ff0000;
|
||||
}
|
||||
.hxp-border {
|
||||
border: 3px solid #ff0000;
|
||||
}
|
||||
.hxp-border-purple {
|
||||
border: 2px solid #bf00ff;
|
||||
}
|
||||
.hxp-btn {
|
||||
background: #ff0000;
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.hxp-btn:hover {
|
||||
background: #000;
|
||||
color: #ff0000;
|
||||
box-shadow: 0 0 15px #ff0000;
|
||||
}
|
||||
.hxp-card {
|
||||
background: #000;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
.hxp-card:hover {
|
||||
border-color: #bf00ff;
|
||||
}
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: #000; }
|
||||
::-webkit-scrollbar-thumb { background: #ff0000; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@^7.11.0",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
|
||||
"vite": "https://esm.sh/vite@^7.3.0",
|
||||
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
index.tsx
Normal file
16
index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "HIPCTF Platform",
|
||||
"description": "A comprehensive Capture The Flag platform build for the Hack Im Pott",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
4329
package-lock.json
generated
Normal file
4329
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
{
|
||||
"name": "hipctf",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"sqlite3": "^5.1.6",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"lucide-react": "^0.474.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
||||
405
server.js
Normal file
405
server.js
Normal file
@@ -0,0 +1,405 @@
|
||||
|
||||
const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Initialize Database
|
||||
const db = new sqlite3.Database('./ctf.db', (err) => {
|
||||
if (err) console.error('Database connection error:', err.message);
|
||||
else console.log('Connected to the ctf.db SQLite database.');
|
||||
});
|
||||
|
||||
// Setup Storage
|
||||
const uploadDir = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
|
||||
// Database Schema & Migrations
|
||||
db.serialize(() => {
|
||||
// Ensure tables exist
|
||||
db.run(`CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE COLLATE NOCASE,
|
||||
password TEXT,
|
||||
isAdmin INTEGER DEFAULT 0,
|
||||
isDisabled INTEGER DEFAULT 0
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
difficulty TEXT,
|
||||
description TEXT,
|
||||
initialPoints INTEGER,
|
||||
flag TEXT,
|
||||
files TEXT DEFAULT '[]',
|
||||
port INTEGER,
|
||||
connectionType TEXT
|
||||
)`);
|
||||
|
||||
// Force migration check for existing columns
|
||||
db.all("PRAGMA table_info(challenges)", (err, rows) => {
|
||||
if (err) return;
|
||||
const columns = rows.map(r => r.name);
|
||||
if (!columns.includes('port')) {
|
||||
db.run("ALTER TABLE challenges ADD COLUMN port INTEGER DEFAULT 0");
|
||||
}
|
||||
if (!columns.includes('connectionType')) {
|
||||
db.run("ALTER TABLE challenges ADD COLUMN connectionType TEXT DEFAULT 'nc'");
|
||||
}
|
||||
});
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS solves (
|
||||
teamId TEXT,
|
||||
challengeId TEXT,
|
||||
timestamp INTEGER,
|
||||
PRIMARY KEY (teamId, challengeId)
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS blogs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
timestamp INTEGER
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)`);
|
||||
|
||||
// Default Configs - Updated with Docker IP
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('isStarted', 'false')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('conferenceName', 'HIP')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('logoUrl', '')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgType', 'color')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgColor', '#000000')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgImageUrl', '')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgOpacity', '0.5')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgBrightness', '1.0')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('bgContrast', '1.0')`);
|
||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES ('dockerIp', '127.0.0.1')`);
|
||||
|
||||
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', 'admin', 1, 0)`);
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/files', express.static(uploadDir));
|
||||
|
||||
// API Router
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// State endpoint
|
||||
apiRouter.get('/state', (req, res) => {
|
||||
const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {} };
|
||||
|
||||
db.all("SELECT key, value FROM config", (err, configRows) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch config' });
|
||||
|
||||
configRows.forEach(row => {
|
||||
state.config[row.key] = row.value;
|
||||
});
|
||||
state.isStarted = state.config.isStarted === 'true';
|
||||
|
||||
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
|
||||
state.teams = teams || [];
|
||||
|
||||
db.all("SELECT * FROM challenges", (err, challenges) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
|
||||
|
||||
db.all("SELECT * FROM solves", (err, solves) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch solves' });
|
||||
|
||||
db.all("SELECT * FROM blogs ORDER BY timestamp DESC", (err, blogs) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch blogs' });
|
||||
|
||||
state.solves = solves || [];
|
||||
state.blogs = blogs || [];
|
||||
state.challenges = (challenges || []).map(c => ({
|
||||
...c,
|
||||
files: JSON.parse(c.files || '[]'),
|
||||
solves: state.solves.filter(s => s.challengeId === c.id).map(s => s.teamId)
|
||||
}));
|
||||
res.json(state);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Auth
|
||||
apiRouter.post('/auth/register', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
if (!name || !password) return res.status(400).json({ message: 'Name and password required' });
|
||||
|
||||
const id = 'team-' + Math.random().toString(36).substr(2, 9);
|
||||
db.run("INSERT INTO teams (id, name, password, isAdmin, isDisabled) VALUES (?, ?, ?, 0, 0)", [id, name, password], function(err) {
|
||||
if (err) {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(400).json({ message: 'This team name is already taken.' });
|
||||
}
|
||||
return res.status(500).json({ message: 'Internal server error during registration.' });
|
||||
}
|
||||
res.json({ team: { id, name, isAdmin: false, isDisabled: false }, token: 'mock-token-' + id });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.post('/auth/login', (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
db.get("SELECT * FROM teams WHERE name = ? AND password = ?", [name, password], (err, row) => {
|
||||
if (err || !row) return res.status(401).json({ message: 'Invalid credentials' });
|
||||
if (row.isDisabled) return res.status(403).json({ message: 'Account disabled' });
|
||||
res.json({ team: { id: row.id, name: row.name, isAdmin: !!row.isAdmin, isDisabled: !!row.isDisabled }, token: 'mock-token-' + row.id });
|
||||
});
|
||||
});
|
||||
|
||||
// Update Config
|
||||
apiRouter.put('/admin/config', upload.fields([{ name: 'logo' }, { name: 'bgImage' }]), (req, res) => {
|
||||
const updates = { ...req.body };
|
||||
|
||||
if (req.files) {
|
||||
if (req.files.logo) updates.logoUrl = `/files/${req.files.logo[0].filename}`;
|
||||
if (req.files.bgImage) updates.bgImageUrl = `/files/${req.files.bgImage[0].filename}`;
|
||||
}
|
||||
|
||||
db.serialize(() => {
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)");
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
stmt.run(key, value);
|
||||
});
|
||||
stmt.finalize(() => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Challenges Submit
|
||||
apiRouter.post('/challenges/submit', (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
||||
const { challengeId, flag } = req.body;
|
||||
|
||||
if (!teamId) return res.status(401).json({ success: false });
|
||||
|
||||
db.get("SELECT isDisabled FROM teams WHERE id = ?", [teamId], (err, team) => {
|
||||
if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' });
|
||||
|
||||
db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => {
|
||||
if (challenge && challenge.flag === flag) {
|
||||
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)",
|
||||
[teamId, challengeId, Date.now()], (err) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
} else {
|
||||
res.json({ success: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
apiRouter.post('/admin/toggle-ctf', (req, res) => {
|
||||
db.get("SELECT value FROM config WHERE key = 'isStarted'", (err, row) => {
|
||||
const newValue = row?.value === 'true' ? 'false' : 'true';
|
||||
db.run("UPDATE config SET value = ? WHERE key = 'isStarted'", [newValue], () => {
|
||||
res.json({ success: true, isStarted: newValue === 'true' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.put('/profile', (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
||||
const { password } = req.body;
|
||||
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
||||
if (!password) return res.status(400).json({ message: 'Password required' });
|
||||
|
||||
db.run("UPDATE teams SET password = ? WHERE id = ?", [password, teamId], function(err) {
|
||||
if (err) return res.status(500).json({ message: 'Update failed' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/teams/:id', (req, res) => {
|
||||
const { name, isDisabled, password, isAdmin } = req.body;
|
||||
const id = req.params.id;
|
||||
|
||||
// Protect root admin from demotion or disabling
|
||||
const finalIsDisabled = id === 'admin-0' ? 0 : (isDisabled ? 1 : 0);
|
||||
const finalIsAdmin = id === 'admin-0' ? 1 : (isAdmin ? 1 : 0);
|
||||
|
||||
let query = "UPDATE teams SET name = ?, isDisabled = ?, isAdmin = ?";
|
||||
let params = [name, finalIsDisabled, finalIsAdmin];
|
||||
if (password) {
|
||||
query += ", password = ?";
|
||||
params.push(password);
|
||||
}
|
||||
query += " WHERE id = ?";
|
||||
params.push(id);
|
||||
db.run(query, params, function(err) {
|
||||
if (err) return res.status(400).json({ message: 'Update failed.' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/teams/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (id === 'admin-0') return res.status(403).json({ message: 'Cannot delete root admin' });
|
||||
db.run("DELETE FROM teams WHERE id = ?", [id], () => {
|
||||
db.run("DELETE FROM solves WHERE teamId = ?", [id], () => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Blog Management
|
||||
apiRouter.post('/admin/blogs', (req, res) => {
|
||||
const { title, content } = req.body;
|
||||
if (!title || !content) return res.status(400).json({ message: 'Title and content required' });
|
||||
const id = 'blog-' + Math.random().toString(36).substr(2, 9);
|
||||
const timestamp = Date.now();
|
||||
|
||||
db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)",
|
||||
[id, title, content, timestamp], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true, id });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/blogs/:id', (req, res) => {
|
||||
const { title, content } = req.body;
|
||||
const { id } = req.params;
|
||||
if (!title || !content) return res.status(400).json({ message: 'Title and content required' });
|
||||
|
||||
db.run("UPDATE blogs SET title = ?, content = ? WHERE id = ?",
|
||||
[title, content, id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/blogs/:id', (req, res) => {
|
||||
db.run("DELETE FROM blogs WHERE id = ?", [req.params.id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Challenges Management
|
||||
apiRouter.post('/admin/challenges', upload.array('files'), (req, res) => {
|
||||
const { title, category, difficulty, description, initialPoints, flag, port, connectionType } = req.body;
|
||||
if (!title || !flag) return res.status(400).json({ message: 'Title and flag are required.' });
|
||||
|
||||
const id = 'chal-' + Math.random().toString(36).substr(2, 9);
|
||||
const files = (req.files || []).map(f => ({
|
||||
name: f.originalname,
|
||||
url: `/files/${f.filename}`
|
||||
}));
|
||||
|
||||
db.run(`INSERT INTO challenges (id, title, category, difficulty, description, initialPoints, flag, files, port, connectionType)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, title, category, difficulty, description, parseInt(initialPoints) || 0, flag, JSON.stringify(files), parseInt(port) || 0, connectionType || 'nc'],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error("Error creating challenge:", err.message);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
res.json({ id, success: true });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/challenges/:id', upload.array('files'), (req, res) => {
|
||||
const { title, category, difficulty, description, initialPoints, flag, existingFiles, port, connectionType } = req.body;
|
||||
const id = req.params.id;
|
||||
if (!id) return res.status(400).json({ message: 'Challenge ID is required for update.' });
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = JSON.parse(existingFiles || '[]');
|
||||
} catch (e) {
|
||||
console.error("Error parsing existingFiles:", e);
|
||||
files = [];
|
||||
}
|
||||
|
||||
if (req.files) {
|
||||
req.files.forEach(f => {
|
||||
files.push({
|
||||
name: f.originalname,
|
||||
url: `/files/${f.filename}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const query = `UPDATE challenges SET title=?, category=?, difficulty=?, description=?, initialPoints=?, flag=?, files=?, port=?, connectionType=? WHERE id=?`;
|
||||
const params = [
|
||||
title || "",
|
||||
category || "WEB",
|
||||
difficulty || "Low",
|
||||
description || "",
|
||||
parseInt(initialPoints) || 0,
|
||||
flag || "",
|
||||
JSON.stringify(files),
|
||||
port ? parseInt(port) : 0,
|
||||
connectionType || 'nc',
|
||||
id
|
||||
];
|
||||
|
||||
db.run(query, params, function(err) {
|
||||
if (err) {
|
||||
console.error("Error updating challenge:", err.message);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
console.warn(`No challenge found with ID: ${id}`);
|
||||
return res.status(404).json({ message: "Challenge not found." });
|
||||
}
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.delete('/admin/challenges/:id', (req, res) => {
|
||||
db.run("DELETE FROM challenges WHERE id = ?", [req.params.id], (err) => {
|
||||
if (err) return res.status(500).json({ message: err.message });
|
||||
db.run("DELETE FROM solves WHERE challengeId = ?", [req.params.id], () => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
const distPath = path.join(__dirname, 'dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/files')) {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
} else {
|
||||
res.status(404).json({ error: 'API resource not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`HIP6 CTF Backend Server running at http://127.0.0.1:${port}`);
|
||||
});
|
||||
179
services/api.ts
Normal file
179
services/api.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
|
||||
import { Challenge, Team, Solve, CTFState, BlogPost } from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
class ApiService {
|
||||
private getHeaders() {
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async getState(): Promise<CTFState> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/state`);
|
||||
if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
console.error('API Error (getState):', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async login(name: string, pass: string): Promise<{ team: Team, token: string }> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, password: pass }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Invalid credentials');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async register(name: string, pass: string): Promise<{ team: Team, token: string }> {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, password: pass }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Registration failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async toggleCtf(): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/toggle-ctf`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Unauthorized or failed to toggle CTF');
|
||||
}
|
||||
|
||||
async updateConfig(formData: FormData): Promise<void> {
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {};
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/admin/config`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update configuration');
|
||||
}
|
||||
|
||||
async updateTeam(id: string, data: { name: string, isDisabled: boolean, isAdmin: boolean, password?: string }): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Failed to update team');
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(data: { password?: string }): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || 'Failed to update profile');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTeam(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete team');
|
||||
}
|
||||
|
||||
async submitFlag(challengeId: string, flag: string): Promise<{ success: boolean }> {
|
||||
const res = await fetch(`${API_BASE}/challenges/submit`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ challengeId, flag }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Submission failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async upsertChallenge(formData: FormData, id?: string): Promise<any> {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `${API_BASE}/admin/challenges/${id}` : `${API_BASE}/admin/challenges`;
|
||||
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
const headers: HeadersInit = {};
|
||||
if (session) {
|
||||
const { token } = JSON.parse(session);
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Save failed' }));
|
||||
throw new Error(err.message || 'Failed to save challenge');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/challenges/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete challenge');
|
||||
}
|
||||
|
||||
async createBlogPost(data: { title: string, content: string }): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/blogs`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create blog post');
|
||||
}
|
||||
|
||||
async updateBlogPost(id: string, data: { title: string, content: string }): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/blogs/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update blog post');
|
||||
}
|
||||
|
||||
async deleteBlogPost(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/admin/blogs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete blog post');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
3
services/database.ts
Normal file
3
services/database.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
// Obsolete: Replaced by server-side SQLite3 in server.js
|
||||
export const localBackend = {};
|
||||
58
services/scoring.ts
Normal file
58
services/scoring.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
/**
|
||||
* Dynamic Scoring Algorithm
|
||||
* Logic:
|
||||
* 1. Base Score starts at initialPoints.
|
||||
* 2. As solves increase, the score for EVERYONE who solved it decreases.
|
||||
* 3. We use a decay function: currentPoints = max(min_points, initial * decay_factor ^ (solves - 1))
|
||||
* 4. Plus a "First Blood" bonus: 1st solver gets 10% extra, 2nd 5%, 3rd 2%.
|
||||
*/
|
||||
|
||||
const MIN_POINTS_PERCENTAGE = 0.2; // Points won't drop below 20% of initial
|
||||
const DECAY_CONSTANT = 0.92; // Aggressive decay per solve
|
||||
|
||||
export const calculateChallengeValue = (initialPoints: number, solveCount: number): number => {
|
||||
if (solveCount === 0) return initialPoints;
|
||||
const minPoints = Math.floor(initialPoints * MIN_POINTS_PERCENTAGE);
|
||||
const decayedPoints = Math.floor(initialPoints * Math.pow(DECAY_CONSTANT, solveCount - 1));
|
||||
return Math.max(minPoints, decayedPoints);
|
||||
};
|
||||
|
||||
export const getFirstBloodBonus = (rank: number): number => {
|
||||
if (rank === 0) return 0.10; // 1st
|
||||
if (rank === 1) return 0.05; // 2nd
|
||||
if (rank === 2) return 0.02; // 3rd
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const calculateTeamTotalScore = (
|
||||
teamId: string,
|
||||
challenges: { id: string, initialPoints: number, solves: string[] }[],
|
||||
solves: { teamId: string, challengeId: string, timestamp: number }[]
|
||||
): number => {
|
||||
let total = 0;
|
||||
|
||||
// Filter solves for this team
|
||||
const teamSolves = solves.filter(s => s.teamId === teamId);
|
||||
|
||||
teamSolves.forEach(solve => {
|
||||
const challenge = challenges.find(c => c.id === solve.challengeId);
|
||||
if (!challenge) return;
|
||||
|
||||
// Current value of challenge (shared by all)
|
||||
const baseValue = calculateChallengeValue(challenge.initialPoints, challenge.solves.length);
|
||||
|
||||
// Calculate rank for bonus
|
||||
// Find all solves for this challenge sorted by time
|
||||
const challengeSolves = solves
|
||||
.filter(s => s.challengeId === solve.challengeId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const rank = challengeSolves.findIndex(s => s.teamId === teamId);
|
||||
const bonus = Math.floor(baseValue * getFirstBloodBonus(rank));
|
||||
|
||||
total += (baseValue + bonus);
|
||||
});
|
||||
|
||||
return total;
|
||||
};
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
53
types.ts
Normal file
53
types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
export type Difficulty = 'Low' | 'Medium' | 'High';
|
||||
|
||||
export interface ChallengeFile {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
difficulty: Difficulty;
|
||||
description: string;
|
||||
initialPoints: number;
|
||||
flag: string;
|
||||
files: ChallengeFile[];
|
||||
solves: string[]; // Team IDs
|
||||
port?: number;
|
||||
connectionType?: 'nc' | 'http';
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
password?: string;
|
||||
isAdmin?: boolean | number;
|
||||
isDisabled?: boolean | number;
|
||||
}
|
||||
|
||||
export interface Solve {
|
||||
teamId: string;
|
||||
challengeId: string;
|
||||
timestamp: number;
|
||||
pointsEarned: number; // Stored at time of solve, but usually recalculated dynamically
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CTFState {
|
||||
isStarted: boolean;
|
||||
startTime: number | null;
|
||||
teams: Team[];
|
||||
challenges: Challenge[];
|
||||
solves: Solve[];
|
||||
blogs: BlogPost[];
|
||||
config: Record<string, string>;
|
||||
}
|
||||
26
vite.config.ts
Normal file
26
vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Directs API calls from Vite (port 5173) to Express (port 3000)
|
||||
// Using 127.0.0.1 is more stable than 'localhost' in some environments
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Directs file download calls to the uploads folder served by Express
|
||||
'/files': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user