├── .cursor
└── rules
│ ├── nextjs.mdc
│ └── tailwindcss.mdc
├── .dockerignore
├── .env.example
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.yml
├── eslint.config.mjs
├── next.config.ts
├── orbital-ctf-promo.gif
├── package-lock.json
├── package.json
├── postcss.config.js
├── postcss.config.mjs
├── prisma
├── generated
│ └── client
│ │ ├── client.d.ts
│ │ ├── client.js
│ │ ├── default.d.ts
│ │ ├── default.js
│ │ ├── edge.d.ts
│ │ ├── edge.js
│ │ ├── index-browser.js
│ │ ├── index.d.ts
│ │ ├── index.js
│ │ ├── package.json
│ │ ├── query_engine-windows.dll.node
│ │ ├── query_engine-windows.dll.node.tmp56020
│ │ ├── runtime
│ │ ├── edge-esm.js
│ │ ├── edge.js
│ │ ├── index-browser.d.ts
│ │ ├── index-browser.js
│ │ ├── library.d.ts
│ │ ├── library.js
│ │ ├── react-native.js
│ │ └── wasm.js
│ │ ├── schema.prisma
│ │ ├── wasm.d.ts
│ │ └── wasm.js
├── migrations
│ ├── 20250513165839_init
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema.prisma
├── seed-example.ts
└── seed.ts
├── public
└── SquadaOne-Regular.ttf
├── src
├── app
│ ├── admin
│ │ └── page.tsx
│ ├── api
│ │ ├── activity
│ │ │ └── route.ts
│ │ ├── admin
│ │ │ ├── challenges
│ │ │ │ ├── bulk
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ ├── submissions
│ │ │ │ └── route.ts
│ │ │ ├── teams
│ │ │ │ └── route.ts
│ │ │ └── users
│ │ │ │ └── route.ts
│ │ ├── announcements
│ │ │ ├── [id]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── auth
│ │ │ ├── [...nextauth]
│ │ │ │ └── route.ts
│ │ │ └── signup
│ │ │ │ └── route.ts
│ │ ├── challenges
│ │ │ ├── [challengeId]
│ │ │ │ └── route.ts
│ │ │ ├── categories
│ │ │ │ ├── [categoryId]
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ ├── route.ts
│ │ │ └── solved
│ │ │ │ └── route.ts
│ │ ├── config
│ │ │ └── route.ts
│ │ ├── files
│ │ │ ├── [filepath]
│ │ │ │ └── route.ts
│ │ │ └── upload
│ │ │ │ └── route.ts
│ │ ├── game-config
│ │ │ └── route.ts
│ │ ├── hints
│ │ │ ├── purchase
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── leaderboard
│ │ │ └── route.ts
│ │ ├── rules
│ │ │ └── route.ts
│ │ ├── scores
│ │ │ └── route.ts
│ │ ├── submissions
│ │ │ └── route.ts
│ │ └── teams
│ │ │ └── [teamId]
│ │ │ ├── points
│ │ │ └── history
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ ├── auth
│ │ ├── signin
│ │ │ └── page.tsx
│ │ ├── signout
│ │ │ └── page.tsx
│ │ └── signup
│ │ │ └── page.tsx
│ ├── categories
│ │ └── [categoryId]
│ │ │ └── page.tsx
│ ├── challenges
│ │ └── [challengeId]
│ │ │ └── page.tsx
│ ├── dashboard
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── not-found.tsx
│ ├── page.tsx
│ ├── profile
│ │ └── page.tsx
│ ├── rules
│ │ └── page.tsx
│ └── scoreboard
│ │ └── page.tsx
├── components
│ ├── DetailedCategoryView.tsx
│ ├── MarkdownComponents.tsx
│ ├── Navbar.tsx
│ ├── Providers.tsx
│ ├── ScoreboardChart.tsx
│ ├── ScoreboardStandings.tsx
│ ├── SpaceScene.tsx
│ ├── admin
│ │ ├── AnnouncementModal.tsx
│ │ ├── AnnouncementsTab.tsx
│ │ ├── ChallengeModal.tsx
│ │ ├── ChallengesTab.tsx
│ │ ├── ConfigurationTab.tsx
│ │ ├── GameConfigurationTab.tsx
│ │ ├── SiteConfigurationTab.tsx
│ │ ├── SubmissionsTab.tsx
│ │ ├── TeamEditModal.tsx
│ │ ├── TeamsTab.tsx
│ │ ├── UserEditModal.tsx
│ │ └── UsersTab.tsx
│ ├── auth
│ │ └── TeamIconSelection.tsx
│ ├── common
│ │ ├── LoadingSpinner.tsx
│ │ └── TabButton.tsx
│ ├── dashboard
│ │ ├── Activity.tsx
│ │ ├── Announcements.tsx
│ │ ├── GameClock.tsx
│ │ └── Leaderboard.tsx
│ └── layouts
│ │ └── PageLayout.tsx
├── instrumentation.ts
├── lib
│ ├── auth.ts
│ ├── challenge-ingestion.ts
│ ├── challenges.ts
│ └── prisma.ts
├── middleware.ts
├── types
│ ├── index.d.ts
│ └── next-auth.d.ts
└── utils
│ └── api.ts
├── tailwind.config.ts
└── tsconfig.json
/.cursor/rules/nextjs.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs: **/*.js,**/*.ts,**/*.tsx
4 | alwaysApply: false
5 | ---
6 | ---
7 | description: This rule explains Next.js conventions and best practices for fullstack development.
8 | globs: **/*.js,**/*.jsx,**/*.ts,**/*.tsx
9 | alwaysApply: false
10 | ---
11 |
12 | # Next.js rules
13 |
14 | - Use the App Router structure with `page.tsx` files in route directories.
15 | - Client components must be explicitly marked with `'use client'` at the top of the file.
16 | - Use kebab-case for directory names (e.g., `components/auth-form`) and PascalCase for component files.
17 | - Prefer named exports over default exports, i.e. `export function Button() { /* ... */ }` instead of `export default function Button() { /* ... */ }`.
18 | - Minimize `'use client'` directives:
19 | - Keep most components as React Server Components (RSC)
20 | - Only use client components when you need interactivity and wrap in `Suspense` with fallback UI
21 | - Create small client component wrappers around interactive elements
22 | - Avoid unnecessary `useState` and `useEffect` when possible:
23 | - Use server components for data fetching
24 | - Use React Server Actions for form handling
25 |
26 | - Use URL search params for shareable state
--------------------------------------------------------------------------------
/.cursor/rules/tailwindcss.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: true
5 | ---
6 | ---
7 | description: This rule explains Tailwind CSS conventions, utility classes, and best practices for modern UI development.
8 | globs: *
9 | alwaysApply: false
10 | ---
11 |
12 | # Tailwind CSS rules
13 |
14 | - Use responsive prefixes for mobile-first design:
15 |
16 | ```html
17 |
18 |
19 |
20 | ```
21 |
22 | - Use state variants for interactive elements:
23 |
24 | ```html
25 |
26 | Click me
27 |
28 | ```
29 |
30 | - Use @apply for repeated patterns when necessary:
31 |
32 | ```css
33 | @layer components {
34 | .btn-primary {
35 | @apply px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600;
36 | }
37 | }
38 | ```
39 |
40 | - Use arbitrary values for specific requirements:
41 |
42 | ```html
43 |
44 |
45 |
46 | ```
47 |
48 | - Use spacing utilities for consistent layout:
49 |
50 | ```html
51 |
52 |
Item 1
53 |
Item 2
54 |
55 |
56 | ```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 |
7 | # Next.js build output
8 | .next
9 |
10 | # Development database
11 | prisma/dev.db*
12 |
13 | # Environment files
14 | .env*
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | # Version control
21 | .git
22 | .gitignore
23 |
24 | # IDE files
25 | .idea
26 | .vscode
27 | *.swp
28 | *.swo
29 |
30 | # OS files
31 | .DS_Store
32 | Thumbs.db
33 |
34 | # Docker
35 | Dockerfile
36 | .dockerignore
37 |
38 | # User uploads
39 | public/uploads/*
40 | !public/uploads/.gitkeep
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NEXTAUTH_SECRET="your-secret-goes-here"
2 | NEXTAUTH_URL="http://localhost:3000"
3 | DATABASE_URL=file:./dev.db
4 | INGEST_CHALLENGES_AT_STARTUP=true
5 | CHALLENGES_DIR="./challenges"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 | dev.db
23 | /public/uploads*
24 |
25 | # misc
26 | .DS_Store
27 | *.pem
28 |
29 | # debug
30 | npm-debug.log*
31 | yarn-debug.log*
32 | yarn-error.log*
33 | .pnpm-debug.log*
34 |
35 | # env files (can opt-in for committing if needed)
36 | .env*
37 | !.env.example
38 |
39 | # vercel
40 | .vercel
41 |
42 | # typescript
43 | *.tsbuildinfo
44 | next-env.d.ts
45 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM node:20-alpine AS builder
3 |
4 | # Set working directory
5 | WORKDIR /app
6 |
7 | # Install dependencies first (including dev dependencies)
8 | COPY package*.json ./
9 | RUN npm ci
10 |
11 | # Copy project files
12 | COPY . .
13 |
14 | # Generate Prisma client
15 | RUN npx prisma generate
16 |
17 | # Build the Next.js application
18 | RUN npm run build
19 |
20 | # Production stage
21 | FROM node:20-alpine AS runner
22 | WORKDIR /app
23 |
24 | # Install production dependencies only
25 | COPY package*.json ./
26 | RUN npm ci --production
27 |
28 | # Copy built files from builder stage
29 | COPY --from=builder /app/.next ./.next
30 | COPY --from=builder /app/public ./public
31 | COPY --from=builder /app/prisma ./prisma
32 |
33 | # Ensure uploads and challenges directories exist
34 | RUN mkdir -p public/uploads
35 | RUN mkdir -p /challenges
36 |
37 | # Copy other necessary files
38 | COPY --from=builder /app/next.config.ts ./
39 | COPY --from=builder /app/package.json ./
40 |
41 | # Create a non-root user and switch to it
42 | RUN addgroup --system --gid 1001 nodejs
43 | RUN adduser --system --uid 1001 --ingroup nodejs --disabled-password --shell /sbin/nologin nextjs
44 | USER nextjs
45 |
46 | # Expose the port the app runs on
47 | EXPOSE 3000
48 |
49 | # Set environment variables
50 | ENV NODE_ENV=production
51 | ENV PORT=3000
52 | ENV CHALLENGES_DIR=/challenges
53 | ENV INGEST_CHALLENGES_AT_STARTUP=false
54 |
55 |
56 | # Initialize database and run the app
57 | CMD npx prisma migrate deploy && \
58 | npx prisma db seed && \
59 | npm start
60 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 Orbital CTF Platform
2 |
3 |
4 |
5 | 
6 | [](https://nextjs.org)
7 | [](https://www.prisma.io)
8 | [](https://tailwindcss.com)
9 |
10 |
11 |
12 | A retro space-themed Capture The Flag platform built with modern tech stack that actually Just Works™️
13 |
14 | Experience a CTF in a sleek, dark-themed environment with real-time scoring and team collaboration.
15 |
16 | [Static Demo Site](https://asynchronous-x.github.io/orbital-ctf/) · [Report Bug](https://github.com/asynchronous-x/orbital-ctf/issues) · [Request Feature](https://github.com/asynchronous-x/orbital-ctf/issues)
17 |
18 | [](https://x.com/i/status/1922884608200188109)
19 |
20 |
21 |
22 |
23 | ## ✨ Features
24 |
25 | - 🔐 **User Authentication** - Individual and team registration system
26 | - 🎯 **Challenge Management** - Create, edit, import/export and manage CTF challenges
27 | - 📊 **Real-time Scoring** - Live leaderboard updates
28 | - 🌙 **Retro UI Theme** - Space-inspired design with stunning visuals for both the categories and challenge selection screens
29 | - 📱 **Responsive Design** - Works on both desktop and mobile
30 | - 🚀 **Modern Stack** - Built with Next.js 15, Prisma, and Tailwind CSS
31 | - 🏁 **Multi-Flag Challenges** - Supports problems with multiple flags for partial credit
32 | - 📈 **Scoreboard History** - Visualize team progress with a dynamic chart
33 | - 🔓 **Unlock Conditions** - Time-based and prerequisite challenge gates
34 |
35 | ## 🛠️ Prerequisites
36 |
37 | Before you begin, ensure you have the following installed:
38 |
39 | - Node.js 20.x or later
40 | - npm or yarn
41 | - SQLite (included with Prisma)
42 |
43 | ## 🚀 Quick Start
44 |
45 | 1. **Clone the repository**
46 | ```bash
47 | git clone https://github.com/asynchronous-x/orbital-ctf.git
48 | cd orbital-ctf
49 | ```
50 |
51 | 2. **Install dependencies**
52 | ```bash
53 | npm install
54 | # or
55 | yarn install
56 | ```
57 |
58 | 3. **Create a `.env` file**
59 | ```bash
60 | cp .env.example .env
61 | ```
62 |
63 | 4. **Set up the database**
64 | ```bash
65 | npx prisma migrate reset
66 | ```
67 |
68 | 5. **Seed initial challenges**
69 | ```bash
70 | npm run prisma:seed
71 | # or
72 | yarn prisma:seed
73 | ```
74 |
75 | 6. **Start development server**
76 | ```bash
77 | npm run dev
78 | # or
79 | yarn dev
80 | ```
81 |
82 | Open [http://localhost:3000](http://localhost:3000) to launch the platform.
83 |
84 | ## 📁 Project Structure
85 |
86 | ```
87 | orbital-ctf/
88 | ├── src/
89 | │ ├── app/ # Next.js app router pages
90 | │ ├── components/ # React components
91 | │ ├── lib/ # Server-side helpers
92 | │ ├── utils/ # Client-side utilities
93 | │ ├── types/ # Shared TypeScript types
94 | │ ├── middleware.ts # Next.js middleware
95 | │ └── instrumentation.ts # Startup tasks (challenge import)
96 | ├── prisma/
97 | │ ├── schema.prisma # Database schema
98 | │ └── migrations/ # Database migrations
99 | └── public/ # Static assets
100 | └── uploads/ # Challenge file uploads
101 | ```
102 |
103 | ## 💾 Database Schema
104 |
105 | The platform is built on these core models:
106 |
107 | | Model | Description |
108 | |-------|-------------|
109 | | `User` | User accounts with authentication and team membership |
110 | | `Team` | Team information, scoring, and member management |
111 | | `Challenge` | CTF challenges with points and locking rules |
112 | | `UnlockCondition` | Challenge unlock requirements |
113 | | `ChallengeFlag` | Supports multi-flag scoring |
114 | | `Submission` | Challenge submission tracking and validation |
115 | | `Announcement` | Platform-wide announcements |
116 | | `ActivityLog` | Team activity tracking |
117 | | `GameConfig` | CTF game timing and state configuration |
118 | | `ChallengeFile` | Challenge attachment management |
119 | | `Hint` | Challenge hints with point costs |
120 | | `TeamHint` | Tracks which teams have purchased hints |
121 | | `SiteConfig` | Platform configuration settings |
122 | | `Score` | Detailed scoring history for teams and users |
123 | | `TeamPointHistory` | Chronological log of team score changes |
124 |
125 | ## 🔧 Configuration
126 |
127 | The platform can be configured through environment variables:
128 |
129 | ```env
130 | DATABASE_URL="file:./dev.db"
131 | NEXTAUTH_SECRET="your-secret-here"
132 | NEXTAUTH_URL="http://localhost:3000"
133 | INGEST_CHALLENGES_AT_STARTUP=true
134 | CHALLENGES_DIR="./challenges"
135 | ```
136 |
137 | Set `INGEST_CHALLENGES_AT_STARTUP` to `true` if you want challenges in `CHALLENGES_DIR` automatically imported when the server starts.
138 |
139 | ## 📝 License
140 |
141 | This project is licensed under the GPL-3.0 License. See the [LICENSE](LICENSE) file for details.
142 |
143 | ## 🌟 Acknowledgments
144 |
145 | - [Next.js](https://nextjs.org)
146 | - [Prisma](https://www.prisma.io)
147 | - [Tailwind CSS](https://tailwindcss.com)
148 | - [NextAuth.js](https://next-auth.js.org)
149 |
150 | ---
151 |
152 |
153 |
154 | Made with 💯 by [Asynchronous-X](https://github.com/asynchronous-x)
155 |
156 |
157 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | ports:
9 | - "3000:3000"
10 | environment:
11 | - NODE_ENV=production
12 | - DATABASE_URL=file:/app/prisma/dev.db
13 | - NEXTAUTH_SECRET="your-secret-goes-here"
14 | - NEXTAUTH_URL="http://localhost:3000"
15 | volumes:
16 | - sqlite_data:/app/prisma
17 | - uploads:/app/public/uploads
18 |
19 | volumes:
20 | sqlite_data:
21 | uploads:
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/orbital-ctf-promo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asynchronous-x/orbital-ctf/126e8d84ad20640140990e0caaa7aea73d78c6e6/orbital-ctf-promo.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "orbital-ctf",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "prisma:seed": "ts-node --compiler-options {\\\"module\\\":\\\"commonjs\\\"} prisma/seed.ts",
11 | "prisma:generate": "prisma generate"
12 | },
13 | "prisma": {
14 | "seed": "ts-node --compiler-options {\"module\":\"commonjs\"} prisma/seed.ts"
15 | },
16 | "dependencies": {
17 | "@prisma/client": "^6.6.0",
18 | "@react-three/drei": "^10.0.6",
19 | "@react-three/fiber": "^9.1.2",
20 | "bcryptjs": "^3.0.2",
21 | "date-fns": "^4.1.0",
22 | "dotenv": "^16.4.7",
23 | "jsonwebtoken": "^9.0.2",
24 | "next": "15.2.5",
25 | "next-auth": "^4.24.11",
26 | "react": "^19.0.0",
27 | "react-dom": "^19.0.0",
28 | "react-hot-toast": "^2.5.2",
29 | "react-icons": "^5.5.0",
30 | "react-markdown": "^10.1.0",
31 | "recharts": "^2.15.2",
32 | "remark-gfm": "^4.0.1",
33 | "three": "^0.175.0",
34 | "ts-node": "^10.9.2"
35 | },
36 | "devDependencies": {
37 | "@eslint/eslintrc": "^3",
38 | "@types/node": "^20",
39 | "@types/react": "^19",
40 | "@types/react-dom": "^19",
41 | "autoprefixer": "^10.4.17",
42 | "eslint": "^9",
43 | "eslint-config-next": "15.2.5",
44 | "postcss": "^8.4.35",
45 | "prisma": "^6.7.0",
46 | "tailwindcss": "^3.4.1",
47 | "typescript": "^5"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/prisma/generated/client/client.d.ts:
--------------------------------------------------------------------------------
1 | export * from "./index"
--------------------------------------------------------------------------------
/prisma/generated/client/client.js:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!!
3 | /* eslint-disable */
4 | module.exports = { ...require('.') }
--------------------------------------------------------------------------------
/prisma/generated/client/default.d.ts:
--------------------------------------------------------------------------------
1 | export * from "./index"
--------------------------------------------------------------------------------
/prisma/generated/client/default.js:
--------------------------------------------------------------------------------
1 |
2 | /* !!! This is code generated by Prisma. Do not edit directly. !!!
3 | /* eslint-disable */
4 | module.exports = { ...require('.') }
--------------------------------------------------------------------------------
/prisma/generated/client/edge.d.ts:
--------------------------------------------------------------------------------
1 | export * from "./default"
--------------------------------------------------------------------------------
/prisma/generated/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prisma-client-10500ca508ef408522d00fb1dabef65d86ab426b50fcce3f0dbb40ea0279518b",
3 | "main": "index.js",
4 | "types": "index.d.ts",
5 | "browser": "index-browser.js",
6 | "exports": {
7 | "./client": {
8 | "require": {
9 | "node": "./index.js",
10 | "edge-light": "./wasm.js",
11 | "workerd": "./wasm.js",
12 | "worker": "./wasm.js",
13 | "browser": "./index-browser.js",
14 | "default": "./index.js"
15 | },
16 | "import": {
17 | "node": "./index.js",
18 | "edge-light": "./wasm.js",
19 | "workerd": "./wasm.js",
20 | "worker": "./wasm.js",
21 | "browser": "./index-browser.js",
22 | "default": "./index.js"
23 | },
24 | "default": "./index.js"
25 | },
26 | "./package.json": "./package.json",
27 | ".": {
28 | "require": {
29 | "node": "./index.js",
30 | "edge-light": "./wasm.js",
31 | "workerd": "./wasm.js",
32 | "worker": "./wasm.js",
33 | "browser": "./index-browser.js",
34 | "default": "./index.js"
35 | },
36 | "import": {
37 | "node": "./index.js",
38 | "edge-light": "./wasm.js",
39 | "workerd": "./wasm.js",
40 | "worker": "./wasm.js",
41 | "browser": "./index-browser.js",
42 | "default": "./index.js"
43 | },
44 | "default": "./index.js"
45 | },
46 | "./edge": {
47 | "types": "./edge.d.ts",
48 | "require": "./edge.js",
49 | "import": "./edge.js",
50 | "default": "./edge.js"
51 | },
52 | "./react-native": {
53 | "types": "./react-native.d.ts",
54 | "require": "./react-native.js",
55 | "import": "./react-native.js",
56 | "default": "./react-native.js"
57 | },
58 | "./extension": {
59 | "types": "./extension.d.ts",
60 | "require": "./extension.js",
61 | "import": "./extension.js",
62 | "default": "./extension.js"
63 | },
64 | "./index-browser": {
65 | "types": "./index.d.ts",
66 | "require": "./index-browser.js",
67 | "import": "./index-browser.js",
68 | "default": "./index-browser.js"
69 | },
70 | "./index": {
71 | "types": "./index.d.ts",
72 | "require": "./index.js",
73 | "import": "./index.js",
74 | "default": "./index.js"
75 | },
76 | "./wasm": {
77 | "types": "./wasm.d.ts",
78 | "require": "./wasm.js",
79 | "import": "./wasm.mjs",
80 | "default": "./wasm.mjs"
81 | },
82 | "./runtime/client": {
83 | "types": "./runtime/client.d.ts",
84 | "require": "./runtime/client.js",
85 | "import": "./runtime/client.mjs",
86 | "default": "./runtime/client.mjs"
87 | },
88 | "./runtime/library": {
89 | "types": "./runtime/library.d.ts",
90 | "require": "./runtime/library.js",
91 | "import": "./runtime/library.mjs",
92 | "default": "./runtime/library.mjs"
93 | },
94 | "./runtime/binary": {
95 | "types": "./runtime/binary.d.ts",
96 | "require": "./runtime/binary.js",
97 | "import": "./runtime/binary.mjs",
98 | "default": "./runtime/binary.mjs"
99 | },
100 | "./runtime/wasm": {
101 | "types": "./runtime/wasm.d.ts",
102 | "require": "./runtime/wasm.js",
103 | "import": "./runtime/wasm.mjs",
104 | "default": "./runtime/wasm.mjs"
105 | },
106 | "./runtime/edge": {
107 | "types": "./runtime/edge.d.ts",
108 | "require": "./runtime/edge.js",
109 | "import": "./runtime/edge-esm.js",
110 | "default": "./runtime/edge-esm.js"
111 | },
112 | "./runtime/react-native": {
113 | "types": "./runtime/react-native.d.ts",
114 | "require": "./runtime/react-native.js",
115 | "import": "./runtime/react-native.js",
116 | "default": "./runtime/react-native.js"
117 | },
118 | "./generator-build": {
119 | "require": "./generator-build/index.js",
120 | "import": "./generator-build/index.js",
121 | "default": "./generator-build/index.js"
122 | },
123 | "./sql": {
124 | "require": {
125 | "types": "./sql.d.ts",
126 | "node": "./sql.js",
127 | "default": "./sql.js"
128 | },
129 | "import": {
130 | "types": "./sql.d.ts",
131 | "node": "./sql.mjs",
132 | "default": "./sql.mjs"
133 | },
134 | "default": "./sql.js"
135 | },
136 | "./*": "./*"
137 | },
138 | "version": "6.7.0",
139 | "sideEffects": false
140 | }
--------------------------------------------------------------------------------
/prisma/generated/client/query_engine-windows.dll.node:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asynchronous-x/orbital-ctf/126e8d84ad20640140990e0caaa7aea73d78c6e6/prisma/generated/client/query_engine-windows.dll.node
--------------------------------------------------------------------------------
/prisma/generated/client/query_engine-windows.dll.node.tmp56020:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asynchronous-x/orbital-ctf/126e8d84ad20640140990e0caaa7aea73d78c6e6/prisma/generated/client/query_engine-windows.dll.node.tmp56020
--------------------------------------------------------------------------------
/prisma/generated/client/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | output = "./generated/client"
4 | }
5 |
6 | datasource db {
7 | provider = "sqlite"
8 | url = env("DATABASE_URL")
9 | }
10 |
11 | model User {
12 | id String @id @default(cuid())
13 | alias String @unique
14 | password String
15 | name String
16 | createdAt DateTime @default(now())
17 | updatedAt DateTime @updatedAt
18 | teamId String?
19 | isTeamLeader Boolean @default(false)
20 | isAdmin Boolean @default(false)
21 | submissions Submission[]
22 | scores Score[]
23 | team Team? @relation(fields: [teamId], references: [id])
24 | }
25 |
26 | model Team {
27 | id String @id @default(cuid())
28 | name String @unique
29 | code String @unique
30 | icon String @default("GiSpaceship")
31 | color String @default("#ffffff")
32 | createdAt DateTime @default(now())
33 | updatedAt DateTime @updatedAt
34 | score Int @default(0)
35 | ActivityLog ActivityLog[]
36 | submissions Submission[]
37 | scores Score[]
38 | teamHints TeamHint[]
39 | members User[]
40 | pointHistory TeamPointHistory[]
41 | }
42 |
43 | model Challenge {
44 | id String @id @default(cuid())
45 | title String
46 | description String
47 | points Int
48 | flag String?
49 | flags ChallengeFlag[]
50 | multipleFlags Boolean @default(false)
51 | category String
52 | difficulty String
53 | isActive Boolean @default(true)
54 | isLocked Boolean @default(false)
55 | link String? // Optional link field for challenges
56 | solveExplanation String? // Optional explanation shown after solving
57 | createdAt DateTime @default(now())
58 | updatedAt DateTime @updatedAt
59 | unlockConditions UnlockCondition[]
60 | files ChallengeFile[]
61 | hints Hint[]
62 | submissions Submission[]
63 | scores Score[]
64 | }
65 |
66 | model Submission {
67 | id String @id @default(cuid())
68 | flag String
69 | isCorrect Boolean
70 | createdAt DateTime @default(now())
71 | updatedAt DateTime @updatedAt
72 | userId String
73 | challengeId String
74 | flagId String?
75 | teamId String
76 | team Team @relation(fields: [teamId], references: [id])
77 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
78 | user User @relation(fields: [userId], references: [id])
79 | challengeFlag ChallengeFlag? @relation(fields: [flagId], references: [id])
80 |
81 | @@index([challengeId])
82 | @@index([flagId])
83 | }
84 |
85 | model Announcement {
86 | id String @id @default(cuid())
87 | title String
88 | content String
89 | createdAt DateTime @default(now())
90 | updatedAt DateTime @updatedAt
91 | }
92 |
93 | model ActivityLog {
94 | id String @id @default(cuid())
95 | type String
96 | description String
97 | teamId String?
98 | createdAt DateTime @default(now())
99 | team Team? @relation(fields: [teamId], references: [id])
100 | }
101 |
102 | model GameConfig {
103 | id String @id @default(cuid())
104 | startTime DateTime
105 | endTime DateTime?
106 | isActive Boolean @default(true)
107 | createdAt DateTime @default(now())
108 | updatedAt DateTime @updatedAt
109 | }
110 |
111 | model ChallengeFile {
112 | id String @id @default(cuid())
113 | name String
114 | path String
115 | size Int
116 | createdAt DateTime @default(now())
117 | updatedAt DateTime @updatedAt
118 | challengeId String
119 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
120 | }
121 |
122 | model Hint {
123 | id String @id @default(cuid())
124 | content String
125 | cost Int @default(0)
126 | challengeId String
127 | createdAt DateTime @default(now())
128 | updatedAt DateTime @updatedAt
129 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
130 | teamHints TeamHint[]
131 | }
132 |
133 | model TeamHint {
134 | id String @id @default(cuid())
135 | teamId String
136 | hintId String
137 | createdAt DateTime @default(now())
138 | updatedAt DateTime @updatedAt
139 | hint Hint @relation(fields: [hintId], references: [id], onDelete: Cascade)
140 | team Team @relation(fields: [teamId], references: [id])
141 |
142 | @@unique([teamId, hintId])
143 | }
144 |
145 | model SiteConfig {
146 | id String @id @default(cuid())
147 | key String @unique
148 | value String
149 | updatedAt DateTime @updatedAt
150 | }
151 |
152 | model Score {
153 | id String @id @default(cuid())
154 | points Int
155 | createdAt DateTime @default(now())
156 | updatedAt DateTime @updatedAt
157 | userId String
158 | teamId String
159 | challengeId String
160 | team Team @relation(fields: [teamId], references: [id])
161 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
162 | user User @relation(fields: [userId], references: [id])
163 |
164 | @@index([teamId])
165 | @@index([userId])
166 | @@index([challengeId])
167 | }
168 |
169 | enum UnlockConditionType {
170 | CHALLENGE_SOLVED
171 | TIME_REMAINDER // Unlocks when time remaining is LESS than the threshold
172 | }
173 |
174 | model UnlockCondition {
175 | id String @id @default(cuid())
176 | challengeId String
177 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) // Add onDelete Cascade
178 | type UnlockConditionType
179 | // Fields specific to condition type
180 | requiredChallengeId String? // For CHALLENGE_SOLVED (ID of challenge that must be solved)
181 | timeThresholdSeconds Int? // For TIME_REMAINDER (e.g., 3600 for 1 hour remaining)
182 | createdAt DateTime @default(now())
183 | updatedAt DateTime @updatedAt
184 |
185 | @@index([challengeId])
186 | @@index([requiredChallengeId]) // Index potential foreign key
187 | }
188 |
189 | // Add new model for challenge flags
190 | model ChallengeFlag {
191 | id String @id @default(cuid())
192 | flag String
193 | points Int
194 | challengeId String
195 | createdAt DateTime @default(now())
196 | updatedAt DateTime @updatedAt
197 | challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
198 | submissions Submission[]
199 |
200 | @@index([challengeId])
201 | }
202 |
203 | model TeamPointHistory {
204 | id String @id @default(cuid())
205 | teamId String
206 | points Int // The point change (positive or negative)
207 | totalPoints Int // The team's total points after this change
208 | reason String // e.g., "HINT_PURCHASE", "CHALLENGE_SOLVE"
209 | metadata String? // Additional JSON data about the change
210 | createdAt DateTime @default(now())
211 | team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
212 |
213 | @@index([teamId])
214 | @@index([createdAt])
215 | }
216 |
--------------------------------------------------------------------------------
/prisma/generated/client/wasm.d.ts:
--------------------------------------------------------------------------------
1 | export * from "./index"
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "sqlite"
4 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from './generated/client';
2 | import dotenv from 'dotenv';
3 |
4 | dotenv.config();
5 |
6 | const prisma = new PrismaClient();
7 |
8 | async function main() {
9 | // Create seed data
10 | console.log('Starting database seed...');
11 |
12 | // Create game config
13 | await prisma.gameConfig.create({
14 | data: {
15 | startTime: new Date(),
16 | endTime: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now
17 | isActive: true,
18 | },
19 | });
20 |
21 | console.log('Game config created successfully');
22 |
23 | // Create initial site configurations
24 | const siteConfigs: { key: string; value: string }[] = [
25 | {
26 | key: 'homepage_title',
27 | value: 'Welcome to Orbital CTF'
28 | },
29 | {
30 | key: 'site_title',
31 | value: 'Orbital CTF'
32 | },
33 | {
34 | key: 'homepage_subtitle',
35 | value: '80s retro ui, space-themed, batteries included CTF platform.'
36 | },
37 | {
38 | key: 'rules_text',
39 | value: `
40 | Following actions are prohibited, unless explicitly told otherwise by event Admins.
41 |
42 | ### Rule 1 - Cooperation
43 |
44 | No cooperation between teams with independent accounts. Sharing of keys or providing revealing hints to other teams is cheating, don't do it.
45 |
46 | ### Rule 2 - Attacking Scoreboard
47 |
48 | No attacking the competition infrastructure. If bugs or vulns are found, please alert the competition organizers immediately.
49 |
50 | ### Rule 3 - Sabotage
51 |
52 | Absolutely no sabotaging of other competing teams, or in any way hindering their independent progress.
53 |
54 | ### Rule 4 - Bruteforcing
55 |
56 | No brute forcing of challenge flag/ keys against the scoring site.
57 |
58 | ### Rule 5 - Denial Of Service
59 |
60 | DoSing the CTF platform or any of the challenges is forbidden.
61 |
62 | ##### Legal Disclaimer
63 |
64 | By participating in the contest, you agree to release the organizer, and the hosting organization from any and all liability, claims or actions of any kind whatsoever for injuries, damages or losses to persons and property which may be sustained in connection with the contest. You acknowledge and agree that Facebook et al is not responsible for technical, hardware or software failures, or other errors or problems which may occur in connection with the contest.
65 |
66 | If you have any questions about what is or is not allowed, please ask an organizer.
67 |
68 | Have fun!`
69 | }
70 | ];
71 |
72 | for (const config of siteConfigs) {
73 | await prisma.siteConfig.upsert({
74 | where: { key: config.key },
75 | update: { value: config.value },
76 | create: {
77 | key: config.key,
78 | value: config.value
79 | }
80 | });
81 | }
82 |
83 | console.log('Site configurations created successfully');
84 | console.log('Database has been seeded. 🌱');
85 | }
86 |
87 | main()
88 | .catch((e) => {
89 | console.error(e);
90 | process.exit(1);
91 | })
92 | .finally(async () => {
93 | await prisma.$disconnect();
94 | });
--------------------------------------------------------------------------------
/public/SquadaOne-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asynchronous-x/orbital-ctf/126e8d84ad20640140990e0caaa7aea73d78c6e6/public/SquadaOne-Regular.ttf
--------------------------------------------------------------------------------
/src/app/admin/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useSession } from 'next-auth/react';
5 | import { useRouter } from 'next/navigation';
6 |
7 | import PageLayout from '@/components/layouts/PageLayout';
8 | import LoadingSpinner from '@/components/common/LoadingSpinner';
9 | import TabButton from '@/components/common/TabButton';
10 | import ChallengesTab from '@/components/admin/ChallengesTab';
11 | import UsersTab from '@/components/admin/UsersTab';
12 | import TeamsTab from '@/components/admin/TeamsTab';
13 | import AnnouncementsTab from '@/components/admin/AnnouncementsTab';
14 | import SubmissionsTab from '@/components/admin/SubmissionsTab';
15 | import GameConfigurationTab from '@/components/admin/GameConfigurationTab';
16 | import SiteConfigurationTab from '@/components/admin/SiteConfigurationTab';
17 | import { Tab } from '@/types';
18 |
19 | const TABS = [
20 | { id: 'challenges' as Tab, label: 'Challenges' },
21 | { id: 'users' as Tab, label: 'Users' },
22 | { id: 'teams' as Tab, label: 'Teams' },
23 | { id: 'submissions' as Tab, label: 'Submissions' },
24 | { id: 'announcements' as Tab, label: 'Announcements' },
25 | { id: 'siteconfig' as Tab, label: 'Site Configuration' },
26 | { id: 'configuration' as Tab, label: 'Game Configuration' },
27 | ] as const;
28 |
29 | export default function AdminDashboard() {
30 | const { status } = useSession();
31 | const router = useRouter();
32 | const [activeTab, setActiveTab] = useState('challenges');
33 |
34 | useEffect(() => {
35 | if (status === 'unauthenticated') {
36 | router.push('/auth/signin');
37 | }
38 | }, [status, router]);
39 |
40 | if (status === 'loading') {
41 | return ;
42 | }
43 |
44 | return (
45 |
46 |
47 | {/* Navigation Tabs - Horizontal scroll on mobile */}
48 |
49 | {TABS.map(tab => (
50 | setActiveTab(tab.id)}
54 | >
55 | {tab.label}
56 |
57 | ))}
58 |
59 |
60 | {/* Content Area - Conditionally render the active tab */}
61 |
62 | {activeTab === 'challenges' &&
}
63 | {activeTab === 'users' &&
}
64 | {activeTab === 'teams' &&
}
65 | {activeTab === 'submissions' &&
}
66 | {activeTab === 'announcements' &&
}
67 | {activeTab === 'siteconfig' &&
}
68 | {activeTab === 'configuration' &&
}
69 |
70 |
71 |
72 | );
73 | }
--------------------------------------------------------------------------------
/src/app/api/activity/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 |
4 | export async function GET() {
5 | try {
6 | const activities = await prisma.activityLog.findMany({
7 | orderBy: {
8 | createdAt: 'desc',
9 | },
10 | take: 10,
11 | include: {
12 | team: true,
13 | },
14 | });
15 |
16 | return NextResponse.json(activities);
17 | } catch (error) {
18 | console.error('Error fetching activity logs:', error);
19 | return NextResponse.json(
20 | { error: 'Internal server error' },
21 | { status: 500 }
22 | );
23 | }
24 | }
--------------------------------------------------------------------------------
/src/app/api/admin/challenges/bulk/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | async function isAdmin() {
7 | const session = await getServerSession(authOptions);
8 | return session?.user?.isAdmin ?? false;
9 | }
10 |
11 | // Export all challenges
12 | export async function GET() {
13 | if (!await isAdmin()) {
14 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
15 | }
16 |
17 | try {
18 | const challenges = await prisma.challenge.findMany({
19 | include: {
20 | files: true,
21 | hints: true,
22 | flags: true,
23 | unlockConditions: true
24 | }
25 | });
26 |
27 | return NextResponse.json(challenges);
28 | } catch (error) {
29 | console.error('Error exporting challenges:', error);
30 | return NextResponse.json({ error: 'Error exporting challenges' }, { status: 500 });
31 | }
32 | }
33 |
34 | // Import challenges
35 | export async function POST(req: Request) {
36 | if (!await isAdmin()) {
37 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
38 | }
39 |
40 | try {
41 | const challenges = await req.json();
42 |
43 | // Validate that challenges is an array
44 | if (!Array.isArray(challenges)) {
45 | return NextResponse.json({ error: 'Invalid format - expected array of challenges' }, { status: 400 });
46 | }
47 |
48 | // Begin transaction to ensure all-or-nothing import
49 | const result = await prisma.$transaction(async (tx) => {
50 | const imported = await Promise.all(challenges.map(async (challenge) => {
51 | const { files, hints, flags, unlockConditions, ...challengeData } = challenge;
52 |
53 | return await tx.challenge.create({
54 | data: {
55 | ...challengeData,
56 | files: files ? {
57 | create: files.map((file: { name: string; path: string; size: number }) => ({
58 | name: file.name,
59 | path: file.path,
60 | size: file.size
61 | }))
62 | } : undefined,
63 | hints: hints ? {
64 | create: hints.map((hint: { content: string; cost: number }) => ({
65 | content: hint.content,
66 | cost: hint.cost
67 | }))
68 | } : undefined,
69 | flags: flags && challengeData.multipleFlags ? {
70 | create: flags.map((flag: { flag: string; points: number }) => ({
71 | flag: flag.flag,
72 | points: flag.points
73 | }))
74 | } : undefined,
75 | unlockConditions: unlockConditions ? {
76 | create: unlockConditions.map((cond: { type: string; requiredChallengeId?: string; timeThresholdSeconds?: number }) => ({
77 | type: cond.type,
78 | requiredChallengeId: cond.requiredChallengeId,
79 | timeThresholdSeconds: cond.timeThresholdSeconds
80 | }))
81 | } : undefined
82 | },
83 | include: {
84 | files: true,
85 | hints: true,
86 | flags: true,
87 | unlockConditions: true
88 | }
89 | });
90 | }));
91 |
92 | return imported;
93 | });
94 |
95 | return NextResponse.json(result);
96 | } catch (error) {
97 | console.error('Error importing challenges:', error);
98 | return NextResponse.json({ error: 'Error importing challenges' }, { status: 500 });
99 | }
100 | }
--------------------------------------------------------------------------------
/src/app/api/admin/challenges/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | async function isAdmin() {
7 | const session = await getServerSession(authOptions);
8 | return session?.user?.isAdmin === true;
9 | }
10 |
11 | export async function POST(req: Request) {
12 | if (!await isAdmin()) {
13 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
14 | }
15 |
16 | try {
17 | const { title, description, category, points, flag, flags, multipleFlags, difficulty, isLocked, files, hints, unlockConditions, link, solveExplanation } = await req.json();
18 |
19 | const challenge = await prisma.challenge.create({
20 | data: {
21 | title,
22 | description,
23 | category,
24 | points,
25 | flag: multipleFlags ? undefined : flag,
26 | multipleFlags: multipleFlags || false,
27 | flags: multipleFlags && flags ? {
28 | create: flags.map((flag: { flag: string; points: number }) => ({
29 | flag: flag.flag,
30 | points: flag.points
31 | }))
32 | } : undefined,
33 | difficulty,
34 | isLocked: isLocked || false,
35 | link,
36 | solveExplanation,
37 | files: files ? {
38 | create: files.map((file: { name: string; path: string; size: number }) => ({
39 | name: file.name,
40 | path: file.path,
41 | size: file.size
42 | }))
43 | } : undefined,
44 | hints: hints ? {
45 | create: hints.map((hint: { content: string; cost: number }) => ({
46 | content: hint.content,
47 | cost: hint.cost
48 | }))
49 | } : undefined,
50 | unlockConditions: unlockConditions ? {
51 | create: unlockConditions.map((cond: { type: string; requiredChallengeId?: string; timeThresholdSeconds?: number }) => ({
52 | type: cond.type,
53 | requiredChallengeId: cond.requiredChallengeId,
54 | timeThresholdSeconds: cond.timeThresholdSeconds
55 | }))
56 | } : undefined
57 | },
58 | include: {
59 | files: true,
60 | hints: true,
61 | flags: true,
62 | unlockConditions: true
63 | }
64 | });
65 |
66 | return NextResponse.json(challenge, { status: 201 });
67 | } catch (error) {
68 | console.error('Error creating challenge:', error);
69 | return NextResponse.json(
70 | { error: 'Error creating challenge' },
71 | { status: 500 }
72 | );
73 | }
74 | }
75 |
76 | export async function GET() {
77 | if (!await isAdmin()) {
78 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
79 | }
80 |
81 | try {
82 | const challenges = await prisma.challenge.findMany({
83 | include: {
84 | files: true,
85 | hints: true,
86 | flags: true,
87 | unlockConditions: true
88 | }
89 | });
90 | return NextResponse.json(challenges);
91 | } catch (error) {
92 | console.error('Error fetching challenges:', error);
93 | return NextResponse.json(
94 | { error: 'Error fetching challenges' },
95 | { status: 500 }
96 | );
97 | }
98 | }
99 |
100 | export async function DELETE(req: Request) {
101 | if (!await isAdmin()) {
102 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
103 | }
104 |
105 | try {
106 | const { id } = await req.json();
107 |
108 | await prisma.challenge.delete({
109 | where: { id },
110 | });
111 |
112 | return NextResponse.json({ message: 'Challenge deleted successfully' });
113 | } catch (error) {
114 | console.error('Error deleting challenge:', error);
115 | return NextResponse.json(
116 | { error: 'Error deleting challenge' },
117 | { status: 500 }
118 | );
119 | }
120 | }
121 |
122 | export async function PATCH(req: Request) {
123 | if (!await isAdmin()) {
124 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
125 | }
126 |
127 | try {
128 | const { id, title, description, category, points, flag, flags, multipleFlags, difficulty, isActive, isLocked, files, hints, unlockConditions, link, solveExplanation } = await req.json();
129 |
130 | // Get the current challenge state to check if it was previously locked
131 | const currentChallenge = await prisma.challenge.findUnique({
132 | where: { id },
133 | select: { isLocked: true, title: true }
134 | });
135 |
136 | const challenge = await prisma.challenge.update({
137 | where: { id },
138 | data: {
139 | title,
140 | description,
141 | category,
142 | points,
143 | flag: multipleFlags ? undefined : flag,
144 | multipleFlags,
145 | flags: flags ? {
146 | deleteMany: {},
147 | create: flags.map((flag: { flag: string; points: number }) => ({
148 | flag: flag.flag,
149 | points: flag.points
150 | }))
151 | } : undefined,
152 | difficulty,
153 | isActive,
154 | isLocked,
155 | link,
156 | solveExplanation,
157 | unlockConditions: unlockConditions ? {
158 | deleteMany: {},
159 | create: unlockConditions.map((cond: { type: string; requiredChallengeId?: string; timeThresholdSeconds?: number }) => ({
160 | type: cond.type,
161 | requiredChallengeId: cond.requiredChallengeId,
162 | timeThresholdSeconds: cond.timeThresholdSeconds
163 | }))
164 | } : {
165 | deleteMany: {}
166 | },
167 | files: files ? {
168 | deleteMany: {},
169 | create: files.map((file: { name: string; path: string; size: number }) => ({
170 | name: file.name,
171 | path: file.path,
172 | size: file.size
173 | }))
174 | } : undefined,
175 | hints: hints ? {
176 | deleteMany: {},
177 | create: hints.map((hint: { content: string; cost: number }) => ({
178 | content: hint.content,
179 | cost: hint.cost
180 | }))
181 | } : undefined
182 | },
183 | include: {
184 | files: true,
185 | hints: true,
186 | flags: true,
187 | unlockConditions: true
188 | }
189 | });
190 |
191 | // Log activity if challenge was unlocked
192 | if (currentChallenge?.isLocked && !isLocked) {
193 | await prisma.activityLog.create({
194 | data: {
195 | type: 'CHALLENGE_UNLOCKED',
196 | description: `Challenge "${challenge.title}" has been unlocked by an admin`,
197 | },
198 | });
199 | }
200 |
201 | return NextResponse.json(challenge);
202 | } catch (error) {
203 | console.error('Error updating challenge:', error);
204 | return NextResponse.json(
205 | { error: 'Error updating challenge' },
206 | { status: 500 }
207 | );
208 | }
209 | }
--------------------------------------------------------------------------------
/src/app/api/admin/submissions/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | export async function GET() {
7 | try {
8 | const session = await getServerSession(authOptions);
9 | if (!session?.user?.isAdmin) {
10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
11 | }
12 |
13 | const submissions = await prisma.submission.findMany({
14 | include: {
15 | user: { select: { id: true, alias: true } },
16 | team: { select: { id: true, name: true, color: true, icon: true } },
17 | challenge: { select: { id: true, title: true } }
18 | },
19 | orderBy: { createdAt: 'desc' }
20 | });
21 |
22 | return NextResponse.json(submissions);
23 | } catch (error) {
24 | console.error('Error fetching submissions:', error);
25 | return NextResponse.json(
26 | { error: 'Internal server error' },
27 | { status: 500 }
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/api/admin/teams/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 | import { ApiError } from '@/types';
6 | export async function GET() {
7 | try {
8 | const session = await getServerSession(authOptions);
9 |
10 | if (!session?.user?.isAdmin) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
12 | }
13 |
14 | const teams = await prisma.team.findMany({
15 | include: {
16 | members: {
17 | select: {
18 | id: true,
19 | alias: true,
20 | name: true,
21 | isAdmin: true,
22 | isTeamLeader: true,
23 | },
24 | },
25 | },
26 | });
27 |
28 | return NextResponse.json(teams);
29 | } catch (error) {
30 | console.error('Error fetching teams:', error);
31 | return NextResponse.json(
32 | { error: 'Internal server error' },
33 | { status: 500 }
34 | );
35 | }
36 | }
37 |
38 | export async function DELETE(req: Request) {
39 | try {
40 | const session = await getServerSession(authOptions);
41 |
42 | if (!session?.user?.isAdmin) {
43 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
44 | }
45 |
46 | const { id } = await req.json();
47 |
48 | if (!id) {
49 | return NextResponse.json({ error: 'Team ID is required' }, { status: 400 });
50 | }
51 |
52 | // First, remove all team members by setting their teamId to null
53 | await prisma.user.updateMany({
54 | where: { teamId: id },
55 | data: { teamId: null, isTeamLeader: false }
56 | });
57 |
58 | // Then delete the team
59 | await prisma.team.delete({
60 | where: { id }
61 | });
62 |
63 | return NextResponse.json({ message: 'Team deleted successfully' });
64 | } catch (error) {
65 | console.error('Error deleting team:', error);
66 | return NextResponse.json(
67 | { error: 'Internal server error' },
68 | { status: 500 }
69 | );
70 | }
71 | }
72 |
73 | export async function PATCH(req: Request) {
74 | try {
75 | const session = await getServerSession(authOptions);
76 |
77 | if (!session?.user?.isAdmin) {
78 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
79 | }
80 |
81 | const { id, name, icon, color } = await req.json();
82 |
83 | if (!id) {
84 | return NextResponse.json({ error: 'Team ID is required' }, { status: 400 });
85 | }
86 |
87 | if (name !== undefined && !name.trim()) {
88 | return NextResponse.json({ error: 'Team name cannot be empty' }, { status: 400 });
89 | }
90 |
91 | const updateData: { name?: string; icon?: string; color?: string } = {};
92 | if (name !== undefined) updateData.name = name;
93 | if (icon !== undefined) updateData.icon = icon;
94 | if (color !== undefined) updateData.color = color;
95 |
96 | if (Object.keys(updateData).length === 0) {
97 | return NextResponse.json({ error: 'No update data provided' }, { status: 400 });
98 | }
99 |
100 | const updatedTeam = await prisma.team.update({
101 | where: { id },
102 | data: updateData,
103 | include: {
104 | members: {
105 | select: {
106 | id: true,
107 | alias: true,
108 | name: true,
109 | isAdmin: true,
110 | isTeamLeader: true,
111 | },
112 | },
113 | },
114 | });
115 |
116 | return NextResponse.json(updatedTeam);
117 | } catch (error) {
118 | const err = error as ApiError;
119 | console.error('Error updating team:', err);
120 | if (err.code === 'P2002' && err.meta?.target?.includes('name')) {
121 | return NextResponse.json(
122 | { error: 'Team name already exists' },
123 | { status: 409 }
124 | );
125 | }
126 | return NextResponse.json(
127 | { error: 'Internal server error' },
128 | { status: 500 }
129 | );
130 | }
131 | }
--------------------------------------------------------------------------------
/src/app/api/admin/users/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 | import { ApiError } from '@/types';
6 |
7 | export async function GET() {
8 | try {
9 | const session = await getServerSession(authOptions);
10 |
11 | if (!session?.user?.isAdmin) {
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
13 | }
14 |
15 | const users = await prisma.user.findMany({
16 | select: {
17 | id: true,
18 | alias: true,
19 | name: true,
20 | isAdmin: true,
21 | teamId: true,
22 | isTeamLeader: true,
23 | },
24 | });
25 |
26 | return NextResponse.json(users);
27 | } catch (error) {
28 | console.error('Error fetching users:', error);
29 | return NextResponse.json(
30 | { error: 'Internal server error' },
31 | { status: 500 }
32 | );
33 | }
34 | }
35 |
36 | export async function DELETE(request: Request) {
37 | try {
38 | const session = await getServerSession(authOptions);
39 |
40 | if (!session?.user?.isAdmin) {
41 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
42 | }
43 |
44 | const { id } = await request.json();
45 |
46 | if (!id) {
47 | return NextResponse.json(
48 | { error: 'User ID is required' },
49 | { status: 400 }
50 | );
51 | }
52 |
53 | // Check if user exists
54 | const user = await prisma.user.findUnique({
55 | where: { id },
56 | });
57 |
58 | if (!user) {
59 | return NextResponse.json(
60 | { error: 'User not found' },
61 | { status: 404 }
62 | );
63 | }
64 |
65 | // Delete the user
66 | await prisma.user.delete({
67 | where: { id },
68 | });
69 |
70 | return NextResponse.json({ message: 'User deleted successfully' });
71 | } catch (error) {
72 | console.error('Error deleting user:', error);
73 | return NextResponse.json(
74 | { error: 'Internal server error' },
75 | { status: 500 }
76 | );
77 | }
78 | }
79 |
80 | export async function PATCH(req: Request) {
81 | try {
82 | const session = await getServerSession(authOptions);
83 |
84 | if (!session?.user?.isAdmin) {
85 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
86 | }
87 |
88 | const { id, alias, name, teamId, isTeamLeader } = await req.json();
89 |
90 | if (!id) {
91 | return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
92 | }
93 |
94 | if (alias !== undefined && !alias.trim()) {
95 | return NextResponse.json({ error: 'Alias cannot be empty' }, { status: 400 });
96 | }
97 |
98 | if (name !== undefined && !name.trim()) {
99 | return NextResponse.json({ error: 'Name cannot be empty' }, { status: 400 });
100 | }
101 |
102 | const updateData: {
103 | alias?: string;
104 | name?: string;
105 | teamId?: string | null;
106 | isTeamLeader?: boolean;
107 | } = {};
108 |
109 | if (alias !== undefined) updateData.alias = alias.trim();
110 | if (name !== undefined) updateData.name = name.trim();
111 | if (teamId !== undefined) updateData.teamId = teamId;
112 | if (isTeamLeader !== undefined) updateData.isTeamLeader = isTeamLeader;
113 |
114 | if (Object.keys(updateData).length === 0) {
115 | return NextResponse.json({ error: 'No update data provided' }, { status: 400 });
116 | }
117 |
118 | const updatedUser = await prisma.user.update({
119 | where: { id },
120 | data: updateData,
121 | });
122 |
123 | return NextResponse.json(updatedUser);
124 | } catch (error) {
125 | const err = error as ApiError;
126 | console.error('Error updating user:', err);
127 | if (err.code === 'P2002' && err.meta?.target?.includes('alias')) {
128 | return NextResponse.json(
129 | { error: 'Alias already exists' },
130 | { status: 409 }
131 | );
132 | }
133 | return NextResponse.json(
134 | { error: 'Internal server error' },
135 | { status: 500 }
136 | );
137 | }
138 | }
--------------------------------------------------------------------------------
/src/app/api/announcements/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getServerSession } from 'next-auth';
3 | import { authOptions } from '@/lib/auth';
4 | import { prisma } from '@/lib/prisma';
5 |
6 | export async function DELETE(
7 | request: Request,
8 | { params }: { params: Promise<{ id: string }> }
9 | ) {
10 | const { id } = await params;
11 | try {
12 | const session = await getServerSession(authOptions);
13 | if (!session?.user?.alias) {
14 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
15 | }
16 |
17 | const user = await prisma.user.findUnique({
18 | where: { alias: session.user.alias },
19 | select: { isAdmin: true },
20 | });
21 |
22 | if (!user || !user.isAdmin) {
23 | return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
24 | }
25 |
26 | const announcement = await prisma.announcement.findUnique({
27 | where: { id: id },
28 | });
29 |
30 | if (!announcement) {
31 | return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
32 | }
33 |
34 | await prisma.announcement.delete({
35 | where: { id: id },
36 | });
37 |
38 | return NextResponse.json({ success: true });
39 | } catch (error) {
40 | console.error('Error deleting announcement:', error);
41 | return NextResponse.json(
42 | { error: 'Internal server error' },
43 | { status: 500 }
44 | );
45 | }
46 | }
--------------------------------------------------------------------------------
/src/app/api/announcements/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getServerSession } from 'next-auth';
3 | import { authOptions } from '@/lib/auth';
4 | import { prisma } from '@/lib/prisma';
5 |
6 | export async function GET() {
7 | try {
8 | const announcements = await prisma.announcement.findMany({
9 | orderBy: {
10 | createdAt: 'desc',
11 | },
12 | });
13 |
14 | return NextResponse.json(announcements);
15 | } catch (error) {
16 | console.error('Error fetching announcements:', error);
17 | return NextResponse.json(
18 | { error: 'Internal server error' },
19 | { status: 500 }
20 | );
21 | }
22 | }
23 |
24 | export async function POST(request: Request) {
25 | try {
26 | const session = await getServerSession(authOptions);
27 | if (!session?.user?.alias) {
28 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
29 | }
30 |
31 | const user = await prisma.user.findUnique({
32 | where: { alias: session.user.alias },
33 | select: { isAdmin: true },
34 | });
35 |
36 | if (!user || !user.isAdmin) {
37 | return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
38 | }
39 |
40 | const { title, content } = await request.json();
41 |
42 | if (!title || !content) {
43 | return NextResponse.json(
44 | { error: 'Title and content are required' },
45 | { status: 400 }
46 | );
47 | }
48 |
49 | const announcement = await prisma.announcement.create({
50 | data: {
51 | title,
52 | content,
53 | },
54 | });
55 |
56 | return NextResponse.json(announcement);
57 | } catch (error) {
58 | console.error('Error creating announcement:', error);
59 | return NextResponse.json(
60 | { error: 'Internal server error' },
61 | { status: 500 }
62 | );
63 | }
64 | }
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 | import { authOptions } from '@/lib/auth';
3 |
4 | const handler = NextAuth(authOptions);
5 |
6 | export { handler as GET, handler as POST };
--------------------------------------------------------------------------------
/src/app/api/auth/signup/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import bcrypt from 'bcryptjs';
4 |
5 | export async function POST(req: Request) {
6 | try {
7 | const { alias, password, name, teamName, teamCode, teamOption, teamIcon, teamColor } = await req.json();
8 |
9 | // Enforce max length constraints
10 | if (alias && alias.length > 32) {
11 | return NextResponse.json({ error: 'Alias must be at most 32 characters' }, { status: 400 });
12 | }
13 | if (password && password.length > 128) {
14 | return NextResponse.json({ error: 'Password must be at most 128 characters' }, { status: 400 });
15 | }
16 | if (name && name.length > 48) {
17 | return NextResponse.json({ error: 'Name must be at most 48 characters' }, { status: 400 });
18 | }
19 | if (teamName && teamName.length > 32) {
20 | return NextResponse.json({ error: 'Team name must be at most 32 characters' }, { status: 400 });
21 | }
22 | if (teamCode && teamCode.length > 12) {
23 | return NextResponse.json({ error: 'Team code must be at most 12 characters' }, { status: 400 });
24 | }
25 |
26 | // Check if user already exists
27 | const existingUser = await prisma.user.findFirst({
28 | where: {
29 | alias: alias
30 | }
31 | });
32 |
33 | if (existingUser) {
34 | return NextResponse.json(
35 | { error: 'User already exists' },
36 | { status: 400 }
37 | );
38 | }
39 |
40 | // Check if this is the first user
41 | const userCount = await prisma.user.count();
42 | const isAdmin = userCount === 0;
43 |
44 | const hashedPassword = await bcrypt.hash(password, 10);
45 |
46 | let teamId = null;
47 | let isTeamLeader = false;
48 |
49 | if (teamOption === 'create') {
50 | // Create new team
51 | if (!teamName) {
52 | return NextResponse.json(
53 | { error: 'Team name is required when creating a new team' },
54 | { status: 400 }
55 | );
56 | }
57 |
58 | // Generate a random 6-character team code
59 | const code = Math.random().toString(36).substring(2, 8).toUpperCase();
60 |
61 | const team = await prisma.team.create({
62 | data: {
63 | name: teamName,
64 | code: code,
65 | icon: teamIcon || 'GiSpaceship',
66 | color: teamColor || '#ffffff'
67 | },
68 | });
69 |
70 | teamId = team.id;
71 | isTeamLeader = true;
72 | } else if (teamOption === 'join') {
73 | // Join existing team
74 | if (!teamCode) {
75 | return NextResponse.json(
76 | { error: 'Team code is required when joining a team' },
77 | { status: 400 }
78 | );
79 | }
80 |
81 | const team = await prisma.team.findFirst({
82 | where: {
83 | code: teamCode
84 | }
85 | });
86 |
87 | if (!team) {
88 | return NextResponse.json(
89 | { error: 'Invalid team code' },
90 | { status: 400 }
91 | );
92 | }
93 |
94 | teamId = team.id;
95 | }
96 |
97 | // Create the user
98 | const user = await prisma.user.create({
99 | data: {
100 | alias,
101 | password: hashedPassword,
102 | name,
103 | isAdmin,
104 | teamId,
105 | isTeamLeader,
106 | },
107 | });
108 |
109 | // Return success with credentials for auto-login
110 | return NextResponse.json({
111 | success: true,
112 | user: {
113 | alias: user.alias,
114 | password: password // Send back original password for auto-login
115 | }
116 | });
117 | } catch (error) {
118 | console.error('Error creating user:', error);
119 | return NextResponse.json(
120 | { error: 'Internal server error' },
121 | { status: 500 }
122 | );
123 | }
124 | }
--------------------------------------------------------------------------------
/src/app/api/challenges/[challengeId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 | import { evaluateUnlockConditions } from '@/lib/challenges';
6 |
7 | export async function GET(
8 | request: Request,
9 | { params }: { params: Promise<{ challengeId: string }> }
10 | ) {
11 | const { challengeId } = await params;
12 | try {
13 | const session = await getServerSession(authOptions);
14 |
15 | const challenge = await prisma.challenge.findUnique({
16 | where: {
17 | id: challengeId
18 | },
19 | include: {
20 | files: true,
21 | hints: true,
22 | unlockConditions: true,
23 | flags: true,
24 | submissions: {
25 | where: {
26 | isCorrect: true,
27 | teamId: session?.user?.teamId || undefined
28 | },
29 | select: {
30 | teamId: true,
31 | flagId: true
32 | }
33 | }
34 | }
35 | });
36 |
37 | if (!challenge) {
38 | return NextResponse.json(
39 | { error: 'Challenge not found' },
40 | { status: 404 }
41 | );
42 | }
43 |
44 | // Fetch all correct submissions for the team to check CHALLENGE_SOLVED conditions
45 | const teamSolves = session?.user?.teamId ? await prisma.submission.findMany({
46 | where: {
47 | teamId: session.user.teamId,
48 | isCorrect: true,
49 | },
50 | select: {
51 | challengeId: true,
52 | }
53 | }) : [];
54 |
55 | const solvedChallengeIds = new Set(teamSolves.map(solve => solve.challengeId));
56 |
57 | // Fetch Game Config (assuming only one active config)
58 | const gameConfig = await prisma.gameConfig.findFirst({
59 | where: { isActive: true },
60 | });
61 |
62 | // Evaluate unlock conditions
63 | const { isUnlocked, reason } = evaluateUnlockConditions(
64 | challenge.unlockConditions,
65 | solvedChallengeIds,
66 | gameConfig
67 | );
68 |
69 | // Decide what to return based on unlock status
70 | if (!isUnlocked) {
71 | // Option 1: Return minimal data for locked challenges
72 | return NextResponse.json({
73 | id: challenge.id,
74 | title: challenge.title,
75 | points: challenge.points,
76 | category: challenge.category,
77 | difficulty: challenge.difficulty,
78 | isLocked: true,
79 | unlockReason: reason
80 | });
81 | // Option 2: Return 403 Forbidden (or 404 Not Found)
82 | // return NextResponse.json({ error: 'Challenge locked', reason }, { status: 403 });
83 | }
84 |
85 | // Get set of solved flag IDs for this challenge
86 | const solvedFlagIds = new Set(challenge.submissions.map(sub => sub.flagId));
87 |
88 | // Determine which hints have been purchased by the user's team
89 | const teamHints = session?.user?.teamId ? await prisma.teamHint.findMany({
90 | where: {
91 | teamId: session.user.teamId,
92 | hint: {
93 | challengeId,
94 | },
95 | },
96 | select: {
97 | hintId: true,
98 | },
99 | }) : [];
100 |
101 | const purchasedHintIds = new Set(teamHints.map(th => th.hintId));
102 |
103 | const sanitizedHints = challenge.hints.map(hint => {
104 | const isPurchased = purchasedHintIds.has(hint.id);
105 | return {
106 | ...hint,
107 | content: isPurchased ? hint.content : undefined,
108 | isPurchased,
109 | };
110 | });
111 |
112 | // Check if user's team has solved this challenge
113 | const userTeamSolved = session?.user?.teamId && challenge.submissions.some(sub => sub.teamId === session.user.teamId);
114 |
115 | // Transform the challenge to include isSolved and solvedByTeamId
116 | const transformedChallenge = {
117 | ...challenge,
118 | flag: undefined,
119 | flags: challenge.multipleFlags ? challenge.flags.map(flag => ({
120 | id: flag.id,
121 | points: flag.points,
122 | isSolved: solvedFlagIds.has(flag.id)
123 | })) : undefined,
124 | hints: sanitizedHints,
125 | isSolved: challenge.multipleFlags
126 | ? solvedFlagIds.size === challenge.flags.length
127 | : challenge.submissions.length > 0,
128 | solvedByTeamId: challenge.submissions[0]?.teamId,
129 | submissions: undefined,
130 | unlockConditions: undefined,
131 | isUnlocked: true,
132 | solveExplanation: userTeamSolved ? challenge.solveExplanation : undefined
133 | };
134 |
135 | return NextResponse.json(transformedChallenge);
136 | } catch (error) {
137 | console.error('Error fetching challenge:', error);
138 | return NextResponse.json(
139 | { error: 'Failed to fetch challenge' },
140 | { status: 500 }
141 | );
142 | }
143 | }
--------------------------------------------------------------------------------
/src/app/api/challenges/categories/[categoryId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | export async function GET(
7 | request: Request,
8 | { params }: { params: Promise<{ categoryId: string }> }
9 | ) {
10 | const { categoryId } = await params;
11 | try {
12 | const session = await getServerSession(authOptions);
13 |
14 | const challenges = await prisma.challenge.findMany({
15 | where: {
16 | category: categoryId
17 | },
18 | select: {
19 | id: true,
20 | title: true,
21 | points: true,
22 | difficulty: true,
23 | description: true,
24 | category: true,
25 | isLocked: true,
26 | submissions: {
27 | where: {
28 | isCorrect: true
29 | },
30 | select: {
31 | teamId: true,
32 | team: {
33 | select: {
34 | color: true
35 | }
36 | }
37 | }
38 | }
39 | }
40 | });
41 |
42 | // Get solved challenges for the current team if authenticated
43 | const solvedChallengeIds = new Set();
44 | if (session?.user?.teamId) {
45 | const solvedChallenges = await prisma.submission.findMany({
46 | where: {
47 | teamId: session.user.teamId,
48 | isCorrect: true,
49 | },
50 | select: {
51 | challengeId: true,
52 | },
53 | });
54 | solvedChallenges.forEach(sub => solvedChallengeIds.add(sub.challengeId));
55 | }
56 |
57 | const transformedChallenges = challenges.map(challenge => ({
58 | id: challenge.id,
59 | title: challenge.title,
60 | points: challenge.points,
61 | difficulty: challenge.difficulty,
62 | description: challenge.description,
63 | category: challenge.category,
64 | isLocked: challenge.isLocked,
65 | isSolved: solvedChallengeIds.has(challenge.id),
66 | solvedBy: challenge.submissions.map(sub => ({
67 | teamId: sub.teamId,
68 | teamColor: sub.team.color
69 | }))
70 | }));
71 |
72 | return NextResponse.json({ challenges: transformedChallenges });
73 | } catch (error) {
74 | console.error('Error fetching category challenges:', error);
75 | return NextResponse.json(
76 | { error: 'Failed to fetch category challenges' },
77 | { status: 500 }
78 | );
79 | }
80 | }
--------------------------------------------------------------------------------
/src/app/api/challenges/categories/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | interface ChallengeFile {
7 | id: string;
8 | name: string;
9 | path: string;
10 | size: number;
11 | challengeId: string;
12 | }
13 |
14 | interface Challenge {
15 | id: string;
16 | category: string;
17 | title: string;
18 | description: string;
19 | points: number;
20 | difficulty: string;
21 | isLocked: boolean;
22 | isActive: boolean;
23 | files: ChallengeFile[];
24 | isSolved: boolean;
25 | solvedBy: Array<{
26 | teamId: string;
27 | teamColor: string;
28 | }>;
29 | }
30 |
31 | export async function GET() {
32 | try {
33 | const session = await getServerSession(authOptions);
34 |
35 | // Get all challenges
36 | const challenges = await prisma.challenge.findMany({
37 | include: {
38 | files: true,
39 | submissions: {
40 | where: {
41 | isCorrect: true
42 | },
43 | select: {
44 | teamId: true,
45 | team: {
46 | select: {
47 | color: true
48 | }
49 | }
50 | }
51 | }
52 | }
53 | });
54 |
55 | // Get solved challenges for the current team if authenticated
56 | const solvedChallengeIds = new Set();
57 | if (session?.user?.teamId) {
58 | const solvedChallenges = await prisma.submission.findMany({
59 | where: {
60 | teamId: session.user.teamId,
61 | isCorrect: true,
62 | },
63 | select: {
64 | challengeId: true,
65 | },
66 | });
67 | solvedChallenges.forEach(sub => solvedChallengeIds.add(sub.challengeId));
68 | }
69 |
70 | // Group challenges by category
71 | const challengesByCategory = challenges.reduce((acc, challenge) => {
72 | if (!acc[challenge.category]) {
73 | acc[challenge.category] = [];
74 | }
75 | acc[challenge.category].push({
76 | id: challenge.id,
77 | title: challenge.title,
78 | description: challenge.description,
79 | points: challenge.points,
80 | category: challenge.category,
81 | difficulty: challenge.difficulty,
82 | isActive: challenge.isActive,
83 | isLocked: challenge.isLocked,
84 | files: challenge.files,
85 | isSolved: solvedChallengeIds.has(challenge.id),
86 | solvedBy: challenge.submissions.map(sub => ({
87 | teamId: sub.teamId,
88 | teamColor: sub.team.color
89 | }))
90 | });
91 | return acc;
92 | }, {} as Record);
93 |
94 | // Get unique categories
95 | const categories = Object.keys(challengesByCategory);
96 |
97 | return NextResponse.json({
98 | categories,
99 | challengesByCategory
100 | });
101 | } catch (error) {
102 | console.error('Error fetching challenges by category:', error);
103 | return NextResponse.json(
104 | { error: 'Error fetching challenges by category' },
105 | { status: 500 }
106 | );
107 | }
108 | }
--------------------------------------------------------------------------------
/src/app/api/challenges/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 | import { evaluateUnlockConditions } from '@/lib/challenges';
6 |
7 | export async function GET() {
8 | const session = await getServerSession(authOptions);
9 |
10 | if (!session) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 | }
13 |
14 | try {
15 | // Get current game config
16 | const gameConfig = await prisma.gameConfig.findFirst({
17 | where: { isActive: true }
18 | });
19 |
20 | // Get current time
21 | const now = new Date();
22 |
23 | // For non-admin users, check if the game has started
24 | // Admins can always see all challenges
25 | if (!session.user.isAdmin && gameConfig) {
26 | const gameStarted = new Date(gameConfig.startTime) <= now;
27 |
28 | // If game hasn't started yet, return empty array or a message
29 | if (!gameStarted) {
30 | return NextResponse.json({
31 | message: "The competition hasn't started yet. Please check back later.",
32 | challenges: []
33 | });
34 | }
35 | }
36 |
37 | const challenges = await prisma.challenge.findMany({
38 | include: {
39 | files: true,
40 | unlockConditions: true,
41 | flags: true,
42 | submissions: session.user.isAdmin ? undefined : {
43 | where: {
44 | isCorrect: true,
45 | teamId: session?.user?.teamId || undefined
46 | },
47 | select: {
48 | teamId: true,
49 | flagId: true
50 | }
51 | }
52 | }
53 | });
54 |
55 | // Fetch all correct submissions for the team if not admin
56 | const teamSolves = (!session.user.isAdmin && session?.user?.teamId) ? await prisma.submission.findMany({
57 | where: {
58 | teamId: session.user.teamId,
59 | isCorrect: true,
60 | },
61 | select: {
62 | challengeId: true,
63 | flagId: true
64 | }
65 | }) : [];
66 |
67 | // Create a map of challenge ID to set of solved flag IDs
68 | const solvedFlagsByChallenge = new Map>();
69 | teamSolves.forEach(solve => {
70 | const solvedFlags = solvedFlagsByChallenge.get(solve.challengeId) || new Set();
71 | if (solve.flagId) solvedFlags.add(solve.flagId);
72 | solvedFlagsByChallenge.set(solve.challengeId, solvedFlags);
73 | });
74 |
75 | // Process challenges: evaluate unlocks for non-admins, return appropriate data
76 | const processedChallenges = challenges.map(challenge => {
77 | // Admins see everything
78 | if (session.user.isAdmin) {
79 | // Optionally calculate isSolved for admin view
80 | // For simplicity, just returning raw data + conditions here
81 | return {
82 | ...challenge,
83 | flag: undefined, // Generally avoid sending flags even to admins in list view
84 | flags: challenge.flags.map(f => ({ id: f.id, points: f.points })), // Send only id and points
85 | submissions: undefined, // Don't need submissions list here
86 | isUnlocked: true // Admins bypass locks
87 | };
88 | }
89 |
90 | // Non-admins: Evaluate unlock conditions
91 | const { isUnlocked, reason } = evaluateUnlockConditions(
92 | challenge.unlockConditions,
93 | new Set(teamSolves.map(solve => solve.challengeId)),
94 | gameConfig
95 | );
96 |
97 | if (!isUnlocked) {
98 | // Return minimal data for locked challenges
99 | return {
100 | id: challenge.id,
101 | title: challenge.title,
102 | points: challenge.points,
103 | category: challenge.category,
104 | difficulty: challenge.difficulty,
105 | isLocked: true,
106 | unlockReason: reason
107 | };
108 | }
109 |
110 | const solvedFlags = solvedFlagsByChallenge.get(challenge.id) || new Set();
111 |
112 | // Return unlocked challenge data (without flag, conditions)
113 | return {
114 | id: challenge.id,
115 | title: challenge.title,
116 | description: challenge.description,
117 | points: challenge.points,
118 | difficulty: challenge.difficulty,
119 | category: challenge.category,
120 | files: challenge.files,
121 | createdAt: challenge.createdAt,
122 | updatedAt: challenge.updatedAt,
123 | isUnlocked: true,
124 | multipleFlags: challenge.multipleFlags,
125 | flags: challenge.multipleFlags ? challenge.flags.map(flag => ({
126 | id: flag.id,
127 | points: flag.points,
128 | isSolved: solvedFlags.has(flag.id)
129 | })) : undefined,
130 | isSolved: challenge.multipleFlags
131 | ? solvedFlags.size === challenge.flags.length
132 | : challenge.submissions && challenge.submissions.length > 0,
133 | unlockConditions: undefined // Don't send conditions
134 | };
135 | });
136 |
137 | return NextResponse.json(processedChallenges);
138 | } catch (error) {
139 | console.error('Error fetching challenges:', error);
140 | return NextResponse.json(
141 | { error: 'Error fetching challenges' },
142 | { status: 500 }
143 | );
144 | }
145 | }
--------------------------------------------------------------------------------
/src/app/api/challenges/solved/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | export async function GET() {
7 | const session = await getServerSession(authOptions);
8 |
9 | if (!session) {
10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 | }
12 |
13 | try {
14 | const solvedChallenges = await prisma.submission.findMany({
15 | where: {
16 | teamId: session.user.teamId ?? "",
17 | isCorrect: true,
18 | },
19 | select: {
20 | challengeId: true,
21 | },
22 | });
23 |
24 | return NextResponse.json(solvedChallenges.map(sub => sub.challengeId));
25 | } catch (error) {
26 | console.error('Error fetching solved challenges:', error);
27 | return NextResponse.json(
28 | { error: 'Error fetching solved challenges' },
29 | { status: 500 }
30 | );
31 | }
32 | }
--------------------------------------------------------------------------------
/src/app/api/config/route.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '@/lib/prisma';
2 | import { NextResponse } from 'next/server';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | async function isAdmin() {
7 | const session = await getServerSession(authOptions);
8 | return session?.user?.isAdmin === true;
9 | }
10 |
11 | export async function GET() {
12 | const configs = await prisma.siteConfig.findMany();
13 | return NextResponse.json(configs);
14 | }
15 |
16 | export async function PUT(request: Request) {
17 | if (!await isAdmin()) {
18 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
19 | }
20 |
21 | const { key, value } = await request.json();
22 |
23 | const config = await prisma.siteConfig.upsert({
24 | where: { key },
25 | update: { value },
26 | create: { key, value },
27 | });
28 |
29 | return NextResponse.json(config);
30 | }
--------------------------------------------------------------------------------
/src/app/api/files/[filepath]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { unlink } from 'fs/promises';
3 | import { join } from 'path';
4 | import { getServerSession } from 'next-auth';
5 | import { authOptions } from '@/lib/auth';
6 | import { prisma } from '@/lib/prisma';
7 |
8 | export async function DELETE(
9 | request: Request,
10 | { params }: { params: Promise<{ filepath: string }> }
11 | ) {
12 | const { filepath } = await params;
13 | try {
14 | const session = await getServerSession(authOptions);
15 | if (!session?.user?.isAdmin) {
16 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
17 | }
18 |
19 | // Get the file record from the database using the path
20 | const fileRecord = await prisma.challengeFile.findFirst({
21 | where: { path: filepath }
22 | });
23 |
24 | if (!fileRecord) {
25 | return NextResponse.json({ error: 'File not found' }, { status: 404 });
26 | }
27 |
28 | // Convert the public path to a filesystem path
29 | const filePath = join(process.cwd(), 'public', fileRecord.path);
30 |
31 | // Delete the file from the filesystem
32 | await unlink(filePath);
33 |
34 | // Delete the file record from the database
35 | await prisma.challengeFile.delete({
36 | where: { id: fileRecord.id }
37 | });
38 |
39 | return NextResponse.json({ success: true });
40 | } catch (error) {
41 | console.error('Error deleting file:', error);
42 | return NextResponse.json(
43 | { error: 'Error deleting file' },
44 | { status: 500 }
45 | );
46 | }
47 | }
--------------------------------------------------------------------------------
/src/app/api/files/upload/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { writeFile, mkdir } from 'fs/promises';
3 | import { join } from 'path';
4 | import { getServerSession } from 'next-auth';
5 | import { authOptions } from '@/lib/auth';
6 | import { prisma } from '@/lib/prisma';
7 |
8 | export async function POST(request: Request) {
9 | try {
10 | const session = await getServerSession(authOptions);
11 | if (!session?.user?.isAdmin) {
12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
13 | }
14 |
15 | const formData = await request.formData();
16 | const file = formData.get('file') as File;
17 | const challengeId = formData.get('challengeId') as string;
18 |
19 | if (!file) {
20 | return NextResponse.json({ error: 'No file provided' }, { status: 400 });
21 | }
22 |
23 | if (!challengeId) {
24 | return NextResponse.json({ error: 'No challenge ID provided' }, { status: 400 });
25 | }
26 |
27 | // Get challenge details
28 | const challenge = await prisma.challenge.findUnique({
29 | where: { id: challengeId }
30 | });
31 |
32 | if (!challenge) {
33 | return NextResponse.json({ error: 'Challenge not found' }, { status: 404 });
34 | }
35 |
36 | // Create normalized folder name from challenge title
37 | const normalizedFolderName = challenge.title
38 | .toLowerCase()
39 | .replace(/[^a-z0-9]+/g, '-')
40 | .replace(/^-+|-+$/g, '');
41 |
42 | const buffer = Buffer.from(await file.arrayBuffer());
43 | const filename = file.name;
44 | const challengeDir = join(process.cwd(), 'public', 'uploads', normalizedFolderName);
45 |
46 | // Create challenge directory if it doesn't exist
47 | try {
48 | await mkdir(challengeDir, { recursive: true });
49 | } catch (error) {
50 | console.error('Error creating challenge directory:', error);
51 | return NextResponse.json(
52 | { error: 'Error creating challenge directory' },
53 | { status: 500 }
54 | );
55 | }
56 |
57 | const path = join(challengeDir, filename);
58 |
59 | // Write the file to the challenge directory
60 | await writeFile(path, buffer);
61 |
62 | // Create ChallengeFile record
63 | const challengeFile = await prisma.challengeFile.create({
64 | data: {
65 | name: file.name,
66 | path: `/uploads/${normalizedFolderName}/${filename}`,
67 | size: file.size,
68 | challengeId: challenge.id
69 | }
70 | });
71 |
72 | return NextResponse.json({
73 | name: file.name,
74 | path: challengeFile.path,
75 | size: file.size,
76 | id: challengeFile.id
77 | });
78 | } catch (error) {
79 | console.error('Error uploading file:', error);
80 | return NextResponse.json(
81 | { error: 'Error uploading file' },
82 | { status: 500 }
83 | );
84 | }
85 | }
--------------------------------------------------------------------------------
/src/app/api/game-config/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getServerSession } from 'next-auth';
3 | import { authOptions } from '@/lib/auth';
4 | import { prisma } from '@/lib/prisma';
5 |
6 | export async function GET() {
7 | try {
8 | const gameConfig = await prisma.gameConfig.findFirst({
9 | where: {
10 | isActive: true,
11 | },
12 | });
13 |
14 | if (!gameConfig) {
15 | return NextResponse.json({
16 | isActive: false,
17 | startTime: null,
18 | endTime: null,
19 | hasEndTime: true
20 | });
21 | }
22 |
23 | // Ensure dates are in ISO format
24 | return NextResponse.json({
25 | ...gameConfig,
26 | startTime: gameConfig.startTime.toISOString(),
27 | endTime: gameConfig.endTime?.toISOString() || null,
28 | hasEndTime: gameConfig.endTime !== null
29 | });
30 | } catch (error) {
31 | console.error('Error fetching game config:', error);
32 | return NextResponse.json(
33 | { error: 'Internal server error' },
34 | { status: 500 }
35 | );
36 | }
37 | }
38 |
39 | export async function PUT(request: Request) {
40 | try {
41 | const session = await getServerSession(authOptions);
42 | if (!session || !session.user.isAdmin) {
43 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
44 | }
45 |
46 | const data = await request.json();
47 | const { startTime, endTime, isActive, hasEndTime } = data;
48 |
49 | // Parse dates consistently using UTC
50 | const startDate = new Date(startTime);
51 | const endDate = hasEndTime && endTime ? new Date(endTime) : null;
52 |
53 | // Validate dates
54 | if (hasEndTime && startDate >= endDate!) {
55 | return NextResponse.json(
56 | { error: 'Start time must be before end time' },
57 | { status: 400 }
58 | );
59 | }
60 |
61 | // Find existing config
62 | const existingConfig = await prisma.gameConfig.findFirst();
63 |
64 | let gameConfig;
65 | if (existingConfig) {
66 | // Update existing config
67 | gameConfig = await prisma.gameConfig.update({
68 | where: { id: existingConfig.id },
69 | data: {
70 | startTime: startDate,
71 | endTime: endDate,
72 | isActive,
73 | },
74 | });
75 | } else {
76 | // Create new config
77 | gameConfig = await prisma.gameConfig.create({
78 | data: {
79 | startTime: startDate,
80 | endTime: endDate,
81 | isActive,
82 | },
83 | });
84 | }
85 |
86 | // Return dates in ISO format
87 | return NextResponse.json({
88 | ...gameConfig,
89 | startTime: gameConfig.startTime.toISOString(),
90 | endTime: gameConfig.endTime?.toISOString() || null,
91 | hasEndTime: gameConfig.endTime !== null
92 | });
93 | } catch (error) {
94 | console.error('Error updating game config:', error);
95 | return NextResponse.json(
96 | { error: 'Internal server error' },
97 | { status: 500 }
98 | );
99 | }
100 | }
--------------------------------------------------------------------------------
/src/app/api/hints/purchase/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | export async function POST(req: Request) {
7 | const session = await getServerSession(authOptions);
8 |
9 | if (!session) {
10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 | }
12 |
13 | try {
14 | const { hintId } = await req.json();
15 |
16 | // Get the hint and team
17 | const [hint, team] = await Promise.all([
18 | prisma.hint.findUnique({
19 | where: { id: hintId },
20 | include: {
21 | challenge: {
22 | select: { title: true }
23 | }
24 | }
25 | }),
26 | prisma.team.findUnique({
27 | where: { id: session.user.teamId ?? "" },
28 | }),
29 | ]);
30 |
31 | if (!hint || !team) {
32 | return NextResponse.json(
33 | { error: 'Hint or team not found' },
34 | { status: 404 }
35 | );
36 | }
37 |
38 | // Check if hint is already purchased
39 | const existingPurchase = await prisma.teamHint.findUnique({
40 | where: {
41 | teamId_hintId: {
42 | teamId: team.id,
43 | hintId: hint.id,
44 | },
45 | },
46 | });
47 |
48 | if (existingPurchase) {
49 | return NextResponse.json(
50 | { error: 'Hint already purchased' },
51 | { status: 400 }
52 | );
53 | }
54 |
55 | // Check if team has enough points
56 | if (team.score < hint.cost) {
57 | return NextResponse.json(
58 | { error: 'Not enough points' },
59 | { status: 400 }
60 | );
61 | }
62 |
63 | // Start a transaction to ensure atomicity
64 | const result = await prisma.$transaction(async (tx) => {
65 | // Create the team hint record
66 | const teamHint = await tx.teamHint.create({
67 | data: {
68 | teamId: team.id,
69 | hintId: hint.id,
70 | },
71 | });
72 |
73 | // Deduct points from team if hint has a cost
74 | if (hint.cost > 0) {
75 | const updatedTeam = await tx.team.update({
76 | where: { id: team.id },
77 | data: {
78 | score: {
79 | decrement: hint.cost,
80 | },
81 | },
82 | });
83 |
84 | // Record point history
85 | await tx.teamPointHistory.create({
86 | data: {
87 | teamId: team.id,
88 | points: -hint.cost, // Negative points for hint purchase
89 | totalPoints: updatedTeam.score,
90 | reason: 'HINT_PURCHASE',
91 | metadata: JSON.stringify({
92 | hintId: hint.id,
93 | challengeTitle: hint.challenge.title,
94 | cost: hint.cost
95 | })
96 | }
97 | });
98 |
99 | // Record activity
100 | await tx.activityLog.create({
101 | data: {
102 | type: 'HINT_PURCHASE',
103 | description: `Team ${team.name} purchased a hint for challenge "${hint.challenge.title}" (-${hint.cost} points)`,
104 | teamId: team.id,
105 | },
106 | });
107 | }
108 |
109 | return teamHint;
110 | });
111 |
112 | return NextResponse.json(result, { status: 201 });
113 | } catch (error) {
114 | console.error('Error purchasing hint:', error);
115 | return NextResponse.json(
116 | { error: 'Error purchasing hint' },
117 | { status: 500 }
118 | );
119 | }
120 | }
--------------------------------------------------------------------------------
/src/app/api/hints/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | async function isAdmin() {
7 | const session = await getServerSession(authOptions);
8 | return session?.user?.isAdmin === true;
9 | }
10 |
11 | export async function POST(req: Request) {
12 | if (!await isAdmin()) {
13 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
14 | }
15 |
16 | try {
17 | const { challengeId, content, cost } = await req.json();
18 |
19 | const hint = await prisma.hint.create({
20 | data: {
21 | content,
22 | cost,
23 | challengeId,
24 | },
25 | });
26 |
27 | return NextResponse.json(hint, { status: 201 });
28 | } catch (error) {
29 | console.error('Error creating hint:', error);
30 | return NextResponse.json(
31 | { error: 'Error creating hint' },
32 | { status: 500 }
33 | );
34 | }
35 | }
36 |
37 | export async function GET(req: Request) {
38 | const session = await getServerSession(authOptions);
39 |
40 | if (!session) {
41 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
42 | }
43 |
44 | try {
45 | const { searchParams } = new URL(req.url);
46 | const challengeId = searchParams.get('challengeId');
47 |
48 | if (!challengeId) {
49 | return NextResponse.json({ error: 'Missing challengeId' }, { status: 400 });
50 | }
51 |
52 | // Get all hints for the challenge
53 | const hints = await prisma.hint.findMany({
54 | where: {
55 | challengeId,
56 | },
57 | });
58 |
59 | // Get hints purchased by the team
60 | const teamHints = await prisma.teamHint.findMany({
61 | where: {
62 | teamId: session.user.teamId ?? "",
63 | hint: {
64 | challengeId,
65 | },
66 | },
67 | select: {
68 | hintId: true,
69 | },
70 | });
71 |
72 | const purchasedHintIds = new Set(teamHints.map(th => th.hintId));
73 |
74 | // Return hints with purchase status, excluding content if not purchased
75 | const hintsWithStatus = hints.map(hint => {
76 | const isPurchased = purchasedHintIds.has(hint.id);
77 | return {
78 | ...hint,
79 | content: isPurchased ? hint.content : undefined,
80 | isPurchased,
81 | };
82 | });
83 |
84 | return NextResponse.json(hintsWithStatus);
85 | } catch (error) {
86 | console.error('Error fetching hints:', error);
87 | return NextResponse.json(
88 | { error: 'Error fetching hints' },
89 | { status: 500 }
90 | );
91 | }
92 | }
93 |
94 | export async function DELETE(req: Request) {
95 | if (!await isAdmin()) {
96 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
97 | }
98 |
99 | try {
100 | const { id } = await req.json();
101 |
102 | await prisma.hint.delete({
103 | where: { id },
104 | });
105 |
106 | return NextResponse.json({ message: 'Hint deleted successfully' });
107 | } catch (error) {
108 | console.error('Error deleting hint:', error);
109 | return NextResponse.json(
110 | { error: 'Error deleting hint' },
111 | { status: 500 }
112 | );
113 | }
114 | }
115 |
116 | export async function PATCH(req: Request) {
117 | if (!await isAdmin()) {
118 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
119 | }
120 |
121 | try {
122 | const { id, content, cost } = await req.json();
123 |
124 | const hint = await prisma.hint.update({
125 | where: { id },
126 | data: {
127 | content,
128 | cost,
129 | },
130 | });
131 |
132 | return NextResponse.json(hint);
133 | } catch (error) {
134 | console.error('Error updating hint:', error);
135 | return NextResponse.json(
136 | { error: 'Error updating hint' },
137 | { status: 500 }
138 | );
139 | }
140 | }
--------------------------------------------------------------------------------
/src/app/api/leaderboard/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getServerSession } from 'next-auth';
3 | import { authOptions } from '@/lib/auth';
4 | import { prisma } from '@/lib/prisma';
5 |
6 | export async function GET() {
7 | try {
8 | const session = await getServerSession(authOptions);
9 | const teams = await prisma.team.findMany({
10 | select: {
11 | id: true,
12 | name: true,
13 | score: true,
14 | icon: true,
15 | color: true,
16 | },
17 | orderBy: {
18 | score: 'desc',
19 | },
20 | });
21 |
22 | // Get current user's team only if authenticated
23 | const currentUserTeam = session?.user?.teamId
24 | ? await prisma.team.findUnique({
25 | where: { id: session.user.teamId },
26 | select: {
27 | id: true,
28 | name: true,
29 | score: true,
30 | icon: true,
31 | color: true,
32 | },
33 | })
34 | : null;
35 |
36 | return NextResponse.json({
37 | teams,
38 | currentUserTeam,
39 | });
40 | } catch (error) {
41 | console.error('Error fetching leaderboard:', error);
42 | return NextResponse.json(
43 | { error: 'Internal server error' },
44 | { status: 500 }
45 | );
46 | }
47 | }
--------------------------------------------------------------------------------
/src/app/api/rules/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 |
4 | export async function GET() {
5 | try {
6 | const siteRules = await prisma.siteConfig.findFirst({
7 | where: {
8 | key: 'rules_text'
9 | }
10 | });
11 |
12 | if (!siteRules) {
13 | return NextResponse.json({
14 | siteRules: "No rules have been set for this competition yet."
15 | });
16 | }
17 |
18 | return NextResponse.json({ siteRules: siteRules.value });
19 | } catch (error) {
20 | console.error('Error fetching rules:', error);
21 | return NextResponse.json(
22 | { error: 'Failed to fetch rules' },
23 | { status: 500 }
24 | );
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/api/scores/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 |
4 | export async function GET() {
5 | try {
6 | const scores = await prisma.score.findMany({
7 | include: {
8 | team: true,
9 | user: true,
10 | challenge: true,
11 | },
12 | orderBy: {
13 | createdAt: 'asc',
14 | },
15 | });
16 |
17 | return NextResponse.json(scores);
18 | } catch (error) {
19 | console.error('Error fetching scores:', error);
20 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
21 | }
22 | }
--------------------------------------------------------------------------------
/src/app/api/teams/[teamId]/points/history/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 |
4 | export async function GET(
5 | request: Request,
6 | { params }: { params: Promise<{ teamId: string }> }
7 | ) {
8 | try {
9 | const { teamId } = await params;
10 |
11 | // Verify the team exists
12 | const team = await prisma.team.findUnique({
13 | where: { id: teamId },
14 | });
15 |
16 | if (!team) {
17 | return NextResponse.json({ error: 'Team not found' }, { status: 404 });
18 | }
19 |
20 | // Get point history with pagination
21 | const searchParams = new URL(request.url).searchParams;
22 | const page = parseInt(searchParams.get('page') || '1');
23 | const limit = parseInt(searchParams.get('limit') || '50');
24 | const skip = (page - 1) * limit;
25 |
26 | const pointHistory = await prisma.teamPointHistory.findMany({
27 | where: { teamId },
28 | orderBy: { createdAt: 'desc' },
29 | skip,
30 | take: limit,
31 | select: {
32 | id: true,
33 | points: true,
34 | totalPoints: true,
35 | reason: true,
36 | metadata: true,
37 | createdAt: true,
38 | },
39 | });
40 |
41 | const total = await prisma.teamPointHistory.count({
42 | where: { teamId },
43 | });
44 |
45 | return NextResponse.json({
46 | items: pointHistory,
47 | total,
48 | page,
49 | limit,
50 | totalPages: Math.ceil(total / limit),
51 | });
52 | } catch (error) {
53 | console.error('Error fetching team point history:', error);
54 | return NextResponse.json(
55 | { error: 'Internal server error' },
56 | { status: 500 }
57 | );
58 | }
59 | }
--------------------------------------------------------------------------------
/src/app/api/teams/[teamId]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { prisma } from '@/lib/prisma';
3 | import { getServerSession } from 'next-auth';
4 | import { authOptions } from '@/lib/auth';
5 |
6 | export async function GET(
7 | request: Request,
8 | { params }: { params: Promise<{ teamId: string }> }
9 | ) {
10 | const { teamId } = await params;
11 | try {
12 | const session = await getServerSession(authOptions);
13 |
14 | if (!session) {
15 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16 | }
17 |
18 | const team = await prisma.team.findUnique({
19 | where: { id: teamId },
20 | select: {
21 | id: true,
22 | name: true,
23 | code: true,
24 | score: true,
25 | icon: true,
26 | color: true,
27 | members: {
28 | select: {
29 | id: true,
30 | alias: true,
31 | name: true,
32 | isAdmin: true,
33 | isTeamLeader: true,
34 | },
35 | },
36 | },
37 | });
38 |
39 | if (!team) {
40 | return NextResponse.json({ error: 'Team not found' }, { status: 404 });
41 | }
42 |
43 | // Only allow access if the user is a member of the team or an admin
44 | if (!session.user.isAdmin && !team.members.some(member => member.id === session.user.id)) {
45 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
46 | }
47 |
48 | return NextResponse.json(team);
49 | } catch (error) {
50 | console.error('Error fetching team:', error);
51 | return NextResponse.json(
52 | { error: 'Internal server error' },
53 | { status: 500 }
54 | );
55 | }
56 | }
--------------------------------------------------------------------------------
/src/app/auth/signin/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { signIn } from 'next-auth/react';
5 | import { useRouter } from 'next/navigation';
6 | import Link from 'next/link';
7 | import toast from 'react-hot-toast';
8 |
9 | export default function SignIn() {
10 | const router = useRouter();
11 | const [error, setError] = useState('');
12 |
13 | const handleSubmit = async (e: React.FormEvent) => {
14 | e.preventDefault();
15 | const formData = new FormData(e.currentTarget);
16 | const alias = formData.get('alias') as string;
17 | const password = formData.get('password') as string;
18 |
19 | try {
20 | const result = await signIn('credentials', {
21 | alias,
22 | password,
23 | redirect: false,
24 | });
25 |
26 | if (result?.error) {
27 | setError('Invalid credentials');
28 | } else {
29 | toast.success('Successfully logged in!');
30 | router.push('/dashboard');
31 | }
32 | } catch (error) {
33 | setError('An error occurred: ' + (error as Error).message);
34 | }
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |
Log in to the CTF
42 |
43 |
78 |
79 |
80 | {"Don't have an account? Sign up"}
81 |
82 |
83 |
84 |
85 | );
86 | }
--------------------------------------------------------------------------------
/src/app/auth/signout/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { signOut } from 'next-auth/react';
4 | import { useRouter } from 'next/navigation';
5 |
6 | export default function SignOut() {
7 | const router = useRouter();
8 |
9 | const handleSignOut = async () => {
10 | await signOut({ redirect: false });
11 | router.push('/auth/signin');
12 | };
13 |
14 | return (
15 |
16 |
17 |
18 |
Sign Out
19 |
20 |
21 |
22 | Are you sure you want to sign out?
23 |
24 |
25 |
29 | Sign Out
30 |
31 | router.back()}
33 | className="button w-full bg-gray-500 hover:bg-gray-600"
34 | >
35 | Cancel
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asynchronous-x/orbital-ctf/126e8d84ad20640140990e0caaa7aea73d78c6e6/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter, Roboto, Roboto_Mono } from "next/font/google";
3 | import "./globals.css";
4 | import { getServerSession } from "next-auth";
5 | import { authOptions } from "../lib/auth";
6 | import Navbar from "@/components/Navbar";
7 | import Providers from "@/components/Providers";
8 | import { Toaster } from 'react-hot-toast';
9 | import { prisma } from "@/lib/prisma";
10 | import { FaCheck, FaTimes } from 'react-icons/fa';
11 |
12 | const inter = Inter({ subsets: ["latin"] });
13 | const roboto = Roboto({ subsets: ["latin"] });
14 | const roboto_mono = Roboto_Mono({ subsets: ["latin"] });
15 |
16 | export async function generateMetadata(): Promise {
17 | const config = await prisma.siteConfig.findFirst({
18 | where: {
19 | key: "site_title"
20 | }
21 | });
22 |
23 | return {
24 | title: config?.value || "Orbital CTF",
25 | description: "Capture The Flag Platform",
26 | };
27 | }
28 |
29 | export default async function RootLayout({
30 | children,
31 | }: {
32 | children: React.ReactNode;
33 | }) {
34 | const session = await getServerSession(authOptions);
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {children}
42 | ,
63 | },
64 | error: {
65 | style: {
66 | border: '2px solid #ff0000',
67 | color: '#ff0000',
68 | borderRadius: '0',
69 | },
70 | icon: ,
71 | },
72 | }}
73 | />
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import SpaceScene from '@/components/SpaceScene';
5 |
6 | export default function NotFound() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ERROR 404
17 |
18 |
19 | The page you are looking for might have been removed, had its name changed or is temporarily unavailable.
20 |
21 |
22 | window.history.back()}
24 | className="px-6 py-3 border border-white hover:text-blue-500 hover:border-blue-500 whitespace-nowrap"
25 | >
26 | Go Back
27 |
28 |
32 | Return to Homepage
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import Link from 'next/link';
5 | import SpaceScene from '@/components/SpaceScene';
6 | import { fetchSiteConfigurations } from '@/utils/api';
7 |
8 | export default function Home() {
9 | const [title, setTitle] = useState('Welcome to Orbital CTF');
10 | const [subtitle, setSubtitle] = useState('80s retro ui, space-themed, batteries included.');
11 |
12 | useEffect(() => {
13 | fetchSiteConfigurations()
14 | .then((configs) => {
15 | const titleConfig = configs.find(c => c.key === 'homepage_title');
16 | const subtitleConfig = configs.find(c => c.key === 'homepage_subtitle');
17 |
18 | if (titleConfig) setTitle(titleConfig.value);
19 | if (subtitleConfig) setSubtitle(subtitleConfig.value);
20 | })
21 | .catch(error => {
22 | console.error('Failed to load site configuration:', error);
23 | });
24 | }, []);
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | {title}
36 |
37 |
38 | {subtitle}
39 |
40 |
41 |
45 | Register
46 |
47 |
51 | Log In
52 |
53 |
57 | Rules
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/app/profile/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useCallback } from 'react';
4 | import { useSession } from 'next-auth/react';
5 | import { useRouter } from 'next/navigation';
6 | import { useEffect, useState } from 'react';
7 | import * as GiIcons from 'react-icons/gi';
8 | import { FaRegCopy, FaCheck } from 'react-icons/fa';
9 | import PageLayout from '@/components/layouts/PageLayout';
10 | import { fetchTeam } from '@/utils/api';
11 | import { Team } from '@/types';
12 |
13 | export default function Profile() {
14 | const { data: session, status } = useSession();
15 | const router = useRouter();
16 | const [team, setTeam] = useState(null);
17 | const [loading, setLoading] = useState(true);
18 | const [copied, setCopied] = useState(false);
19 |
20 | const loadTeamData = useCallback(async () => {
21 | try {
22 | if (session?.user?.teamId) {
23 | const data = await fetchTeam(session.user.teamId);
24 | setTeam(data);
25 | }
26 | } catch (error) {
27 | console.error('Error fetching team data:', error);
28 | } finally {
29 | setLoading(false);
30 | }
31 | }, [session?.user?.teamId]);
32 |
33 | const handleCopy = useCallback(() => {
34 | if (team?.code) {
35 | navigator.clipboard.writeText(team.code);
36 | setCopied(true);
37 | setTimeout(() => setCopied(false), 1500);
38 | }
39 | }, [team?.code]);
40 |
41 | useEffect(() => {
42 | if (status === 'unauthenticated') {
43 | router.push('/auth/signin');
44 | } else if (status === 'authenticated' && session?.user?.teamId) {
45 | loadTeamData();
46 | }
47 | }, [status, session, router, loadTeamData]);
48 |
49 | if (status === 'loading' || loading) {
50 | return (
51 |
54 | );
55 | }
56 |
57 | return (
58 |
59 |
60 |
61 |
62 | {/* User Information */}
63 |
64 |
User Information
65 |
66 |
67 |
Name
68 |
{session?.user?.name}
69 |
70 |
71 |
Alias
72 |
{session?.user?.alias}
73 |
74 |
75 |
Role
76 |
77 | {session?.user?.isAdmin ? 'Admin' : session?.user?.isTeamLeader ? 'Team Leader' : 'Member'}
78 |
79 |
80 |
81 |
82 |
83 | {/* Team Information */}
84 | {team && (
85 |
86 |
Team Information
87 |
88 |
89 |
Team Name
90 |
{team.name}
91 |
92 |
93 |
Team Code
94 |
95 |
96 | {team.code}
97 |
98 |
104 | {copied ? (
105 | <>
106 |
107 | Copied!
108 | >
109 | ) : (
110 | <>
111 |
112 | Copy to clipboard
113 | >
114 | )}
115 |
116 |
117 |
118 |
119 |
Team Score
120 |
{team.score} points
121 |
122 | {team.icon && (
123 |
124 |
Team Icon
125 |
126 |
127 | {GiIcons[team.icon as keyof typeof GiIcons] ?
128 | React.createElement(GiIcons[team.icon as keyof typeof GiIcons], { size: 32 })
129 | : team.icon}
130 |
131 |
132 |
133 | )}
134 |
135 |
Team Members
136 |
137 | {team.members.map((member) => (
138 |
139 | {member.alias}
140 | {member.isTeamLeader && (
141 |
142 | Leader
143 |
144 | )}
145 |
146 | ))}
147 |
148 |
149 |
150 |
151 | )}
152 |
153 |
154 |
155 | );
156 | }
--------------------------------------------------------------------------------
/src/app/rules/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import ReactMarkdown from 'react-markdown';
5 | import remarkGfm from 'remark-gfm';
6 | import { MarkdownComponents } from '@/components/MarkdownComponents';
7 | import PageLayout from '@/components/layouts/PageLayout';
8 | import { fetchRules } from '@/utils/api';
9 |
10 | export default function RulesPage() {
11 | const [rules, setRules] = useState('Loading...');
12 |
13 | useEffect(() => {
14 | const loadRules = async () => {
15 | try {
16 | const data = await fetchRules();
17 | setRules(data.siteRules);
18 | } catch (error) {
19 | console.error('Error fetching rules:', error);
20 | setRules('Error loading rules. Please try again later.');
21 | }
22 | };
23 |
24 | loadRules();
25 | }, []);
26 |
27 | return (
28 |
29 |
34 | {rules}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/DetailedCategoryView.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { useFrame } from '@react-three/fiber';
3 | import { Billboard, Box, Cylinder, Edges, Sphere, Text } from '@react-three/drei';
4 | import { useRouter } from 'next/navigation';
5 | import * as THREE from 'three';
6 | import { Challenge } from '@/types';
7 |
8 | interface DetailedCategoryViewProps {
9 | challenges: Challenge[];
10 | hoveredChallenge: string | null;
11 | setHoveredChallenge: (id: string | null) => void;
12 | }
13 |
14 | export default function DetailedCategoryView({
15 | challenges,
16 | hoveredChallenge,
17 | setHoveredChallenge
18 | }: DetailedCategoryViewProps) {
19 | const groupRef = useRef(null);
20 | const router = useRouter();
21 |
22 | // Calculate grid dimensions
23 | const challengesPerRow = 2;
24 | const spacing = 0.35; // Vertical spacing between rows
25 | const startX = -0.5; // Left column
26 | const startY = -0.8; // Start near the bottom of the main box
27 | const startZ = 0;
28 |
29 | // Calculate box height based on number of challenges
30 | const baseHeight = 2;
31 | const extraHeight = Math.max(0, Math.ceil((challenges.length - 10) / 2)) * 0.25;
32 | const totalHeight = baseHeight + extraHeight;
33 | const adjustedStartY = startY - (extraHeight / 2); // Adjust starting Y position to keep challenges centered
34 |
35 | useFrame((state) => {
36 | if (groupRef.current) {
37 | // Subtle floating animation instead of rotation
38 | groupRef.current.position.y = Math.sin(state.clock.elapsedTime) * 0.1;
39 | }
40 | });
41 |
42 | // Create challenge boxes arranged in two columns
43 | const createChallengeGrid = () => {
44 | return challenges.map((challenge, index) => {
45 | const row = Math.floor(index / challengesPerRow);
46 | const col = index % challengesPerRow;
47 | const x = startX + (col * 1.0); // 1.0 units between columns
48 | const y = adjustedStartY + (row * spacing);
49 |
50 | const getChallengeColor = () => {
51 | if (challenge.isLocked) return "gold";
52 | if (challenge.isSolved) return "#00ff00";
53 | return hoveredChallenge === challenge.id ? "#4a90e2" : "#ffffff";
54 | };
55 |
56 | const getChallengeText = () => {
57 | if (challenge.isLocked) return "🔒 Locked";
58 | if (challenge.isSolved) return "✓ Solved";
59 | return challenge.title;
60 | };
61 |
62 | return (
63 |
64 | !challenge.isLocked && router.push(`/challenges/${challenge.id}`)}
67 | onPointerOver={() => setHoveredChallenge(challenge.id)}
68 | onPointerOut={() => setHoveredChallenge(null)}
69 | >
70 |
71 |
75 |
76 | {hoveredChallenge === challenge.id && (
77 |
78 |
79 |
88 | {getChallengeText()}
89 |
90 |
91 | )}
92 |
93 | );
94 | });
95 | };
96 |
97 | return (
98 |
99 | {/* main box */}
100 |
101 |
102 |
103 |
104 | {/* accent boxes */}
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | {/* solar panel connection boxes */}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | {/* Left solar panel */}
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | {/* Right solar panel */}
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | {/* satellite dish */}
157 |
158 |
161 |
162 |
163 |
164 |
168 |
169 |
170 |
171 |
172 |
173 | {/* Challenge boxes */}
174 | {createChallengeGrid()}
175 |
176 | );
177 | }
--------------------------------------------------------------------------------
/src/components/MarkdownComponents.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, ComponentPropsWithoutRef } from 'react';
2 |
3 | type MarkdownComponentProps = ComponentPropsWithoutRef & {
4 | children?: ReactNode;
5 | };
6 |
7 | export const MarkdownComponents = {
8 | h1: ({ children, ...props }: MarkdownComponentProps<'h1'>) => {children} ,
9 | h2: ({ children, ...props }: MarkdownComponentProps<'h2'>) => {children} ,
10 | h3: ({ children, ...props }: MarkdownComponentProps<'h3'>) => {children} ,
11 | h4: ({ children, ...props }: MarkdownComponentProps<'h4'>) => {children} ,
12 | h5: ({ children, ...props }: MarkdownComponentProps<'h5'>) => {children} ,
13 | h6: ({ children, ...props }: MarkdownComponentProps<'h6'>) => {children} ,
14 | p: ({ children, ...props }: MarkdownComponentProps<'p'>) => {children}
,
15 | ul: ({ children, ...props }: MarkdownComponentProps<'ul'>) => ,
16 | ol: ({ children, ...props }: MarkdownComponentProps<'ol'>) => {children} ,
17 | li: ({ children, ...props }: MarkdownComponentProps<'li'>) => {children} ,
18 | code: ({ children, ...props }: MarkdownComponentProps<'code'>) => {children}
,
19 | pre: ({ children, ...props }: MarkdownComponentProps<'pre'>) => {children} ,
20 | blockquote: ({ children, ...props }: MarkdownComponentProps<'blockquote'>) => {children} ,
21 | a: ({ children, ...props }: MarkdownComponentProps<'a'>) => {children} ,
22 | strong: ({ children, ...props }: MarkdownComponentProps<'strong'>) => {children} ,
23 | em: ({ children, ...props }: MarkdownComponentProps<'em'>) => {children} ,
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/Providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { SessionProvider } from 'next-auth/react';
4 | import { Session } from 'next-auth';
5 |
6 | export default function Providers({
7 | children,
8 | session
9 | }: {
10 | children: React.ReactNode;
11 | session: Session | null;
12 | }) {
13 | return {children} ;
14 | }
--------------------------------------------------------------------------------
/src/components/ScoreboardChart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useMemo } from 'react';
4 | import {
5 | LineChart,
6 | Line,
7 | XAxis,
8 | YAxis,
9 | CartesianGrid,
10 | Tooltip,
11 | ResponsiveContainer,
12 | } from 'recharts';
13 | import { Team, PointHistory } from '@/types';
14 |
15 | interface ChartData {
16 | teams: Team[];
17 | pointHistories: Record;
18 | chartStart: string;
19 | chartEnd: string;
20 | }
21 |
22 | interface ScoreboardChartProps {
23 | chartData: ChartData;
24 | }
25 |
26 | export default function ScoreboardChart({ chartData }: ScoreboardChartProps) {
27 | const [hoveredTeam] = useState(null);
28 | const { teams, pointHistories, chartStart, chartEnd } = chartData;
29 |
30 | // Calculate time buckets (e.g., every 1 hour)
31 | const timePoints = useMemo(() => {
32 | const start = new Date(chartStart);
33 | const end = new Date(chartEnd);
34 | const points: Date[] = [];
35 | const current = new Date(start);
36 | current.setMinutes(0, 0, 0); // round to hour
37 | while (current <= end) {
38 | points.push(new Date(current));
39 | current.setHours(current.getHours() + 1);
40 | }
41 | return points;
42 | }, [chartStart, chartEnd]);
43 |
44 | // Build chart data: for each time bucket, for each team, the latest totalPoints at or before that time
45 | const chartRows = useMemo(() => {
46 | if (!timePoints.length) return [];
47 | return timePoints.map((time) => {
48 | const row: Record = {
49 | timestamp: time.toLocaleTimeString([], {
50 | hour: '2-digit',
51 | minute: '2-digit',
52 | hour12: false,
53 | }),
54 | };
55 | teams.forEach((team) => {
56 | const history = pointHistories[team.id] || [];
57 | // Find the latest event at or before this time
58 | let latest = 0;
59 | for (let i = 0; i < history.length; i++) {
60 | if (new Date(history[i].createdAt) <= time) {
61 | latest = history[i].totalPoints;
62 | break;
63 | }
64 | }
65 | // If no event before this time, use 0
66 | row[team.id] = latest;
67 | });
68 | return row;
69 | });
70 | }, [teams, pointHistories, timePoints]);
71 |
72 | // Only show teams with at least one point history event
73 | const teamsWithHistory = useMemo(() => {
74 | return teams.filter(team => (pointHistories[team.id] && pointHistories[team.id].length > 0));
75 | }, [teams, pointHistories]);
76 |
77 | return (
78 |
79 |
Score History
80 |
81 |
82 |
83 |
84 |
89 |
90 |
97 | {teamsWithHistory.map((team) => (
98 |
108 | ))}
109 |
110 |
111 |
112 |
113 | );
114 | }
--------------------------------------------------------------------------------
/src/components/ScoreboardStandings.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo } from 'react';
4 | import * as GiIcons from 'react-icons/gi';
5 | import { IconType } from 'react-icons';
6 | import { Score } from '@/types';
7 |
8 | interface ScoreboardStandingsProps {
9 | scores: Score[];
10 | }
11 |
12 | export default function ScoreboardStandings({ scores }: ScoreboardStandingsProps) {
13 | const teamScores = useMemo(() => {
14 | const scoresByTeam = new Map();
15 |
16 | scores.forEach((score) => {
17 | const existing = scoresByTeam.get(score.team.id);
18 | if (existing) {
19 | existing.total += score.points;
20 | } else {
21 | scoresByTeam.set(score.team.id, {
22 | team: score.team,
23 | total: score.points,
24 | });
25 | }
26 | });
27 |
28 | return Array.from(scoresByTeam.values()).sort((a, b) => b.total - a.total);
29 | }, [scores]);
30 |
31 | const getTeamIcon = (iconName?: string, color?: string) => {
32 | if (!iconName) return null;
33 | const IconComponent: IconType | undefined = (GiIcons as Record)[iconName];
34 | return IconComponent ? (
35 |
39 | ) : null;
40 | };
41 |
42 | return (
43 |
44 |
Current Standings
45 |
46 | {teamScores.map(({ team, total }, index) => (
47 |
51 |
52 | {index + 1}
53 | {getTeamIcon(team.icon, team.color)}
54 |
55 | {team.name}
56 |
57 |
58 |
{total}
59 |
60 | ))}
61 |
62 |
63 | );
64 | }
--------------------------------------------------------------------------------
/src/components/admin/AnnouncementModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NewAnnouncement } from '@/types';
3 |
4 | interface AnnouncementModalProps {
5 | title: string;
6 | announcement: NewAnnouncement;
7 | setAnnouncement: React.Dispatch>;
8 | onSubmit: (e: React.FormEvent) => Promise;
9 | onClose: () => void;
10 | submitText: string;
11 | }
12 |
13 | export default function AnnouncementModal({
14 | title,
15 | announcement,
16 | setAnnouncement,
17 | onSubmit,
18 | onClose,
19 | submitText
20 | }: AnnouncementModalProps) {
21 | return (
22 |
68 | );
69 | }
--------------------------------------------------------------------------------
/src/components/admin/AnnouncementsTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { FaPlus } from "react-icons/fa";
3 | import { Announcement, ApiError, NewAnnouncement } from '@/types';
4 | import { fetchAnnouncements, createAnnouncement, deleteAnnouncement } from '@/utils/api';
5 | import AnnouncementModal from './AnnouncementModal';
6 | import LoadingSpinner from '@/components/common/LoadingSpinner';
7 | import { toast } from 'react-hot-toast';
8 |
9 | export default function AnnouncementsTab() {
10 | const [isModalOpen, setIsModalOpen] = useState(false);
11 | const [announcementToDelete, setAnnouncementToDelete] = useState(null);
12 | const [announcements, setAnnouncements] = useState([]);
13 | const [isLoading, setIsLoading] = useState(true);
14 | const [error, setError] = useState(null);
15 | const [newAnnouncement, setNewAnnouncement] = useState({
16 | title: '',
17 | content: '',
18 | });
19 |
20 | const fetchAnnouncementsData = useCallback(async () => {
21 | setIsLoading(true);
22 | setError(null);
23 | try {
24 | const data = await fetchAnnouncements();
25 | setAnnouncements(data);
26 | } catch (error) {
27 | const err = error as ApiError;
28 | setError(err.error);
29 | toast.error(`Error fetching announcements: ${err.error}`);
30 | console.error('Error fetching announcements:', err);
31 | } finally {
32 | setIsLoading(false);
33 | }
34 | }, []);
35 |
36 | useEffect(() => {
37 | fetchAnnouncementsData();
38 | }, [fetchAnnouncementsData]);
39 |
40 | const handleCreateAnnouncement = async (e: React.FormEvent) => {
41 | e.preventDefault();
42 | try {
43 | await createAnnouncement(newAnnouncement);
44 | setNewAnnouncement({ title: '', content: '' });
45 | setIsModalOpen(false);
46 | await fetchAnnouncementsData();
47 | toast.success('Announcement created successfully');
48 | } catch (error) {
49 | const err = error as ApiError;
50 | console.error('Error creating announcement:', err);
51 | toast.error(`Error creating announcement: ${err.error}`);
52 | }
53 | };
54 |
55 | const handleDeleteAnnouncement = async (id: string) => {
56 | try {
57 | await deleteAnnouncement(id);
58 | setAnnouncementToDelete(null);
59 | await fetchAnnouncementsData();
60 | toast.success('Announcement deleted successfully');
61 | } catch (error) {
62 | const err = error as ApiError;
63 | console.error('Error deleting announcement:', err.error);
64 | toast.error(`Error deleting announcement: ${err.error}`);
65 | }
66 | };
67 |
68 | if (isLoading) {
69 | return ;
70 | }
71 |
72 | if (error) {
73 | return Error loading announcements: {error}
;
74 | }
75 |
76 | return (
77 |
78 |
79 |
Announcements
80 | setIsModalOpen(true)}
82 | className="flex items-center px-4 py-2 bg-blue-600 text-white hover:bg-blue-700"
83 | >
84 |
85 | Add Announcement
86 |
87 |
88 |
89 |
90 | {announcements.length === 0 ? (
91 |
No announcements yet
92 | ) : (
93 | announcements.map((announcement) => (
94 |
95 |
96 |
97 |
98 |
{announcement.title}
99 |
{announcement.content}
100 |
101 | Created: {new Date(announcement.createdAt).toLocaleString()}
102 |
103 |
104 |
announcementToDelete?.id === announcement.id
106 | ? handleDeleteAnnouncement(announcement.id)
107 | : setAnnouncementToDelete(announcement)
108 | }
109 | onMouseLeave={() => setAnnouncementToDelete(null)}
110 | className={`shrink-0 px-4 py-2 rounded-md border transition-colors ${
111 | announcementToDelete?.id === announcement.id
112 | ? 'bg-red-600 text-white hover:bg-red-700 border-red-600'
113 | : 'bg-transparent hover:bg-gray-700 border-gray-600 text-gray-300'
114 | }`}
115 | >
116 | {announcementToDelete?.id === announcement.id ? 'Confirm?' : 'Delete'}
117 |
118 |
119 |
120 |
121 | ))
122 | )}
123 |
124 |
125 | {isModalOpen && (
126 |
setIsModalOpen(false)}
132 | submitText="Create Announcement"
133 | />
134 | )}
135 |
136 | );
137 | }
--------------------------------------------------------------------------------
/src/components/admin/SiteConfigurationTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { toast } from 'react-hot-toast';
3 | import LoadingSpinner from '@/components/common/LoadingSpinner';
4 | import { fetchSiteConfigurations, updateSiteConfiguration } from '@/utils/api';
5 | import { ApiError, SiteConfiguration } from '@/types';
6 |
7 | export default function SiteConfigurationTab() {
8 | const [isLoading, setIsLoading] = useState(true);
9 | const [formData, setFormData] = useState>({});
10 |
11 | const configKeys = [
12 | 'site_title',
13 | 'homepage_title',
14 | 'homepage_subtitle',
15 | 'rules_text',
16 | ];
17 |
18 | useEffect(() => {
19 | fetchConfigs();
20 | }, []);
21 |
22 | const fetchConfigs = async () => {
23 | try {
24 | const data = await fetchSiteConfigurations();
25 | setFormData(
26 | data.reduce((acc: Record, config: SiteConfiguration) => {
27 | acc[config.key] = config.value;
28 | return acc;
29 | }, {})
30 | );
31 | setIsLoading(false);
32 | } catch (fetchError) {
33 | const err = fetchError as ApiError;
34 | toast.error(`Error fetching configurations: ${err.error}`);
35 | console.error('Error fetching configs:', fetchError);
36 | setIsLoading(false);
37 | }
38 | };
39 |
40 | const handleSubmit = async (e: React.FormEvent) => {
41 | e.preventDefault();
42 | try {
43 | const updates = Object.entries(formData).map(([key, value]) =>
44 | updateSiteConfiguration(key, value)
45 | );
46 |
47 | await Promise.all(updates);
48 | toast.success('All configurations updated successfully');
49 | fetchConfigs();
50 | } catch (updateError) {
51 | const err = updateError as ApiError;
52 | toast.error(`Error updating configurations: ${err.error}`);
53 | console.error('Error updating configs:', updateError);
54 | }
55 | };
56 |
57 | const handleInputChange = (key: string, value: string) => {
58 | setFormData(prev => ({
59 | ...prev,
60 | [key]: value
61 | }));
62 | };
63 |
64 | if (isLoading) {
65 | return ;
66 | }
67 |
68 | return (
69 |
70 |
71 |
Site Configuration
72 |
73 | {configKeys.map((key) => (
74 |
75 |
76 | {key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
77 |
78 | {key === 'rules_text' ? (
79 |
handleInputChange(key, e.target.value)}
83 | rows={8}
84 | className="mt-1 block w-full bg-gray-700 border-gray-600 rounded text-white focus:border-blue-500 focus:ring-blue-500 p-2"
85 | />
86 | ) : (
87 | handleInputChange(key, e.target.value)}
92 | className="mt-1 block w-full bg-gray-700 border-gray-600 rounded text-white focus:border-blue-500 focus:ring-blue-500 p-2"
93 | />
94 | )}
95 |
96 | {getConfigDescription(key)}
97 |
98 |
99 | ))}
100 |
104 | Save All Configuration
105 |
106 |
107 |
108 |
109 | );
110 | }
111 |
112 | function getConfigDescription(key: string): string {
113 | const descriptions: Record = {
114 | site_title: 'Appears in browser tab and main navigation',
115 | homepage_title: 'Main title displayed on the homepage',
116 | homepage_subtitle: 'Subtitle shown below the main title',
117 | rules_text: 'Competition rules and guidelines (supports Markdown)'
118 | };
119 | return descriptions[key] || '';
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/admin/SubmissionsTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { AdminSubmission, ApiError } from '@/types';
3 | import LoadingSpinner from '@/components/common/LoadingSpinner';
4 | import { fetchAdminSubmissions } from '@/utils/api';
5 |
6 | export default function SubmissionsTab() {
7 | const [submissions, setSubmissions] = useState([]);
8 | const [isLoading, setIsLoading] = useState(true);
9 | const [error, setError] = useState(null);
10 |
11 | const fetchSubmissions = useCallback(async () => {
12 | setIsLoading(true);
13 | setError(null);
14 | try {
15 | const data = await fetchAdminSubmissions();
16 | setSubmissions(data);
17 | } catch (err) {
18 | const error = err as ApiError;
19 | setError(error.error);
20 | console.error('Error fetching submissions:', error);
21 | } finally {
22 | setIsLoading(false);
23 | }
24 | }, []);
25 |
26 | useEffect(() => {
27 | fetchSubmissions();
28 | }, [fetchSubmissions]);
29 |
30 | if (isLoading) {
31 | return ;
32 | }
33 |
34 | if (error) {
35 | return Error loading submissions: {error}
;
36 | }
37 |
38 | return (
39 |
40 |
Submissions
41 |
42 |
43 |
44 |
45 | Time
46 | Team
47 | User
48 | Challenge
49 | Flag
50 | Correct
51 |
52 |
53 |
54 | {submissions.map(sub => (
55 |
56 | {new Date(sub.createdAt).toLocaleString()}
57 |
58 | {sub.team.name}
59 |
60 | {sub.user.alias}
61 | {sub.challenge.title}
62 | {sub.flag}
63 |
64 |
65 | {sub.isCorrect ? 'Yes' : 'No'}
66 |
67 |
68 |
69 | ))}
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/admin/TeamEditModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Team, ApiError } from '@/types'; // Assuming Team type exists here
3 |
4 | interface TeamEditModalProps {
5 | team: Team | null;
6 | isOpen: boolean;
7 | onClose: () => void;
8 | onSave: (updatedTeam: Partial) => Promise; // Function to call API
9 | onDataRefresh?: () => Promise; // Add refresh callback prop
10 | }
11 |
12 | export default function TeamEditModal({ team, isOpen, onClose, onSave, onDataRefresh }: TeamEditModalProps) {
13 | const [name, setName] = React.useState(team?.name || '');
14 | const [icon, setIcon] = React.useState(team?.icon || 'GiSpaceship'); // Default icon from schema
15 | const [color, setColor] = React.useState(team?.color || '#ffffff'); // Default color from schema
16 | const [error, setError] = React.useState(null);
17 | const [isSaving, setIsSaving] = React.useState(false);
18 |
19 | React.useEffect(() => {
20 | if (team) {
21 | setName(team.name || '');
22 | setIcon(team.icon || 'GiSpaceship');
23 | setColor(team.color || '#ffffff');
24 | setError(null);
25 | }
26 | }, [team]);
27 |
28 | const handleSubmit = async (e: React.FormEvent) => {
29 | e.preventDefault();
30 | setError(null);
31 | setIsSaving(true);
32 |
33 | if (!team) return;
34 | if (!name.trim()) {
35 | setError('Team name cannot be empty.');
36 | setIsSaving(false);
37 | return;
38 | }
39 |
40 | try {
41 | await onSave({ id: team.id, name: name.trim(), icon, color });
42 | onClose(); // Close modal on successful save
43 | if (onDataRefresh) {
44 | await onDataRefresh();
45 | }
46 | } catch (err) {
47 | const error = err as ApiError;
48 | setError(error.error || 'Failed to update team.');
49 | } finally {
50 | setIsSaving(false);
51 | }
52 | };
53 |
54 | if (!isOpen || !team) return null;
55 |
56 | return (
57 |
58 |
59 |
Edit Team: {team.name}
60 |
61 |
62 | Team Name
63 | setName(e.target.value)}
68 | className="w-full bg-gray-700 text-white px-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:ring-blue-500"
69 | required
70 | />
71 |
72 |
73 |
Icon Name (React Icons)
74 |
setIcon(e.target.value)}
79 | className="w-full bg-gray-700 text-white px-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:ring-blue-500"
80 | placeholder="e.g., GiRocket, FaFlag, etc."
81 | />
82 |
Enter a valid React Icons name (e.g., from 'react-icons/gi', 'react-icons/fa').
83 |
84 |
85 | Team Color
86 | setColor(e.target.value)}
91 | className="w-full h-10 bg-gray-700 text-white px-1 py-1 rounded border border-gray-600 cursor-pointer"
92 | />
93 |
94 |
95 | {error && (
96 | Error: {error}
97 | )}
98 |
99 |
100 |
106 | Cancel
107 |
108 |
113 | {isSaving ? 'Saving...' : 'Save Changes'}
114 |
115 |
116 |
117 |
118 |
119 | );
120 | }
--------------------------------------------------------------------------------
/src/components/admin/TeamsTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { Team, ApiError } from '@/types';
3 | import TeamEditModal from './TeamEditModal';
4 | import toast from 'react-hot-toast';
5 | import LoadingSpinner from '@/components/common/LoadingSpinner';
6 | import { fetchAdminTeams, deleteTeam, updateTeam } from '@/utils/api';
7 |
8 | export default function TeamsTab() {
9 | const [teamToDelete, setTeamToDelete] = useState(null);
10 | const [isEditModalOpen, setIsEditModalOpen] = useState(false);
11 | const [editingTeam, setEditingTeam] = useState(null);
12 | const [teams, setTeams] = useState([]);
13 | const [isLoading, setIsLoading] = useState(true);
14 | const [error, setError] = useState(null);
15 |
16 | const fetchTeams = useCallback(async () => {
17 | setIsLoading(true);
18 | setError(null);
19 | try {
20 | const data = await fetchAdminTeams();
21 | setTeams(data);
22 | } catch (err) {
23 | const error = err as ApiError;
24 | setError(error.error);
25 | console.error('Error fetching teams:', error);
26 | } finally {
27 | setIsLoading(false);
28 | }
29 | }, []);
30 |
31 | useEffect(() => {
32 | fetchTeams();
33 | }, [fetchTeams]);
34 |
35 | const handleDeleteTeam = async (id: string) => {
36 | try {
37 | await deleteTeam(id);
38 | setTeamToDelete(null);
39 | await fetchTeams();
40 | } catch (error) {
41 | const err = error as ApiError;
42 | console.error('Error deleting team:', error);
43 | toast.error(`Error deleting team: ${err.error}`);
44 | }
45 | };
46 |
47 | const handleEditTeam = (team: Team) => {
48 | setEditingTeam(team);
49 | setIsEditModalOpen(true);
50 | };
51 |
52 | const handleUpdateTeam = async (updatedData: Partial) => {
53 | if (!editingTeam) return;
54 |
55 | try {
56 | await updateTeam(updatedData);
57 | toast.success('Team updated successfully!');
58 | setIsEditModalOpen(false);
59 | setEditingTeam(null);
60 | await fetchTeams();
61 | } catch (err) {
62 | const error = err as ApiError;
63 | console.error('Error updating team:', error);
64 | toast.error(`Error updating team: ${error.error}`);
65 | throw error;
66 | }
67 | };
68 |
69 | if (isLoading) {
70 | return ;
71 | }
72 |
73 | if (error) {
74 | return Error loading teams: {error}
;
75 | }
76 |
77 | return (
78 |
79 | {/* Table Container with horizontal scroll on mobile */}
80 |
81 |
Teams
82 |
83 |
84 |
85 | Name
86 | Code
87 | Score
88 | Members
89 | Actions
90 |
91 |
92 |
93 | {teams.map((team) => (
94 |
95 | {team.name}
96 | {team.code}
97 | {team.score}
98 |
99 |
100 | {team.members.length} members
101 |
102 |
103 |
104 |
105 | handleEditTeam(team)}
107 | className="bg-blue-900 text-blue-300 px-3 py-1 rounded hover:bg-blue-800 transition-colors"
108 | disabled={!!teamToDelete}
109 | >
110 | Edit
111 |
112 | {teamToDelete?.id === team.id ? (
113 | <>
114 | handleDeleteTeam(team.id)}
116 | className="bg-red-900 text-red-300 px-3 py-1 rounded hover:bg-red-800 transition-colors"
117 | >
118 | Confirm
119 |
120 | setTeamToDelete(null)}
122 | className="bg-gray-700 text-gray-300 px-3 py-1 rounded hover:bg-gray-600 transition-colors"
123 | >
124 | Cancel
125 |
126 | >
127 | ) : (
128 | setTeamToDelete(team)}
130 | className="bg-red-900 text-red-300 px-3 py-1 rounded hover:bg-red-800 transition-colors"
131 | >
132 | Delete
133 |
134 | )}
135 |
136 |
137 |
138 | ))}
139 |
140 |
141 |
142 |
143 |
{
147 | setIsEditModalOpen(false);
148 | setEditingTeam(null);
149 | }}
150 | onDataRefresh={fetchTeams}
151 | onSave={handleUpdateTeam}
152 | />
153 |
154 | );
155 | }
--------------------------------------------------------------------------------
/src/components/admin/UserEditModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { User, Team, ApiError } from '@/types'; // Import Team type and ApiError type
3 |
4 | interface UserEditModalProps {
5 | user: User | null;
6 | isOpen: boolean;
7 | onClose: () => void;
8 | onSave: (updatedUser: Partial) => Promise; // Function to call API
9 | onDataRefresh?: () => Promise; // Add refresh callback prop
10 | teams: Team[]; // Pass teams for the dropdown
11 | }
12 |
13 | export default function UserEditModal({ user, isOpen, onClose, onSave, teams, onDataRefresh }: UserEditModalProps) {
14 | const [alias, setAlias] = React.useState(user?.alias || '');
15 | const [name, setName] = React.useState(user?.name || '');
16 | const [selectedTeamId, setSelectedTeamId] = React.useState(user?.teamId || null);
17 | const [isTeamLeader, setIsTeamLeader] = React.useState(user?.isTeamLeader || false);
18 | const [error, setError] = React.useState(null);
19 | const [isSaving, setIsSaving] = React.useState(false);
20 |
21 | // Update state when the user prop changes (e.g., when opening the modal for a different user)
22 | React.useEffect(() => {
23 | if (user) {
24 | setAlias(user.alias || '');
25 | setName(user.name || '');
26 | setSelectedTeamId(user.teamId || null);
27 | setIsTeamLeader(user.isTeamLeader || false);
28 | setError(null);
29 | }
30 | }, [user]);
31 |
32 | const handleSubmit = async (e: React.FormEvent) => {
33 | e.preventDefault();
34 | setError(null);
35 | setIsSaving(true);
36 |
37 | if (!user) return;
38 | const trimmedAlias = alias.trim();
39 | const trimmedName = name.trim();
40 |
41 | if (!trimmedAlias) {
42 | setError('Alias cannot be empty.');
43 | setIsSaving(false);
44 | return;
45 | }
46 | if (!trimmedName) {
47 | setError('Name cannot be empty.');
48 | setIsSaving(false);
49 | return;
50 | }
51 |
52 | try {
53 | await onSave({
54 | id: user.id,
55 | alias: trimmedAlias,
56 | name: trimmedName,
57 | teamId: selectedTeamId,
58 | isTeamLeader: selectedTeamId ? isTeamLeader : false,
59 | });
60 | onClose(); // Close modal on successful save
61 | // Call refresh function if provided
62 | if (onDataRefresh) {
63 | await onDataRefresh();
64 | }
65 | } catch (err) {
66 | const error = err as ApiError;
67 | setError(error.error || 'Failed to update user alias.');
68 | } finally {
69 | setIsSaving(false);
70 | }
71 | };
72 |
73 | if (!isOpen || !user) return null;
74 |
75 | return (
76 |
77 |
78 |
Edit User: {user.name} ({user.alias})
79 |
80 |
81 | Alias
82 | setAlias(e.target.value)}
87 | className="w-full bg-gray-700 text-white px-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:ring-blue-500"
88 | required
89 | />
90 |
91 |
92 |
93 | Name
94 | setName(e.target.value)}
99 | className="w-full bg-gray-700 text-white px-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:ring-blue-500"
100 | required
101 | />
102 |
103 |
104 |
105 | Team
106 | {
110 | const value = e.target.value;
111 | setSelectedTeamId(value === '--no-team--' ? null : value);
112 | // Unset team leader if no team is selected
113 | if (value === '--no-team--') {
114 | setIsTeamLeader(false);
115 | }
116 | }}
117 | className="w-full bg-gray-700 text-white px-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:ring-blue-500"
118 | >
119 | -- No Team --
120 | {teams.map((team) => (
121 |
122 | {team.name}
123 |
124 | ))}
125 |
126 |
127 |
128 |
129 | setIsTeamLeader(e.target.checked)}
134 | disabled={!selectedTeamId}
135 | className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-600 disabled:cursor-not-allowed"
136 | />
137 |
141 | Team Leader
142 |
143 |
144 |
145 | {error && (
146 | Error: {error}
147 | )}
148 |
149 |
150 |
156 | Cancel
157 |
158 |
163 | {isSaving ? 'Saving...' : 'Save Changes'}
164 |
165 |
166 |
167 |
168 |
169 | );
170 | }
--------------------------------------------------------------------------------
/src/components/admin/UsersTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { User, Team, ApiError } from '@/types';
3 | import UserEditModal from './UserEditModal';
4 | import toast from 'react-hot-toast';
5 | import LoadingSpinner from '@/components/common/LoadingSpinner';
6 | import { fetchAdminUsers, deleteAdminUser, updateAdminUser, fetchAdminTeams } from '@/utils/api';
7 |
8 | export default function UsersTab() {
9 | const [userToDelete, setUserToDelete] = useState(null);
10 | const [isEditModalOpen, setIsEditModalOpen] = useState(false);
11 | const [editingUser, setEditingUser] = useState(null);
12 | const [users, setUsers] = useState([]);
13 | const [teams, setTeams] = useState([]);
14 | const [isLoading, setIsLoading] = useState(true);
15 | const [error, setError] = useState(null);
16 |
17 | const fetchUsersAndTeams = useCallback(async () => {
18 | setIsLoading(true);
19 | setError(null);
20 | try {
21 | const [usersData, teamsData] = await Promise.all([
22 | fetchAdminUsers(),
23 | fetchAdminTeams()
24 | ]);
25 |
26 | setUsers(usersData);
27 | setTeams(teamsData);
28 | } catch (err) {
29 | const error = err as ApiError;
30 | setError(error.error);
31 | console.error('Error fetching users or teams:', error);
32 | } finally {
33 | setIsLoading(false);
34 | }
35 | }, []);
36 |
37 | useEffect(() => {
38 | fetchUsersAndTeams();
39 | }, [fetchUsersAndTeams]);
40 |
41 | const handleDeleteUser = async (id: string) => {
42 | try {
43 | await deleteAdminUser(id);
44 | setUserToDelete(null);
45 | await fetchUsersAndTeams();
46 | } catch (error) {
47 | const err = error as ApiError;
48 | console.error('Error deleting user:', err.error);
49 | toast.error(`Error deleting user: ${err.error}`);
50 | }
51 | };
52 |
53 | const handleEditUser = (user: User) => {
54 | setEditingUser(user);
55 | setIsEditModalOpen(true);
56 | };
57 |
58 | const handleUpdateUser = async (updatedData: Partial) => {
59 | if (!editingUser) return;
60 |
61 | try {
62 | await updateAdminUser(updatedData);
63 | toast.success('User updated successfully!');
64 | setIsEditModalOpen(false);
65 | setEditingUser(null);
66 | await fetchUsersAndTeams();
67 | } catch (err) {
68 | const error = err as ApiError;
69 | console.error('Error updating user:', error);
70 | toast.error(`Error updating user: ${error.error}`);
71 | throw error;
72 | }
73 | };
74 |
75 | if (isLoading) {
76 | return ;
77 | }
78 |
79 | if (error) {
80 | return Error loading data: {error}
;
81 | }
82 |
83 | return (
84 |
85 |
Users
86 |
87 |
88 |
89 |
90 | Alias
91 | Name
92 | Team
93 | Role
94 | Actions
95 |
96 |
97 |
98 | {users.map((user) => (
99 |
100 | {user.alias}
101 | {user.name}
102 |
103 | {user.teamId
104 | ? teams.find((t) => t.id === user.teamId)?.name || 'Unknown Team'
105 | : 'No Team'}
106 |
107 |
108 |
115 | {user.isAdmin ? 'Admin' : user.isTeamLeader ? 'Team Leader' : 'Member'}
116 |
117 |
118 |
119 |
120 | handleEditUser(user)}
122 | className="bg-blue-900 text-blue-300 px-3 py-1 rounded hover:bg-blue-800 transition-colors"
123 | disabled={!!userToDelete}
124 | >
125 | Edit
126 |
127 | {userToDelete?.id === user.id ? (
128 | <>
129 | handleDeleteUser(user.id)}
131 | className="bg-red-900 text-red-300 px-3 py-1 rounded hover:bg-red-800 transition-colors"
132 | disabled={user.isAdmin}
133 | >
134 | Confirm
135 |
136 | setUserToDelete(null)}
138 | className="bg-gray-700 text-gray-300 px-3 py-1 rounded hover:bg-gray-600 transition-colors"
139 | >
140 | Cancel
141 |
142 | >
143 | ) : (
144 | setUserToDelete(user)}
146 | className={`px-3 py-1 rounded transition-colors ${
147 | user.isAdmin
148 | ? 'bg-gray-700 text-gray-400 cursor-not-allowed'
149 | : 'bg-red-900 text-red-300 hover:bg-red-800'
150 | }`}
151 | disabled={user.isAdmin}
152 | title={user.isAdmin ? "Cannot delete admin users" : "Delete user"}
153 | >
154 | Delete
155 |
156 | )}
157 |
158 |
159 |
160 | ))}
161 |
162 |
163 |
164 |
165 |
{
171 | setIsEditModalOpen(false);
172 | setEditingUser(null);
173 | }}
174 | onSave={handleUpdateUser}
175 | />
176 |
177 | );
178 | }
--------------------------------------------------------------------------------
/src/components/auth/TeamIconSelection.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState, useCallback, useEffect } from 'react';
2 | import { AiOutlineDoubleLeft, AiOutlineSearch } from 'react-icons/ai';
3 | import { IconType } from 'react-icons';
4 |
5 | interface TeamIconSelectionProps {
6 | selectedIcon: string;
7 | selectedColor: string;
8 | onIconSelect: (iconName: string) => void;
9 | }
10 |
11 | interface IconData {
12 | name: string;
13 | component: IconType | null;
14 | }
15 |
16 | export default function TeamIconSelection({ selectedIcon, selectedColor, onIconSelect }: TeamIconSelectionProps) {
17 | const [searchQuery, setSearchQuery] = useState('');
18 | const [icons, setIcons] = useState([]);
19 | const [loading, setLoading] = useState(false);
20 | const [page, setPage] = useState(1);
21 | const iconsPerPage = 20;
22 |
23 | // Load icons dynamically
24 | useEffect(() => {
25 | const loadIcons = async () => {
26 | setLoading(true);
27 | try {
28 | const iconsModule = await import('react-icons/gi');
29 | const iconNames = Object.keys(iconsModule);
30 | const filteredIcons = iconNames
31 | .filter(name => name.toLowerCase().includes(searchQuery.toLowerCase()))
32 | .map(name => ({
33 | name,
34 | component: iconsModule[name as keyof typeof iconsModule] as IconType
35 | }));
36 | setIcons(filteredIcons);
37 | // Reset to page 1 when search changes
38 | setPage(1);
39 | } catch (error) {
40 | console.error('Error loading icons:', error);
41 | }
42 | setLoading(false);
43 | };
44 |
45 | loadIcons();
46 | }, [searchQuery]);
47 |
48 | // Calculate paginated icons
49 | const paginatedIcons = useMemo(() => {
50 | const start = (page - 1) * iconsPerPage;
51 | return icons.slice(start, start + iconsPerPage);
52 | }, [icons, page]);
53 |
54 | const totalPages = Math.ceil(icons.length / iconsPerPage);
55 |
56 | // Handle navigation
57 | const handlePrevious = useCallback(() => {
58 | setPage(prev => Math.max(1, prev - 1));
59 | }, []);
60 |
61 | const handleNext = useCallback(() => {
62 | setPage(prev => Math.min(totalPages, prev + 1));
63 | }, [totalPages]);
64 |
65 | // Render individual icon
66 | const renderIcon = useCallback((iconData: IconData) => {
67 | const { name, component: Icon } = iconData;
68 | const isSelected = name === selectedIcon;
69 | return Icon ? (
70 | onIconSelect(name)}
75 | title={name}
76 | >
77 |
81 |
82 | ) : null;
83 | }, [selectedIcon, selectedColor, onIconSelect]);
84 |
85 | return (
86 |
87 |
Team Icon
88 |
89 | {/* Search input */}
90 |
91 |
setSearchQuery(e.target.value)}
95 | placeholder="Search icons..."
96 | className="w-full px-4 py-2 pl-10 bg-gray-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
97 | />
98 |
99 |
100 |
101 | {/* Icons grid */}
102 |
103 | {loading ? (
104 |
107 | ) : (
108 |
109 | {paginatedIcons.map(renderIcon)}
110 |
111 | )}
112 |
113 |
114 | {/* Pagination controls */}
115 |
116 |
122 |
123 |
124 |
125 |
126 | Page {page} of {totalPages}
127 |
128 |
129 |
= totalPages}
134 | >
135 |
136 |
137 |
138 |
139 | );
140 | }
--------------------------------------------------------------------------------
/src/components/common/LoadingSpinner.tsx:
--------------------------------------------------------------------------------
1 | interface LoadingSpinnerProps {
2 | size?: 'sm' | 'md' | 'lg';
3 | color?: string;
4 | }
5 |
6 | export default function LoadingSpinner({
7 | size = 'md',
8 | color = 'white'
9 | }: LoadingSpinnerProps) {
10 | const sizeClasses = {
11 | sm: 'h-8 w-8',
12 | md: 'h-12 w-12',
13 | lg: 'h-16 w-16'
14 | };
15 |
16 | return (
17 |
23 | );
24 | }
--------------------------------------------------------------------------------
/src/components/common/TabButton.tsx:
--------------------------------------------------------------------------------
1 | interface TabButtonProps {
2 | active: boolean;
3 | onClick: () => void;
4 | children: React.ReactNode;
5 | }
6 |
7 | export default function TabButton({ active, onClick, children }: TabButtonProps) {
8 | return (
9 |
17 | {children}
18 |
19 | );
20 | }
--------------------------------------------------------------------------------
/src/components/dashboard/Activity.tsx:
--------------------------------------------------------------------------------
1 | import { FaChevronDown, FaChevronUp } from "react-icons/fa";
2 |
3 | interface Team {
4 | id: string;
5 | name: string;
6 | score: number;
7 | color?: string;
8 | }
9 |
10 | interface ActivityLog {
11 | id: string;
12 | type: string;
13 | description: string;
14 | createdAt: string;
15 | team?: Team;
16 | }
17 |
18 | interface ActivityProps {
19 | activities: ActivityLog[];
20 | isOpen: boolean;
21 | setIsOpen: (open: boolean) => void;
22 | isMobile: boolean;
23 | }
24 |
25 | export default function Activity({ activities, isOpen, setIsOpen, isMobile = false }: ActivityProps) {
26 | return (
27 |
28 | {/* Header - only show if not mobile */}
29 | {!isMobile && (
30 |
setIsOpen(!isOpen)}
33 | aria-expanded={isOpen}
34 | >
35 | ACTIVITY
36 | {isOpen ? (
37 |
38 | ) : (
39 |
40 | )}
41 |
42 | )}
43 |
44 |
48 |
49 |
50 | {activities[0] ? (
51 | activities.map((activity) => (
52 |
56 |
57 |
58 |
59 | {activity.type.replace('_', ' ')}
60 |
61 |
62 | {new Date(activity.createdAt).toLocaleTimeString()}
63 |
64 |
65 | {/* Inline team badge in description if present */}
66 |
67 | {activity.team && activity.team.name && activity.description.includes(activity.team.name)
68 | ? (() => {
69 | const teamName = activity.team?.name ?? '';
70 | const teamColor = activity.team?.color;
71 | const parts = activity.description.split(new RegExp(`(${teamName})`, 'g'));
72 | return parts.map((part, i) =>
73 | part === teamName ? (
74 |
81 | {teamName}
82 |
83 | ) : (
84 | part
85 | )
86 | );
87 | })()
88 | : activity.description}
89 |
90 |
91 |
92 | ))
93 | ) : (
94 |
95 | No activity yet
96 |
97 | )}
98 |
99 |
100 |
101 |
102 | );
103 | }
--------------------------------------------------------------------------------
/src/components/dashboard/Announcements.tsx:
--------------------------------------------------------------------------------
1 | import { FaChevronDown, FaChevronUp } from "react-icons/fa";
2 |
3 | interface Announcement {
4 | id: string;
5 | title: string;
6 | content: string;
7 | createdAt: string;
8 | }
9 |
10 | interface AnnouncementsProps {
11 | announcements: Announcement[];
12 | isOpen: boolean;
13 | setIsOpen: (open: boolean) => void;
14 | isMobile: boolean;
15 | }
16 |
17 | export default function Announcements({ announcements, isOpen, setIsOpen, isMobile }: AnnouncementsProps) {
18 | return (
19 |
20 |
21 | {/* Header - only show if not mobile */}
22 | {!isMobile && (
23 |
setIsOpen(!isOpen)}
26 | >
27 | ANNOUNCEMENTS
28 | {isOpen ? (
29 |
30 | ) : (
31 |
32 | )}
33 |
34 | )}
35 |
36 |
41 |
42 | {announcements[0] ? (
43 | announcements.map((announcement) => (
44 |
45 |
{announcement.title}
46 |
{announcement.content}
47 |
48 | {new Date(announcement.createdAt).toLocaleString()}
49 |
50 |
51 | ))
52 | ) : (
53 |
54 | No announcements yet
55 |
56 | )}
57 |
58 |
59 |
60 |
61 | );
62 | }
--------------------------------------------------------------------------------
/src/components/dashboard/Leaderboard.tsx:
--------------------------------------------------------------------------------
1 | import { FaChevronDown, FaChevronUp } from "react-icons/fa";
2 | import * as GiIcons from 'react-icons/gi';
3 | import { IconType } from 'react-icons';
4 | import { LeaderboardTeam } from '@/types';
5 |
6 | interface LeaderboardProps {
7 | teams: LeaderboardTeam[];
8 | currentUserTeam: LeaderboardTeam | null;
9 | isOpen: boolean;
10 | setIsOpen: (open: boolean) => void;
11 | isMobile: boolean;
12 | }
13 |
14 | export default function Leaderboard({ teams, currentUserTeam, isOpen, setIsOpen, isMobile }: LeaderboardProps) {
15 | // Sort teams by score in descending order
16 | const sortedTeams = [...teams].sort((a, b) => b.score - a.score);
17 |
18 | // Find current user's team rank
19 | const userTeamRankIndex = currentUserTeam
20 | ? sortedTeams.findIndex(team => team.id === currentUserTeam.id)
21 | : -1;
22 | const userTeamRank = userTeamRankIndex !== -1 ? userTeamRankIndex + 1 : null;
23 |
24 | // Helper function to get the icon component
25 | const getTeamIcon = (iconName?: string, color?: string) => {
26 | if (!iconName) return null;
27 | const IconComponent: IconType | undefined = (GiIcons as Record)[iconName];
28 | // Only use white outline if color is exactly true black
29 | const isTrueBlack = color === '#000' || color === '#000000';
30 | if (!IconComponent) return null;
31 | if (isTrueBlack) {
32 | // Render white outline by stacking icons
33 | return (
34 |
35 |
39 |
43 |
44 | );
45 | }
46 | return (
47 |
51 | );
52 | };
53 |
54 | return (
55 |
56 |
57 | {/* Fixed header at top - only show if not mobile */}
58 | {!isMobile && (
59 |
setIsOpen(!isOpen)}
62 | >
63 | LEADERBOARD
64 | {isOpen ? (
65 |
66 | ) : (
67 |
68 | )}
69 |
70 | )}
71 |
72 | {/* Content area with scroll */}
73 |
78 |
79 | {/* Current user's team details */}
80 | {currentUserTeam && (
81 |
82 |
83 |
84 | {getTeamIcon(currentUserTeam.icon, currentUserTeam.color)}
85 |
{currentUserTeam.name}
86 |
87 |
88 | {userTeamRank !== null && (
89 |
Rank: #{userTeamRank}
90 | )}
91 |
Score: {currentUserTeam.score} pts
92 |
93 |
94 |
95 | )}
96 |
97 | {/* Divider */}
98 | {currentUserTeam && sortedTeams.length > 0 && (
99 |
100 | )}
101 |
102 | {/* Other teams list */}
103 | {sortedTeams.map((team, index) => (
104 |
105 |
106 | {/* Team icon */}
107 |
108 | {getTeamIcon(team.icon, team.color)}
109 |
110 |
111 |
112 | {team.name}
113 | #{index + 1}
114 |
115 |
{team.score} pts
116 |
117 |
118 |
119 | ))}
120 |
121 |
122 |
123 |
124 | );
125 | }
--------------------------------------------------------------------------------
/src/components/layouts/PageLayout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { Righteous } from 'next/font/google';
3 |
4 | const righteous = Righteous({ weight: '400', subsets: ['latin'] });
5 |
6 | interface PageLayoutProps {
7 | children: ReactNode;
8 | title?: string;
9 | maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '5xl' | '6xl' | '7xl' | '8xl';
10 | showHeaderLine?: boolean;
11 | }
12 |
13 | export default function PageLayout({
14 | children,
15 | title,
16 | maxWidth = '7xl',
17 | showHeaderLine = true
18 | }: PageLayoutProps) {
19 | const maxWidthClasses = {
20 | 'sm': 'max-w-sm',
21 | 'md': 'max-w-md',
22 | 'lg': 'max-w-lg',
23 | 'xl': 'max-w-xl',
24 | '2xl': 'max-w-2xl',
25 | '5xl': 'max-w-5xl',
26 | '6xl': 'max-w-6xl',
27 | '7xl': 'max-w-7xl',
28 | '8xl': 'max-w-8xl'
29 | };
30 |
31 | return (
32 |
33 |
34 |
35 |
36 | {title && (
37 | <>
38 |
39 | {title}
40 |
41 | {showHeaderLine && (
42 |
45 | )}
46 | >
47 | )}
48 | {children}
49 |
50 |
51 |
52 |
53 | );
54 | }
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | export async function register() {
2 | if (process.env.NEXT_RUNTIME === 'nodejs') {
3 | console.log('Running startup tasks...');
4 |
5 | if (process.env.INGEST_CHALLENGES_AT_STARTUP === 'true') {
6 | // Run challenge ingestion
7 | const { ChallengeIngestionService } = await import('@/lib/challenge-ingestion');
8 | const challengeIngestion = new ChallengeIngestionService();
9 | await challengeIngestion.ingestChallenges();
10 | }
11 |
12 | console.log('Startup tasks completed');
13 | }
14 | }
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { NextAuthOptions } from 'next-auth';
2 | import CredentialsProvider from 'next-auth/providers/credentials';
3 | import { prisma } from './prisma';
4 | import bcrypt from 'bcryptjs';
5 |
6 | export const authOptions: NextAuthOptions = {
7 | providers: [
8 | CredentialsProvider({
9 | name: 'Credentials',
10 | credentials: {
11 | alias: { label: 'Alias', type: 'text' },
12 | password: { label: 'Password', type: 'password' },
13 | },
14 | async authorize(credentials) {
15 | if (!credentials?.alias || !credentials?.password) {
16 | return null;
17 | }
18 |
19 | const user = await prisma.user.findUnique({
20 | where: { alias: credentials.alias },
21 | });
22 |
23 | if (!user) {
24 | return null;
25 | }
26 |
27 | const isValid = await bcrypt.compare(credentials.password, user.password);
28 |
29 | if (!isValid) {
30 | return null;
31 | }
32 |
33 | return {
34 | id: user.id,
35 | alias: user.alias,
36 | name: user.name,
37 | isAdmin: user.isAdmin,
38 | teamId: user.teamId || undefined,
39 | isTeamLeader: user.isTeamLeader,
40 | };
41 | },
42 | }),
43 | ],
44 | callbacks: {
45 | async jwt({ token, user }) {
46 | if (user) {
47 | token.id = user.id;
48 | token.alias = user.alias;
49 | token.isAdmin = user.isAdmin;
50 | token.teamId = user.teamId;
51 | token.isTeamLeader = user.isTeamLeader;
52 | }
53 | return token;
54 | },
55 | async session({ session, token }) {
56 | if (session.user) {
57 | session.user.id = token.id as string;
58 | session.user.alias = token.alias as string;
59 | session.user.isAdmin = token.isAdmin as boolean;
60 | session.user.teamId = token.teamId as string | undefined;
61 | session.user.isTeamLeader = token.isTeamLeader as boolean;
62 | }
63 | return session;
64 | },
65 | },
66 | pages: {
67 | signIn: '/auth/signin',
68 | signOut: '/auth/signout',
69 | },
70 | };
--------------------------------------------------------------------------------
/src/lib/challenge-ingestion.ts:
--------------------------------------------------------------------------------
1 | import { promises as fs } from 'fs';
2 | import path from 'path';
3 | import { prisma } from './prisma';
4 | import { NewChallenge } from '@/types';
5 |
6 | interface FileInput {
7 | name: string;
8 | path: string;
9 | size: number;
10 | }
11 |
12 | export class ChallengeIngestionService {
13 | private challengesDir: string;
14 |
15 | constructor() {
16 | this.challengesDir = process.env.CHALLENGES_DIR || path.join(process.cwd(), 'challenges');
17 | }
18 |
19 | private async copyFile(sourcePath: string, destPath: string): Promise {
20 | await fs.mkdir(path.dirname(destPath), { recursive: true });
21 | await fs.copyFile(sourcePath, destPath);
22 | }
23 |
24 | private async processChallengeFiles(
25 | challengeDir: string,
26 | challengeName: string
27 | ): Promise {
28 | const processedFiles: FileInput[] = [];
29 | const filesDir = path.join(challengeDir, 'files');
30 |
31 | try {
32 | // Check if files directory exists
33 | await fs.access(filesDir);
34 |
35 | // Get all files in the directory
36 | const files = await fs.readdir(filesDir);
37 |
38 | for (const fileName of files) {
39 | const sourcePath = path.join(filesDir, fileName);
40 | const destPath = path.join(process.cwd(), 'public', 'uploads', challengeName, fileName);
41 |
42 | try {
43 | const stats = await fs.stat(sourcePath);
44 | if (stats.isFile()) {
45 | await this.copyFile(sourcePath, destPath);
46 | processedFiles.push({
47 | name: fileName,
48 | path: `/uploads/${challengeName}/${fileName}`,
49 | size: stats.size
50 | });
51 | }
52 | } catch (error) {
53 | console.error(`Error processing file ${fileName}:`, error);
54 | }
55 | }
56 | } catch (error) {
57 | // Directory doesn't exist or other error, just return empty array
58 | console.log(`No files directory found for challenge ${challengeName}: ${error}`);
59 | }
60 |
61 | return processedFiles;
62 | }
63 |
64 | private async processChallenge(
65 | categoryDir: string,
66 | challengeName: string
67 | ): Promise {
68 | const challengeDir = path.join(categoryDir, challengeName);
69 | const jsonPath = path.join(challengeDir, 'challenge.json');
70 | const solvePath = path.join(challengeDir, 'solve', 'solve.md');
71 |
72 | try {
73 | const jsonContent = await fs.readFile(jsonPath, 'utf-8');
74 | const challengeData: NewChallenge = JSON.parse(jsonContent);
75 |
76 | // Check if challenge already exists
77 | const existingChallenge = await prisma.challenge.findFirst({
78 | where: {
79 | title: challengeData.title
80 | }
81 | });
82 |
83 | if (existingChallenge) {
84 | console.log(`Challenge "${challengeData.title}" already exists, skipping...`);
85 | return;
86 | }
87 |
88 | // Read solve explanation if it exists
89 | let solveExplanation: string | undefined;
90 | try {
91 | solveExplanation = await fs.readFile(solvePath, 'utf-8');
92 | } catch (error) {
93 | // Solve file doesn't exist, which is fine
94 | console.log(`No solve explanation found for challenge ${challengeName}: ${error}`);
95 | }
96 |
97 | // Process all files in the files directory
98 | const processedFiles = await this.processChallengeFiles(challengeDir, challengeName);
99 |
100 | // Create challenge in database
101 | await prisma.challenge.create({
102 | data: {
103 | title: challengeData.title,
104 | description: challengeData.description,
105 | category: challengeData.category,
106 | points: challengeData.points,
107 | flag: challengeData.multipleFlags ? undefined : challengeData.flag,
108 | multipleFlags: challengeData.multipleFlags || false,
109 | flags: challengeData.flags && challengeData.multipleFlags ? {
110 | create: challengeData.flags.map(flag => ({
111 | flag: flag.flag,
112 | points: flag.points
113 | }))
114 | } : undefined,
115 | difficulty: challengeData.difficulty,
116 | isLocked: challengeData.isLocked || false,
117 | link: challengeData.link,
118 | solveExplanation: solveExplanation,
119 | files: processedFiles.length > 0 ? {
120 | create: processedFiles.map(file => ({
121 | name: file.name,
122 | path: file.path,
123 | size: file.size
124 | }))
125 | } : undefined,
126 | hints: challengeData.hints ? {
127 | create: challengeData.hints.map(hint => ({
128 | content: hint.content,
129 | cost: hint.cost
130 | }))
131 | } : undefined,
132 | unlockConditions: challengeData.unlockConditions ? {
133 | create: challengeData.unlockConditions.map(cond => ({
134 | type: cond.type,
135 | requiredChallengeId: cond.requiredChallengeId,
136 | timeThresholdSeconds: cond.timeThresholdSeconds
137 | }))
138 | } : undefined
139 | }
140 | });
141 |
142 | console.log(`Successfully imported challenge: ${challengeData.title}`);
143 | } catch (error) {
144 | console.error(`Error processing challenge ${challengeName}:`, error);
145 | }
146 | }
147 |
148 | public async ingestChallenges(): Promise {
149 | try {
150 | console.log('Starting challenge ingestion...');
151 | console.log(`Challenges directory: ${this.challengesDir}`);
152 |
153 | // Read all category directories
154 | const categoryDirs = await fs.readdir(this.challengesDir);
155 |
156 | for (const categoryDir of categoryDirs) {
157 | const fullCategoryPath = path.join(this.challengesDir, categoryDir);
158 | const stat = await fs.stat(fullCategoryPath);
159 |
160 | if (stat.isDirectory()) {
161 | // Read all challenge directories within the category
162 | const challengeDirs = await fs.readdir(fullCategoryPath);
163 |
164 | for (const challengeDir of challengeDirs) {
165 | const fullChallengePath = path.join(fullCategoryPath, challengeDir);
166 | const challengeStat = await fs.stat(fullChallengePath);
167 |
168 | if (challengeStat.isDirectory()) {
169 | await this.processChallenge(fullCategoryPath, challengeDir);
170 | }
171 | }
172 | }
173 | }
174 |
175 | console.log('Challenge ingestion completed successfully');
176 | } catch (error) {
177 | console.error('Error during challenge ingestion:', error);
178 | }
179 | }
180 | }
--------------------------------------------------------------------------------
/src/lib/challenges.ts:
--------------------------------------------------------------------------------
1 | import type { UnlockCondition, GameConfig } from '../../prisma/generated/client';
2 |
3 | interface EvaluationResult {
4 | isUnlocked: boolean;
5 | reason?: string; // Optional reason for being locked
6 | }
7 |
8 | export function evaluateUnlockConditions(
9 | conditions: UnlockCondition[],
10 | solvedChallengeIds: Set,
11 | gameConfig: GameConfig | null
12 | ): EvaluationResult {
13 | // If there are no conditions, the challenge is unlocked by default
14 | if (!conditions || conditions.length === 0) {
15 | return { isUnlocked: true };
16 | }
17 |
18 | // Check each condition - ANY fulfilled condition unlocks the challenge
19 | for (const condition of conditions) {
20 | switch (condition.type) {
21 | case 'CHALLENGE_SOLVED':
22 | if (condition.requiredChallengeId && solvedChallengeIds.has(condition.requiredChallengeId)) {
23 | return { isUnlocked: true }; // This condition is met
24 | }
25 | break;
26 | case 'TIME_REMAINDER':
27 | if (gameConfig?.endTime && condition.timeThresholdSeconds !== null) {
28 | const now = new Date();
29 | const endTime = new Date(gameConfig.endTime);
30 | const timeRemainingSeconds = (endTime.getTime() - now.getTime()) / 1000;
31 |
32 | if (timeRemainingSeconds < condition.timeThresholdSeconds) {
33 | return { isUnlocked: true }; // This condition is met
34 | }
35 | }
36 | break;
37 | // Add cases for future condition types here
38 | default:
39 | console.warn(`Unknown unlock condition type: ${condition.type}`);
40 | break;
41 | }
42 | }
43 |
44 | // If no conditions were met after checking all of them, the challenge remains locked.
45 | // TODO: Generate a more descriptive reason string based on unmet conditions.
46 | return { isUnlocked: false, reason: 'Unlock conditions not met.' };
47 | }
--------------------------------------------------------------------------------
/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '../../prisma/generated/client';
2 |
3 | const prisma = new PrismaClient();
4 |
5 | export { prisma };
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getToken } from 'next-auth/jwt';
3 | import { NextRequestWithAuth } from 'next-auth/middleware';
4 |
5 | export async function middleware(request: NextRequestWithAuth) {
6 | const token = await getToken({ req: request });
7 | const isAdminRoute = request.nextUrl.pathname.startsWith('/api/admin');
8 |
9 | if (isAdminRoute) {
10 | if (!token?.isAdmin) {
11 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
12 | }
13 | }
14 |
15 | return NextResponse.next();
16 | }
17 |
18 | export const config = {
19 | matcher: ['/api/admin/:path*'],
20 | };
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | // Base entity for common fields
2 | export interface BaseEntity {
3 | id: string;
4 | createdAt: string;
5 | updatedAt: string;
6 | }
7 |
8 | // ChallengeFlag
9 | export interface ChallengeFlagBase {
10 | flag: string;
11 | points: number;
12 | challengeId?: string;
13 | }
14 |
15 | export interface ChallengeFlag extends ChallengeFlagBase, Partial {
16 | isSolved?: boolean; // For API/UI
17 | }
18 |
19 | // ChallengeFile
20 | export interface ChallengeFile extends BaseEntity {
21 | name: string;
22 | path: string;
23 | size: number;
24 | }
25 |
26 | // Hint
27 | export interface Hint extends BaseEntity {
28 | content?: string;
29 | cost: number;
30 | challengeId: string;
31 | isPurchased?: boolean; // For API/UI
32 | }
33 |
34 | // UnlockCondition
35 | export interface UnlockCondition {
36 | id?: string;
37 | type: 'CHALLENGE_SOLVED' | 'TIME_REMAINDER';
38 | requiredChallengeId?: string | null;
39 | timeThresholdSeconds?: number | null;
40 | }
41 |
42 | // Challenge
43 | export interface Challenge extends BaseEntity {
44 | title: string;
45 | description: string;
46 | category: string;
47 | points: number;
48 | flag?: string;
49 | flags: ChallengeFlag[];
50 | multipleFlags: boolean;
51 | difficulty: string;
52 | isActive: boolean;
53 | isLocked: boolean;
54 | link?: string;
55 | solveExplanation?: string;
56 | files: ChallengeFile[];
57 | hints: Hint[];
58 | unlockConditions: UnlockCondition[];
59 | isSolved?: boolean; // For API/UI
60 | }
61 |
62 | // NewChallenge
63 | export interface NewChallenge {
64 | title: string;
65 | description: string;
66 | category: string;
67 | points: number;
68 | flag?: string;
69 | flags: ChallengeFlag[];
70 | multipleFlags: boolean;
71 | difficulty: string;
72 | isActive?: boolean;
73 | isLocked?: boolean;
74 | link?: string;
75 | solveExplanation?: string;
76 | files: ChallengeFile[];
77 | hints: Hint[];
78 | unlockConditions?: UnlockCondition[];
79 | }
80 |
81 | // Announcement
82 | export interface Announcement extends BaseEntity {
83 | title: string;
84 | content: string;
85 | }
86 |
87 | // NewAnnouncement
88 | export interface NewAnnouncement {
89 | title: string;
90 | content: string;
91 | }
92 |
93 | // SiteConfig
94 | export interface SiteConfig {
95 | id: string;
96 | siteTitle: string;
97 | headerText: string;
98 | }
99 |
100 | // GameConfig
101 | export interface GameConfig extends Partial {
102 | id?: string;
103 | startTime: string | Date | null;
104 | endTime: string | Date | null;
105 | isActive: boolean;
106 | hasEndTime?: boolean;
107 | }
108 |
109 | // ApiError
110 | export interface ApiError extends Error {
111 | error: string;
112 | code?: string;
113 | meta?: {
114 | target?: string[];
115 | };
116 | }
117 |
118 | // User
119 | export interface User {
120 | id: string;
121 | alias: string;
122 | name: string;
123 | isAdmin: boolean;
124 | teamId: string | null;
125 | isTeamLeader: boolean;
126 | }
127 |
128 | // Team
129 | export interface Team {
130 | id: string;
131 | name: string;
132 | code: string;
133 | score: number;
134 | icon?: string;
135 | color?: string;
136 | members: User[];
137 | }
138 |
139 | export interface LeaderboardTeam {
140 | id: string;
141 | name: string;
142 | score: number;
143 | icon: string;
144 | color: string;
145 | }
146 |
147 | // CategoryChallenge
148 | export interface CategoryChallenge {
149 | id: string;
150 | title: string;
151 | isSolved: boolean;
152 | isLocked: boolean;
153 | points: number;
154 | category: string;
155 | solvedBy: { teamId: string; teamColor: string }[];
156 | }
157 |
158 | // CategoryResponse
159 | export interface CategoryResponse {
160 | challenges: CategoryChallenge[];
161 | }
162 |
163 | // TeamMember
164 | export interface TeamMember {
165 | id: string;
166 | alias: string;
167 | name: string;
168 | isTeamLeader: boolean;
169 | }
170 |
171 | // LeaderboardTeam
172 | export interface LeaderboardTeam {
173 | id: string;
174 | name: string;
175 | score: number;
176 | }
177 |
178 | // LeaderboardResponse
179 | export interface LeaderboardResponse {
180 | teams: LeaderboardTeam[];
181 | currentUserTeam: LeaderboardTeam | null;
182 | }
183 |
184 | // ActivityLog
185 | export interface ActivityLog {
186 | id: string;
187 | type: string;
188 | description: string;
189 | createdAt: string;
190 | team?: LeaderboardTeam;
191 | }
192 |
193 | // ScoreboardTeam
194 | export interface ScoreboardTeam {
195 | id: string;
196 | name: string;
197 | color: string;
198 | icon: string;
199 | score: number;
200 | }
201 |
202 | // PointHistory
203 | export interface PointHistory {
204 | id: string;
205 | points: number;
206 | totalPoints: number;
207 | reason: string;
208 | metadata: string | null;
209 | createdAt: string;
210 | }
211 |
212 | export interface PointHistoryResponse {
213 | items: PointHistory[];
214 | }
215 |
216 | // Score
217 | export interface Score {
218 | id: string;
219 | points: number;
220 | createdAt: string;
221 | team: {
222 | id: string;
223 | name: string;
224 | color: string;
225 | icon: string;
226 | };
227 | }
228 |
229 | export interface AdminSubmission {
230 | id: string;
231 | flag: string;
232 | isCorrect: boolean;
233 | createdAt: string;
234 | user: {
235 | id: string;
236 | alias: string;
237 | };
238 | team: {
239 | id: string;
240 | name: string;
241 | color?: string;
242 | icon?: string;
243 | };
244 | challenge: {
245 | id: string;
246 | title: string;
247 | };
248 | }
249 |
250 | // SubmissionResponse
251 | export interface SubmissionResponse {
252 | message: string;
253 | isCorrect: boolean;
254 | points?: number;
255 | }
256 |
257 | // CategoriesResponse
258 | export interface CategoriesResponse {
259 | categories: string[];
260 | challengesByCategory: Record;
261 | }
262 |
263 | // SiteConfiguration
264 | export interface SiteConfiguration {
265 | key: string;
266 | value: string;
267 | }
268 |
269 | // SignUpRequest
270 | export interface SignUpRequest {
271 | name: string;
272 | alias: string;
273 | password: string;
274 | teamOption: 'create' | 'join';
275 | teamName?: string;
276 | teamCode?: string;
277 | teamIcon?: string;
278 | teamColor?: string;
279 | }
280 |
281 | // SignUpResponse
282 | export interface SignUpResponse {
283 | user: {
284 | alias: string;
285 | password: string;
286 | };
287 | }
288 |
289 | export type Tab =
290 | | 'challenges'
291 | | 'users'
292 | | 'teams'
293 | | 'submissions'
294 | | 'announcements'
295 | | 'configuration'
296 | | 'siteconfig';
297 |
298 | export interface RulesResponse {
299 | siteRules: string;
300 | }
--------------------------------------------------------------------------------
/src/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import 'next-auth';
2 |
3 | declare module 'next-auth' {
4 | interface User {
5 | id: string;
6 | alias: string;
7 | name: string;
8 | isAdmin: boolean;
9 | teamId?: string;
10 | isTeamLeader?: boolean;
11 | }
12 |
13 | interface Session {
14 | user: User;
15 | }
16 | }
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: [
5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | primary: '#000000',
13 | secondary: '#1a1a1a',
14 | accent: '#ffffff',
15 | },
16 | backgroundImage: {
17 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
18 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
19 | },
20 | keyframes: {
21 | 'battery-blink': {
22 | '0%, 100%': { backgroundColor: 'transparent' },
23 | '50%': { backgroundColor: 'rgb(252 165 165)' }, // red-500
24 | }
25 | },
26 | animation: {
27 | 'battery-blink': 'battery-blink 4s infinite',
28 | },
29 | screens: {
30 | 'xl': '1150px',
31 | },
32 | },
33 | },
34 | plugins: [],
35 | };
36 |
37 | export default config;
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------