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 crypto = require('crypto'); const app = express(); 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 const db = new sqlite3.Database('./data/ctf.db', (err) => { if (err) console.error('Database connection error:', err.message); 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 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(() => { 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, minimumPoints INTEGER DEFAULT 0, decaySolves INTEGER DEFAULT 1, flag TEXT, files TEXT DEFAULT '[]', port INTEGER, connectionType TEXT, overrideIp TEXT )`); 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'"); if (!columns.includes('overrideIp')) db.run("ALTER TABLE challenges ADD COLUMN overrideIp TEXT"); if (!columns.includes('minimumPoints')) db.run("ALTER TABLE challenges ADD COLUMN minimumPoints INTEGER DEFAULT 0"); if (!columns.includes('decaySolves')) db.run("ALTER TABLE challenges ADD COLUMN decaySolves INTEGER DEFAULT 1"); }); db.run(`CREATE TABLE IF NOT EXISTS solves ( teamId TEXT, challengeId TEXT, timestamp INTEGER, 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 ( id TEXT PRIMARY KEY, title TEXT, content TEXT, timestamp INTEGER )`); db.run(`CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT )`); const defaults = [ ['isStarted', 'false'], ['conferenceName', 'HIP'], ['landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄'], ['logoData', ''], ['bgType', 'color'], ['bgColor', '#000000'], ['bgImageData', ''], ['bgOpacity', '0.5'], ['bgBrightness', '1.0'], ['bgContrast', '1.0'], ['dockerIp', '127.0.0.1'], ['eventStartTime', Date.now().toString()], ['eventEndTime', (Date.now() + 86400000).toString()] ]; defaults.forEach(([k, v]) => { db.run(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, [k, v]); }); 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(express.json({ limit: '500mb' })); app.use('/files', express.static(uploadDir)); const apiRouter = express.Router(); 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) => { 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); }); }); }); }); }); }); apiRouter.put('/admin/config', upload.fields([{ name: 'logo' }, { name: 'bgImage' }]), (req, res) => { const updates = { ...req.body }; if (req.files) { if (req.files.logo) { 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.run("BEGIN TRANSACTION"); const stmt = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"); Object.entries(updates).forEach(([key, value]) => { if (!['logo','bgImage','files','isStarted'].includes(key)) stmt.run(key, String(value)); }); stmt.finalize(); db.run("COMMIT", (err) => { if (err) return res.status(500).json({ success: false }); res.json({ success: true }); }); }); }); 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) => { 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.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) => { 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 })); } else res.json({ success: false }); }); }); }); }); 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) => { 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.post('/admin/reset-scores', (req, res) => { db.run("DELETE FROM solves", () => res.json({ success: true })); }); apiRouter.put('/profile', (req, res) => { const teamId = req.headers.authorization?.replace('Bearer mock-token-', ''); if (!teamId) return res.status(401).json({ message: 'Unauthorized' }); db.run("UPDATE teams SET password = ? WHERE id = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true })); }); apiRouter.put('/admin/teams/:id', (req, res) => { const { name, isDisabled, password, isAdmin } = req.body, id = req.params.id; let query = "UPDATE teams SET name = ?, isDisabled = ?, isAdmin = ?"; let params = [name, id === 'admin-0' ? 0 : (isDisabled ? 1 : 0), id === 'admin-0' ? 1 : (isAdmin ? 1 : 0)]; if (password) { query += ", password = ?"; params.push(hashPassword(password)); } query += " WHERE id = ?"; params.push(id); db.run(query, params, () => res.json({ success: true })); }); apiRouter.delete('/admin/teams/:id', (req, res) => { if (req.params.id === 'admin-0') return res.status(403).json({ message: 'Protected' }); 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.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 })); }); 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 })); }); apiRouter.delete('/admin/blogs/:id', (req, res) => { db.run("DELETE FROM blogs WHERE id = ?", [req.params.id], () => res.json({ success: true })); }); apiRouter.post('/admin/challenges', upload.array('files'), (req, res) => { const { title, category, difficulty, description, initialPoints, minimumPoints, decaySolves, flag, port, connectionType, overrideIp } = req.body; 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, minimumPoints, decaySolves, flag, files, port, connectionType, overrideIp) 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 }) ); }); apiRouter.put('/admin/challenges/:id', upload.array('files'), (req, res) => { const id = req.params.id; db.get("SELECT files FROM challenges WHERE id = ?", [id], (err, row) => { let files = JSON.parse(req.body.existingFiles || '[]'), oldFiles = JSON.parse(row.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); } }); 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=?, minimumPoints=?, decaySolves=?, flag=?, files=?, port=?, connectionType=?, overrideIp=? WHERE id=?`; 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]; db.run(query, params, () => res.json({ success: true, id })); }); }); apiRouter.delete('/admin/challenges/:id', (req, res) => { db.get("SELECT files FROM challenges WHERE id = ?", [req.params.id], (err, row) => { if (row) unlinkFiles(row.files); 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 })); }); }); }); 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: 'Not found' }); }); } app.listen(port, '0.0.0.0', () => console.log(`CTF Backend running on port ${port}`));