- Added "HW" (Hardware) category to the platform with a dedicated icon and color
- Updated challenge grid to 6 columns on desktop to accommodate the new category - Alphabetized challenge categories in the main view and Admin panel selection - Alphabetized operators list in the Admin panel with case-insensitive sorting - Restricted visibility of Challenges, Scoreboard, and Score Matrix to authenticated users only - Secured the /state API endpoint to prevent leaking challenges, solves, teams, or internal IP (dockerIp) to guests - Implemented server-side verification of user profile in the state response to prevent client-side admin spoofing - Refactored the /state backend endpoint using async/await for better reliability and error handling - Rebranded the project from "cypherstrike-ctf" to "hipctf" across package.json, index.html, and server defaults - Synchronized browser page title with the competition name configured in the Admin panel - Fixed a "black page" issue by resolving a missing React import and adding frontend sanity checks
This commit is contained in:
@@ -24,9 +24,9 @@ export const Admin: React.FC = () => {
|
||||
}, [state.challenges]);
|
||||
|
||||
const sortedOperators = useMemo(() => {
|
||||
return state.teams
|
||||
return [...state.teams]
|
||||
.filter(t => t.id !== 'admin-0')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
}, [state.teams]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
16
App.tsx
16
App.tsx
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HashRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
|
||||
import { Terminal, Flag, Trophy, Newspaper, Shield, Settings, LogOut, X, History } from 'lucide-react';
|
||||
import { CTFProvider, useCTF } from './CTFContext';
|
||||
@@ -44,6 +44,12 @@ const LayoutShell: React.FC = () => {
|
||||
const { state, currentUser, logout, loading, loadError } = useCTF();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.config && state.config.conferenceName) {
|
||||
document.title = state.config.conferenceName;
|
||||
}
|
||||
}, [state.config]);
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-black flex items-center justify-center text-[#ff0000] font-black italic text-4xl animate-pulse tracking-tighter uppercase">INITIALIZING_SESSION...</div>;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -71,9 +77,9 @@ const LayoutShell: React.FC = () => {
|
||||
</Link>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="hidden md:flex gap-8 text-[10px] font-black uppercase tracking-widest">
|
||||
<Link to="/challenges" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Flag size={14}/> Challenges</Link>
|
||||
{currentUser ? <Link to="/challenges" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Flag size={14}/> Challenges</Link> : null}
|
||||
<Link to="/blog" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Newspaper size={14}/> Blog</Link>
|
||||
<Link to="/scoreboard" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Trophy size={14}/> Scoreboard</Link>
|
||||
{currentUser ? <Link to="/scoreboard" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><Trophy size={14}/> Scoreboard</Link> : null}
|
||||
{currentUser ? <Link to="/log" className="hover:text-[#bf00ff] flex items-center gap-2 transition-colors"><History size={14}/> Log</Link> : null}
|
||||
{currentUser?.isAdmin ? <Link to="/admin" className="text-[#ff0000] hover:text-white transition-colors flex items-center gap-2"><Shield size={14}/> Admin</Link> : null}
|
||||
</div>
|
||||
@@ -99,9 +105,9 @@ const LayoutShell: React.FC = () => {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/scoreboard" element={<Scoreboard />} />
|
||||
<Route path="/scoreboard" element={<ProtectedRoute><Scoreboard /></ProtectedRoute>} />
|
||||
<Route path="/log" element={<ProtectedRoute><Log /></ProtectedRoute>} />
|
||||
<Route path="/matrix" element={<ScoreMatrix />} />
|
||||
<Route path="/matrix" element={<ProtectedRoute><ScoreMatrix /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute>{currentUser?.isAdmin ? <Admin /> : <Navigate to="/" />}</ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -49,7 +49,18 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const refreshState = useCallback(async () => {
|
||||
try {
|
||||
const newState = await api.getState();
|
||||
if (!newState || !newState.config) return;
|
||||
setState(newState);
|
||||
if (newState.user !== undefined) {
|
||||
setCurrentUser(newState.user);
|
||||
const session = localStorage.getItem('hip6_session');
|
||||
if (session && newState.user) {
|
||||
const { token } = JSON.parse(session);
|
||||
localStorage.setItem('hip6_session', JSON.stringify({ team: newState.user, token }));
|
||||
} else if (session && !newState.user) {
|
||||
localStorage.removeItem('hip6_session');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("State refresh failed:", err);
|
||||
throw err;
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { X, CheckCircle2, Download, Globe, Sparkles, Heart, Clock, Bell } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { Challenge, Difficulty } from './types';
|
||||
import { CATEGORIES } from './constants';
|
||||
import { Button, CategoryIcon, Countdown } from './UIComponents';
|
||||
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
||||
|
||||
@@ -122,7 +123,6 @@ export const ChallengeList: React.FC = () => {
|
||||
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(null);
|
||||
const [showRefreshPopup, setShowRefreshPopup] = useState(false);
|
||||
const difficultyWeight: Record<Difficulty, number> = { 'Low': 1, 'Medium': 2, 'High': 3 };
|
||||
const CATEGORIES = ['WEB', 'PWN', 'REV', 'CRY', 'MSC'];
|
||||
|
||||
const now = Date.now();
|
||||
const startTime = parseInt(state.config.eventStartTime || "0");
|
||||
@@ -178,7 +178,7 @@ export const ChallengeList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-8 max-w-[1600px] mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-8 max-w-[1800px] mx-auto">
|
||||
{CATEGORIES.map(category => {
|
||||
const categoryChallenges = (state.challenges || [])
|
||||
.filter(c => c.category === category)
|
||||
@@ -188,7 +188,7 @@ export const ChallengeList: React.FC = () => {
|
||||
<div key={category} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 mb-4">
|
||||
<div className="p-3 bg-white/5 hxp-border border-[#333] hover:border-[#bf00ff] transition-colors">
|
||||
<CategoryIcon category={category} size={48} color={category === 'WEB' ? '#ff0000' : category === 'PWN' ? '#ffaa00' : category === 'REV' ? '#00ccff' : '#bf00ff'} />
|
||||
<CategoryIcon category={category} size={48} color={category === 'WEB' ? '#ff0000' : category === 'PWN' ? '#ffaa00' : category === 'REV' ? '#00ccff' : category === 'HW' ? '#00ff00' : '#bf00ff'} />
|
||||
</div>
|
||||
<h3 className="text-3xl font-black italic text-white uppercase tracking-tighter">{category}</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Terminal, Radar, Zap, RefreshCw, Box } from 'lucide-react';
|
||||
import { Terminal, Radar, Zap, RefreshCw, Box, Cpu } from 'lucide-react';
|
||||
import { useCTF } from './CTFContext';
|
||||
|
||||
export const formatDuration = (ms: number) => {
|
||||
@@ -51,6 +51,7 @@ export const CategoryIcon: React.FC<{ category: string; size?: number; color?: s
|
||||
case 'PWN': return <Zap size={size} color={color} />;
|
||||
case 'REV': return <RefreshCw size={size} color={color} />;
|
||||
case 'CRY': return <Box size={size} color={color} />;
|
||||
case 'HW': return <Cpu size={size} color={color} />;
|
||||
default: return <Terminal size={size} color={color} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
2026-03-10
|
||||
- Added "HW" (Hardware) category to the platform with a dedicated icon and color
|
||||
- Updated challenge grid to 6 columns on desktop to accommodate the new category
|
||||
- Alphabetized challenge categories in the main view and Admin panel selection
|
||||
- Alphabetized operators list in the Admin panel with case-insensitive sorting
|
||||
- Restricted visibility of Challenges, Scoreboard, and Score Matrix to authenticated users only
|
||||
- Secured the /state API endpoint to prevent leaking challenges, solves, teams, or internal IP (dockerIp) to guests
|
||||
- Implemented server-side verification of user profile in the state response to prevent client-side admin spoofing
|
||||
- Refactored the /state backend endpoint using async/await for better reliability and error handling
|
||||
- Rebranded the project from "cypherstrike-ctf" to "hipctf" across package.json, index.html, and server defaults
|
||||
- Synchronized browser page title with the competition name configured in the Admin panel
|
||||
- Fixed a "black page" issue by resolving a missing React import and adding frontend sanity checks
|
||||
|
||||
2026-03-08
|
||||
- Added an interactive graph view to the event log to track the score progression of the top 10 teams
|
||||
|
||||
@@ -37,4 +50,4 @@
|
||||
|
||||
2026-01-07
|
||||
- Removed the README.md file
|
||||
- Initial project setup including React frontend, Express backend, Docker configuration, and baseline scoring services
|
||||
- Initial project setup including React frontend, Express backend, Docker configuration, and baseline scoring services
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Challenge, Team } from './types';
|
||||
|
||||
export const CATEGORIES = ['WEB', 'PWN', 'REV', 'CRY', 'MSC'];
|
||||
export const CATEGORIES = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'];
|
||||
export const DIFFICULTIES: ('Low' | 'Medium' | 'High')[] = ['Low', 'Medium', 'High'];
|
||||
|
||||
export const INITIAL_CHALLENGES: Challenge[] = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HIP7CTF</title>
|
||||
<title>HIPCTF</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>
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "cypherstrike-ctf",
|
||||
"name": "hipctf",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cypherstrike-ctf",
|
||||
"name": "hipctf",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.2",
|
||||
@@ -57,6 +57,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1353,6 +1354,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1654,6 +1656,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3524,6 +3527,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3695,6 +3699,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3704,6 +3709,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3723,6 +3729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -3857,7 +3864,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4615,6 +4623,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "cypherstrike-ctf",
|
||||
"name": "hipctf",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
|
||||
123
server.js
123
server.js
@@ -152,7 +152,7 @@ db.serialize(() => {
|
||||
|
||||
const defaults = [
|
||||
['isStarted', 'false'],
|
||||
['conferenceName', 'HIP'],
|
||||
['conferenceName', 'HIPCTF'],
|
||||
['landingText', 'WELCOME TO THE PLAYGROUND. SOLVE CHALLENGES. SHARE KNOWLEDGE. 🦄'],
|
||||
['logoData', ''],
|
||||
['bgType', 'color'],
|
||||
@@ -260,72 +260,73 @@ apiRouter.post('/auth/login', (req, res) => {
|
||||
});
|
||||
|
||||
apiRouter.get('/state', async (req, res) => {
|
||||
const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {} };
|
||||
|
||||
// Security Check: Identify requester and admin status
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||
const teamId = verifyToken(token);
|
||||
let isAdmin = false;
|
||||
try {
|
||||
const state = { isStarted: false, teams: [], challenges: [], solves: [], blogs: [], config: {}, user: null };
|
||||
|
||||
// Security Check: Identify requester and admin status
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||
const teamId = verifyToken(token);
|
||||
let isAdmin = false;
|
||||
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await dbGet("SELECT isAdmin FROM teams WHERE id = ?", [teamId]);
|
||||
isAdmin = team && team.isAdmin === 1;
|
||||
} catch (err) {
|
||||
console.error("Auth verify failed in state API:", err);
|
||||
if (teamId) {
|
||||
const team = await dbGet("SELECT id, name, isAdmin, isDisabled FROM teams WHERE id = ?", [teamId]);
|
||||
if (team) {
|
||||
state.user = { id: team.id, name: team.name, isAdmin: team.isAdmin === 1 };
|
||||
isAdmin = team.isAdmin === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.all("SELECT key, value FROM config", (err, configRows) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to fetch config' });
|
||||
const configRows = await dbAll("SELECT key, value FROM 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' });
|
||||
|
||||
if (isAdmin) {
|
||||
state.teams = teams || [];
|
||||
} else {
|
||||
// For non-admins, only show active non-admin teams for the scoreboard
|
||||
state.teams = (teams || []).filter(t => !t.isAdmin && !t.isDisabled).map(t => {
|
||||
const { isAdmin: _, isDisabled: __, ...publicTeam } = t;
|
||||
return publicTeam;
|
||||
});
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const startTime = parseInt(state.config.eventStartTime || "0", 10);
|
||||
const isBeforeStart = Date.now() < startTime;
|
||||
if (!teamId || (!isAdmin && (!state.isStarted || isBeforeStart))) {
|
||||
state.challenges = [];
|
||||
} else {
|
||||
state.challenges = (challenges || []).map(c => {
|
||||
const enriched = {
|
||||
...c,
|
||||
files: JSON.parse(c.files || '[]'),
|
||||
solves: state.solves.filter(s => s.challengeId === c.id).map(s => s.teamId)
|
||||
};
|
||||
// CRITICAL SECURITY FIX: Hide flag if not admin
|
||||
if (!isAdmin) {
|
||||
delete enriched.flag;
|
||||
}
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
});
|
||||
if (!teamId) delete state.config.dockerIp;
|
||||
|
||||
const startTime = parseInt(state.config.eventStartTime || "0", 10);
|
||||
const isBeforeStart = Date.now() < startTime;
|
||||
|
||||
// Fetch teams
|
||||
const teams = await dbAll("SELECT id, name, isAdmin, isDisabled FROM teams");
|
||||
if (!teamId || (!isAdmin && (!state.isStarted || isBeforeStart))) {
|
||||
state.teams = [];
|
||||
} else if (isAdmin) {
|
||||
state.teams = teams || [];
|
||||
} else {
|
||||
state.teams = (teams || []).filter(t => !t.isAdmin && !t.isDisabled).map(t => {
|
||||
const { isAdmin: _, isDisabled: __, ...publicTeam } = t;
|
||||
return publicTeam;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch solves
|
||||
const solves = await dbAll("SELECT * FROM solves");
|
||||
|
||||
// Fetch challenges
|
||||
const challenges = await dbAll("SELECT * FROM challenges");
|
||||
if (!teamId || (!isAdmin && (!state.isStarted || isBeforeStart))) {
|
||||
state.challenges = [];
|
||||
state.solves = [];
|
||||
} else {
|
||||
state.solves = solves || [];
|
||||
state.challenges = (challenges || []).map(c => {
|
||||
const enriched = {
|
||||
...c,
|
||||
files: JSON.parse(c.files || '[]'),
|
||||
solves: (solves || []).filter(s => s.challengeId === c.id).map(s => s.teamId)
|
||||
};
|
||||
if (!isAdmin) delete enriched.flag;
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch blogs
|
||||
state.blogs = await dbAll("SELECT * FROM blogs ORDER BY timestamp DESC");
|
||||
|
||||
res.json(state);
|
||||
} catch (err) {
|
||||
console.error("State API Error:", err);
|
||||
res.status(500).json({ error: 'Failed to fetch state' });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/admin/config', upload.fields([{ name: 'logo' }, { name: 'bgImage' }]), (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user