├── .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 | 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 | ![GitHub License](https://img.shields.io/github/license/asynchronous-x/orbital-ctf) 6 | [![Made with Next.js](https://img.shields.io/badge/Made%20with-Next.js-000000?logo=next.js&logoWidth=20)](https://nextjs.org) 7 | [![Powered by Prisma](https://img.shields.io/badge/Powered%20by-Prisma-2D3748?logo=prisma&logoWidth=20)](https://www.prisma.io) 8 | [![Styled with Tailwind](https://img.shields.io/badge/Styled%20with-Tailwind-38B2AC?logo=tailwind-css&logoWidth=20)](https://tailwindcss.com) 9 | 10 | Orbital CTF Logo 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 | [![Watch the demo](./orbital-ctf-promo.gif)](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 |
44 |
45 | 46 | 55 |
56 |
57 | 58 | 67 |
68 | 69 | {error &&
{error}
} 70 | 71 | 77 |
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 | 31 | 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 | 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 |
52 |
Loading...
53 |
54 | ); 55 | } 56 | 57 | return ( 58 | 59 | 60 |
61 |
62 | {/* User Information */} 63 |
64 |

User Information

65 |
66 |
67 | 68 |

{session?.user?.name}

69 |
70 |
71 | 72 |

{session?.user?.alias}

73 |
74 |
75 | 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 | 90 |

{team.name}

91 |
92 |
93 | 94 |
95 | 96 | {team.code} 97 | 98 | 116 |
117 |
118 |
119 | 120 |

{team.score} points

121 |
122 | {team.icon && ( 123 |
124 | 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 | 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'>) =>
    {children}
, 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 |
    23 |
    24 |

    {title}

    25 |
    26 |
    27 | 28 | 32 | setAnnouncement({ ...announcement, title: e.target.value }) 33 | } 34 | className="w-full bg-gray-700 text-white px-3 py-2 rounded" 35 | required 36 | /> 37 |
    38 |
    39 | 40 |