├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── (home) │ ├── Header.tsx │ ├── Prizes.tsx │ ├── Rules.tsx │ ├── Sponsor.tsx │ ├── Sponsors.tsx │ ├── Timer.tsx │ └── page.tsx ├── Equalizer.tsx ├── Footer.tsx ├── LogoutButton.tsx ├── NavBar.tsx ├── NavLink.tsx ├── NavWrapper.tsx ├── ScrollableBackground.tsx ├── admin │ └── challs │ │ └── preview │ │ ├── AdminChallengesPreviewAlert.tsx │ │ └── page.tsx ├── auth │ └── route.ts ├── challenges │ ├── Challenge.tsx │ ├── Challenges.tsx │ ├── DisplayToggle.tsx │ ├── Filters.tsx │ ├── FlagSubmissionInput.tsx │ ├── GridChallenge.tsx │ ├── GridChallengeModal.tsx │ ├── Solve.tsx │ ├── SolvesContent.tsx │ ├── SolvesModal.tsx │ └── page.tsx ├── globals.css ├── icon.svg ├── layout.tsx ├── login-handler │ └── route.ts ├── login │ ├── LoginContent.tsx │ └── page.tsx ├── logout │ └── route.ts ├── profile │ ├── DivisionSelector.tsx │ ├── MyProfileInfo.tsx │ ├── Profile.tsx │ ├── ProfileCard.tsx │ ├── ProfileSolve.tsx │ ├── ProfileSolves.tsx │ ├── ProfileStats.tsx │ ├── TeamInvite.tsx │ ├── UpdateInformation.tsx │ ├── [id] │ │ ├── not-found.tsx │ │ └── page.tsx │ └── page.tsx ├── recover │ ├── RecoverContent.tsx │ └── page.tsx ├── register │ ├── RegisterContent.tsx │ └── page.tsx ├── scoreboard │ ├── Scoreboard.tsx │ ├── ScoreboardContent.tsx │ ├── ScoreboardEntry.tsx │ ├── ScoreboardFilters.tsx │ ├── ScoreboardGraph.tsx │ └── page.tsx └── verify │ ├── VerifyButton.tsx │ └── page.tsx ├── components ├── AnimatedListbox.tsx ├── CTFNotStarted.tsx ├── CenteredModal.tsx ├── FilterProvider.tsx ├── FlagDispatchProvider.tsx ├── IconInput.tsx ├── Pagination.tsx ├── PreferencesProvider.tsx ├── SectionHeader.tsx └── TimeProvider.tsx ├── conf.d ├── 01-ui.yaml ├── 02-ctf.yaml ├── 03-db.yaml ├── 04-email.example.yaml ├── 05-uploads.example.yaml └── 06-secrets.example.yaml ├── contexts ├── CurrentTimeContext.ts ├── FilterContext.ts ├── FlagDispatchContext.ts └── PreferencesContext.ts ├── docker-compose.yml ├── hooks ├── useIsMounted.ts └── useScroll.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── assets │ ├── background.jpg │ ├── background.webp │ ├── background2.webp │ ├── background3.webp │ ├── logo-new.png │ ├── logo-uwu.png │ ├── logo.svg │ ├── sponsors │ ├── CERIAS.png │ ├── blackwired_combined.png │ ├── caesar-creek.jpg │ ├── google-cloud.png │ ├── ottersec.svg │ └── zellic.svg │ └── videos │ ├── failed-vp9-chrome.webm │ ├── failed1-chrome.webm │ ├── failed1-safari.mov │ ├── failed2-chrome.webm │ ├── failed2-safari.mov │ ├── failed3-chrome.webm │ ├── failed3-safari.mov │ ├── failed4-chrome.webm │ ├── failed4-safari.mov │ ├── failed5-chrome.webm │ ├── failed5-safari.mov │ ├── failed6-chrome.webm │ ├── failed6-safari.mov │ ├── special-chrome.webm │ ├── special-safari.mov │ ├── special2-chrome.webm │ ├── special2-safari.mov │ ├── success1-chrome.webm │ ├── success1-safari.mov │ ├── success2-chrome.webm │ ├── success2-safari.mov │ ├── success3-chrome.webm │ ├── success3-safari.mov │ ├── success4-chrome.webm │ ├── success4-safari.mov │ ├── success5-chrome.webm │ ├── success5-safari.mov │ ├── success6-chrome.webm │ └── success6-safari.mov ├── tailwind.config.ts ├── tsconfig.json └── util ├── admin.ts ├── auth.ts ├── challenges.ts ├── config.ts ├── errors.ts ├── flags.ts ├── profile.ts ├── random.ts ├── scoreboard.ts ├── solves.ts └── strings.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | .idea 20 | 21 | # rCTF 22 | /data 23 | /conf.d/04-email.yaml 24 | /conf.d/05-uploads.yaml 25 | /conf.d/06-secrets.yaml 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | .env* 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN npm i && npm run build 6 | 7 | CMD ["npm", "start"] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kevin Yu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bctf 2 | 3 | The updated website for b01lers CTF! 4 | 5 | This website was heavily inspired by [LA CTF](https://platform.lac.tf/) and the default rCTF frontend. 6 | 7 | ### Running locally 8 | 9 | This is a custom Next.js frontend wrapping [rCTF](https://rctf.redpwn.net/) as a backend. To configure rCTF, edit the 10 | config files in `/conf.d` [as normal](https://rctf.redpwn.net/configuration/). In `conf.d`, 11 | 12 | - `01-ui.yaml` defines rCTF's UI config values. These values are mostly ignored by the custom frontend, but necessary 13 | for styling certain things that we don't have full control over (ex. the email template). 14 | - `02-ctf.yaml` defines metadata for the actual CTF, including divisions, start / end time, and the frontend URL. 15 | - `03-db.yaml` defines config options for rCTF's underlying databases. 16 | - `04-email.yaml` defines config options for email verification. This includes an API key so isn't committed, but an 17 | example is included in `04-email.example.yaml`. 18 | - `05-uploads.yaml` defines config options for GCS uploads. This includes a private key and service account email so 19 | isn't committed, but an example is included in `05-uploads.example.yaml`. 20 | - `06-secrets.yaml` defines secret rCTF values such as the token key. 21 | 22 | To run just the frontend, first install dependencies with 23 | ```bash 24 | npm i 25 | ``` 26 | In `next.config.js`, set `RCTF_BASE` to the public URL of the backend rCTF instance, and `KLODD_URL` to the public URL of 27 | the Klodd instancer frontend: 28 | ```js 29 | const RCTF_BASE = 'http://ctf.b01lers.com:9000'; 30 | 31 | const nextConfig = { 32 | env: { 33 | API_BASE: `${RCTF_BASE}/api/v1`, 34 | KLODD_URL: 'https://klodd.localhost.direct' 35 | }, 36 | // ... 37 | } 38 | ``` 39 | Then, run 40 | ```bash 41 | npm run dev 42 | ``` 43 | to start the development server on `localhost:3000`. 44 | 45 | To start rCTF, you'll need a `.env` file in the project root exporting database credentials: 46 | ```env 47 | RCTF_DATABASE_PASSWORD=... 48 | RCTF_REDIS_PASSWORD=... 49 | RCTF_GIT_REF=master 50 | ``` 51 | (this file can be copied after running [rCTF's install script](https://rctf.redpwn.net/installation/)). 52 | 53 | You can then start both the rCTF backend and production frontend instance simultaneously with 54 | ```bash 55 | docker compose up -d --build 56 | ``` 57 | 58 | ### Non-standard properties (experimental) 59 | On top of supporting all the standard `Challenge` object fields provided by rCTF, this frontend also supports a subset 60 | of non-standard fields if they are present; see [`b01lers/rctf-deploy-action`](https://github.com/b01lers/rctf-deploy-action) 61 | for a complete list. 62 | 63 | The underlying mechanism for this is that: 64 | - Challenges are created via `PUT` requests to `/api/v1/admin/challs/{id}` on the rCTF backend with the challenge 65 | metadata as a JSON body, and the JSON data is stored in the challenge database as-is (extra properties included). 66 | - When non-admin users fetch `/api/v1/challs`, however, challenges are [cleaned to only return rCTF's standard properties](https://github.com/redpwn/rctf/blob/master/server/api/challs/get.js#L15) 67 | (see the clean function [here](https://github.com/redpwn/rctf/blob/master/server/challenges/index.ts#L16)). 68 | 69 | Then, this frontend fetches the admin challenges endpoint and manually injects (or otherwise handles) any additional 70 | properties before returning them to the client. To this end, if you want to support `prereqs` or other non-standard rCTF 71 | properties in your deployment, make sure you have an `env` file exporting an admin auth token like so: 72 | ```env 73 | ADMIN_TOKEN=... 74 | ``` 75 | If you want to customize which additional properties are supported, see the [challenges page](https://github.com/ky28059/bctf/blob/main/app/challenges/page.tsx). 76 | 77 | ### Configuring 78 | Further config options can be edited in `/util/config.ts`: 79 | ```ts 80 | export const SOLVES_PAGE_SIZE = 10; 81 | export const SCOREBOARD_PAGE_SIZE = 100; 82 | 83 | export const AUTH_COOKIE_NAME = 'ctf_clearance'; 84 | ``` 85 | - `SOLVES_PAGE_SIZE` — The number of solves to show on each page of the solves modal. 86 | - `SCOREBOARD_PAGE_SIZE` — The number of teams to show on each page of the scoreboard. 87 | - `AUTH_COOKIE_NAME` — The name of the auth token cookie. 88 | -------------------------------------------------------------------------------- /app/(home)/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Hubot_Sans, Martian_Mono } from 'next/font/google'; 2 | import Timer from '@/app/(home)/Timer'; 3 | 4 | // Utils 5 | import { getConfig } from '@/util/config'; 6 | 7 | // Icons 8 | import { BsChevronCompactDown } from 'react-icons/bs'; 9 | import { AiFillFlag } from 'react-icons/ai'; 10 | 11 | 12 | const hubot = Hubot_Sans({ subsets: ['latin'], weight: '700' }); 13 | const martian = Martian_Mono({ subsets: ['latin'], weight: '600' }); 14 | 15 | export default async function Header() { 16 | const config = await getConfig(); 17 | 18 | return ( 19 |
20 | {/* 21 | 25 | */} 26 | 27 |

31 | b01lers CTF 32 | 33 |

34 |
35 | 39 |
40 |

41 | b01lers CTF is the public competitive CTF hosted by the b01lers CTF team at Purdue University. 42 | Join our discord at discord.gg/tBMqujE{' '} 43 | and look out for further info soon! 44 |

45 | 49 | 50 | 51 | 52 | Jump to Rules 53 | 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/(home)/Prizes.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import SectionHeader from '@/components/SectionHeader'; 3 | 4 | 5 | export default function Prizes() { 6 | const openDivisionPrizes = ['$600', '$300', '$100']; 7 | 8 | return ( 9 | <> 10 | 11 | Prizes 12 | 13 | 14 |
15 | 16 | {openDivisionPrizes.map((p, i) => ( 17 |
18 |
{i + 1}.
19 |
{p}
20 |
21 | ))} 22 |
23 |
24 | 25 |

26 | There will also be a $100 prize for the top 5 challenge writeups submitted after the competition. 27 |

28 | 29 |

30 | Prize transfers can only be arranged with entities in the United States. 31 |

32 | 33 | ) 34 | } 35 | 36 | function PrizeTable(props: { children: ReactNode, division: string }) { 37 | return ( 38 |
39 |
40 |
41 |
#
42 |
43 | {props.division} division prizes 44 |
45 |
46 |
47 | 48 | {props.children} 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/(home)/Rules.tsx: -------------------------------------------------------------------------------- 1 | import SectionHeader from '@/components/SectionHeader'; 2 | 3 | 4 | export default function Rules() { 5 | return ( 6 | <> 7 | 8 | Rules 9 | 10 |
    11 |
  1. 12 | During the competition, each person may only be a part of one team total, and only members of a 13 | given team may assist in solving a challenge for that team. 14 |
  2. 15 |
  3. 16 | Each team must have a valid email address that should serve as the point of contact. 17 |
  4. 18 |
  5. 19 | There is no limit on team size, and teams can be from anywhere. 20 |
  6. 21 | {/* 22 |
  7. 23 | For the Purdue division: teams must be composed of current Purdue students to be 24 | eligible for prizes, and there is a maximum team size of 4. Sign up with a @purdue.edu email to 25 | gain access to the Purdue division. 26 |
  8. 27 | */} 28 |
  9. 29 | Flags are of the format{' '} 30 | {'bctf{[ -~]+}'}{' '} 31 | unless otherwise noted on the challenge description. No brute-force guessing flags. 32 |
  10. 33 |
  11. 34 | No flag or hint sharing. Do not solicit or accept hints or guidance from any person except through 35 | official support channels. 36 |
  12. 37 |
  13. 38 | Do not attempt to attack or interfere with other teams or any servers used in this competition that 39 | are not explicitly designated for being hacked in a problem. 40 |
  14. 41 |
  15. 42 | Do not perform any sort of online bruteforce against any of our systems. 43 |
  16. 44 |
  17. Learn as much as you can, and have a good time!
  18. 45 |
  19. Pay it forward.
  20. 46 |
47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /app/(home)/Sponsor.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | 4 | type SponsorProps = { 5 | href: string, 6 | src: string, 7 | name: string, 8 | children: ReactNode 9 | } 10 | export default function Sponsor(props: SponsorProps) { 11 | return ( 12 |
13 | 19 | {props.name} 24 |
25 |

{props.name}

26 |

{/* TODO: hacky? */} 27 | {props.children} 28 |

29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/(home)/Sponsors.tsx: -------------------------------------------------------------------------------- 1 | import Sponsor from '@/app/(home)/Sponsor'; 2 | import SectionHeader from '@/components/SectionHeader'; 3 | 4 | 5 | export default function Sponsors() { 6 | return ( 7 |
8 | 9 | Sponsors 10 | 11 | 12 | 17 | Zellic is a security research firm. We hire top CTF talent to solve the world's most critical security problems. We specialize in ZKPs, cryptography, web app security, smart contracts, and blockchain L1/L2s. Before Zellic, we previously founded perfect blue, the #1 CTF team in 2020 and 2021. You're a good fit for Zellic if you have extensive real-world experience in vulnerability research (VR) / binary exploitation, reverse engineering (RE), cryptography, or web application security. We hire internationally and offer competitive salaries and a comprehensive benefits package. 18 |
19 |
20 | To learn more about Zellic, check out our blog:
https://www.zellic.io/blog/the-auditooor-grindset 21 |
22 | Work at Zellic: jobs@zellic.io | @gf_256 23 | 24 | 25 | 30 | OtterSec secures critical blockchain infrastructure — from custom compilers to novel virtual machines, 31 | we review a wide range of difficult targets. Our team consists largely of CTF players that enjoy solving 32 | hard problems. If that sounds like you, please apply through our careers page. 33 | 34 | 35 | 40 | Infra sponsored by goo.gle/ctfsponsorship. 41 | 42 | 43 | 48 | Caesar Creek Software works with various government agencies to perform cyber research into major operating 49 | system platforms, software security products, personal computers, cell phones, networking equipment, and 50 | IoT devices. We specialize in offensive information operations, reverse engineering, vulnerability analysis, 51 | and exploit development. We have a robust Internal Research and Development program that lets us do cool 52 | stuff on our own. If it has a processor, we love taking it apart to see what makes it tick. We offer a 53 | highly competitive compensation package including one of the best benefit packages in Ohio and starting 54 | in 2019 we are an employee-owned company. U.S. citizenship is required for all positions, as well as the 55 | ability to obtain a high-level security clearance. 56 | 57 | 58 | 63 | Blackwired's solutions are designed to shield what matters most to your organization. Our cyber 64 | solutions deliver enterprises, governments, and managed service providers an unparalleled balance of 65 | security and value. ThirdWatch, a Blackwired solution, provides an intuitive, real-time command center 66 | that maps Direct Threat Risk Management while consolidating and prioritizing attack vectors. Designed 67 | for rapid decision making, it transforms complex security data into a refined, actionable visual 68 | interface, ensuring you stay ahead of emerging threats with unmatched clarity and control. Unlike 69 | traditional security solutions that rely on intrusive scans or reactive measures, ThirdWatch monitors 70 | potential hazards in real-time, providing organizations with preventative and comprehensive 360° and 3D 71 | visualization of their risk exposure. 72 | 73 | 74 | {/* 75 | 80 | The Center for Education and Research in Information Assurance and Security (CERIAS), a cross-cutting 81 | institute at Purdue University, is the world’s foremost interdisciplinary academic center for cyber and 82 | cyber-physical systems; more than a hundred researchers addressing issues of security, privacy, resiliency, 83 | trusted electronics, autonomy and explainable artificial intelligence. CERIAS brings together world-class 84 | faculty, students and industry partners to design, build and maintain trusted cyber/cyber-physical systems. 85 | CERIAS serves as an unbiased resource to the worldwide community. 86 | 87 | */} 88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /app/(home)/Timer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext } from 'react'; 4 | import { Reddit_Mono } from 'next/font/google'; 5 | import { DateTime } from 'luxon'; 6 | import { useIsMounted } from '@/hooks/useIsMounted'; 7 | import CurrentTimeContext from '@/contexts/CurrentTimeContext'; 8 | 9 | 10 | const martian = Reddit_Mono({ subsets: ['latin'], weight: '500' }); 11 | 12 | type TimerProps = { 13 | startTime: number, 14 | endTime: number 15 | } 16 | export default function Timer(props: TimerProps) { 17 | const time = useContext(CurrentTimeContext); 18 | 19 | const ctfStart = DateTime.fromMillis(props.startTime); 20 | const ctfEnd = DateTime.fromMillis(props.endTime); 21 | 22 | // To prevent hydration errors 23 | const mounted = useIsMounted(); 24 | 25 | // If the CTF is over 26 | if (time > ctfEnd) return ( 27 |
28 |
32 | 00 33 | : 34 | 00 35 | : 36 | 00 37 | : 38 | 00 39 |
40 |

41 | b01lers CTF is over! 42 |

43 |
44 | ); 45 | 46 | const diff = time > ctfStart 47 | ? ctfEnd.diff(time, ['days', 'hours', 'minutes', 'seconds']) 48 | : ctfStart.diff(time, ['days', 'hours', 'minutes', 'seconds']); 49 | 50 | return ( 51 |
52 |
56 | {!mounted ? '00' : diff.days.toString().padStart(2, '0')} 57 | : 58 | {!mounted ? '00' : diff.hours.toString().padStart(2, '0')} 59 | : 60 | {!mounted ? '00' : diff.minutes.toString().padStart(2, '0')} 61 | : 62 | {!mounted ? '00' : Math.floor(diff.seconds).toString().padStart(2, '0')} 63 |
64 |

65 | {time > ctfStart ? ( 66 | 'left until b01lers CTF ends.' 67 | ) : ( 68 | 'days until b01lers CTF.' 69 | )} 70 |

71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@/app/(home)/Header'; 2 | import Sponsors from '@/app/(home)/Sponsors'; 3 | import Rules from '@/app/(home)/Rules'; 4 | import Prizes from '@/app/(home)/Prizes'; 5 | 6 | 7 | export default async function Home() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/Equalizer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext, useEffect, useRef } from 'react'; 4 | import PreferencesContext from '@/contexts/PreferencesContext'; 5 | 6 | 7 | export default function Equalizer() { 8 | const { preferences } = useContext(PreferencesContext); 9 | const canvasRef = useRef(null); 10 | 11 | function draw(dt: number) { 12 | const canvas = canvasRef.current; 13 | if (!canvas) return; 14 | 15 | const ctx = canvas.getContext('2d')!; 16 | 17 | dt = dt / 1000; 18 | requestAnimationFrame(draw); 19 | 20 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 21 | 22 | const margin = 10; 23 | const width = 50; 24 | const maxHeight = .2 * ctx.canvas.height; 25 | const minHeight = 20; 26 | const maxFrequency = 1.0; 27 | const minFrequency = 0.8; 28 | 29 | let i = 0; 30 | for (let x = margin; x < canvas.width + width + margin; x += width + margin) { 31 | const amp = lerp(minHeight, maxHeight, random(i)); 32 | const freq = lerp(minFrequency, maxFrequency, random(i + 100)) * 2 * Math.PI; 33 | const phase = lerp(0, 2 * Math.PI, random(i + 200)); 34 | const height = amp * (0.5 * Math.sin(freq * dt + phase) + 0.5); 35 | 36 | let col0 = '#ff1e1e'; 37 | col0 = changeHue(col0, lerp(-10, 10, x / ctx.canvas.width)); 38 | ctx.fillStyle = col0; 39 | 40 | ctx.fillRect(x, ctx.canvas.height - height, width, height); 41 | i += 1; 42 | } 43 | } 44 | 45 | useEffect(() => { 46 | if (!preferences.animations) return; 47 | 48 | const canvas = canvasRef.current; 49 | if (!canvas) return; 50 | 51 | // Start the animation 52 | requestAnimationFrame(draw); 53 | 54 | const onResize = () => { 55 | const rect = canvas.getBoundingClientRect(); 56 | canvas.width = rect.right - rect.left; 57 | canvas.height = rect.bottom - rect.top; 58 | } 59 | onResize(); 60 | 61 | window.addEventListener('resize', onResize); 62 | return () => window.removeEventListener('resize', onResize); 63 | }, [canvasRef, preferences.animations]); 64 | 65 | if (!preferences.animations) return null; 66 | 67 | return ( 68 | 72 | ) 73 | } 74 | 75 | function lerp(from: number, to: number, f: number) { 76 | return (1.0 - f) * from + f * to; 77 | } 78 | 79 | function fract(x: number) { 80 | return Math.abs(x) - Math.trunc(Math.abs(x)); 81 | } 82 | 83 | const seed = Math.random(); 84 | function random(s: number) { 85 | return fract(Math.sin(s + 78.233) * 43758.5453 * (seed + 1)); 86 | } 87 | 88 | function changeHue(rgb: string, degree: number) { 89 | const hsl = rgbToHSL(rgb); 90 | hsl.h += degree; 91 | if (hsl.h > 360) { 92 | hsl.h -= 360; 93 | } else if (hsl.h < 0) { 94 | hsl.h += 360; 95 | } 96 | return hslToRGB(hsl); 97 | } 98 | 99 | // expects a string and returns an object 100 | function rgbToHSL(rgb: string) { 101 | // strip the leading # if it's there 102 | rgb = rgb.replace(/^\s*#|\s*$/g, ''); 103 | 104 | // convert 3 char codes --> 6, e.g. `E0F` --> `EE00FF` 105 | if (rgb.length === 3) { 106 | rgb = rgb.replace(/(.)/g, '$1$1'); 107 | } 108 | 109 | const r = parseInt(rgb.slice(0, 2), 16) / 255, 110 | g = parseInt(rgb.slice(2, 4), 16) / 255, 111 | b = parseInt(rgb.slice(4, 6), 16) / 255, 112 | cMax = Math.max(r, g, b), 113 | cMin = Math.min(r, g, b), 114 | delta = cMax - cMin; 115 | 116 | let l = (cMax + cMin) / 2, 117 | h = 0, 118 | s = 0; 119 | 120 | if (delta == 0) { 121 | h = 0; 122 | } else if (cMax == r) { 123 | h = 60 * (((g - b) / delta) % 6); 124 | } else if (cMax == g) { 125 | h = 60 * (((b - r) / delta) + 2); 126 | } else { 127 | h = 60 * (((r - g) / delta) + 4); 128 | } 129 | 130 | if (delta == 0) { 131 | s = 0; 132 | } else { 133 | s = (delta / (1 - Math.abs(2 * l - 1))); 134 | } 135 | 136 | return { h, s, l } satisfies HSL 137 | } 138 | 139 | // expects an object and returns a string 140 | type HSL = { 141 | h: number, 142 | s: number, 143 | l: number 144 | } 145 | 146 | function hslToRGB(hsl: HSL) { 147 | const { h, s, l } = hsl; 148 | const c = (1 - Math.abs(2 * l - 1)) * s; 149 | const x = c * (1 - Math.abs((h / 60) % 2 - 1)); 150 | const m = l - c / 2; 151 | 152 | let r, g, b; 153 | if (h < 60) { 154 | r = c; 155 | g = x; 156 | b = 0; 157 | } else if (h < 120) { 158 | r = x; 159 | g = c; 160 | b = 0; 161 | } else if (h < 180) { 162 | r = 0; 163 | g = c; 164 | b = x; 165 | } else if (h < 240) { 166 | r = 0; 167 | g = x; 168 | b = c; 169 | } else if (h < 300) { 170 | r = x; 171 | g = 0; 172 | b = c; 173 | } else { 174 | r = c; 175 | g = 0; 176 | b = x; 177 | } 178 | 179 | r = normalize_rgb_value(r, m); 180 | g = normalize_rgb_value(g, m); 181 | b = normalize_rgb_value(b, m); 182 | 183 | return rgbToHex(r, g, b); 184 | } 185 | 186 | function normalize_rgb_value(color: number, m: number) { 187 | return Math.max( 188 | Math.floor((color + m) * 255), 189 | 0 190 | ); 191 | } 192 | 193 | function rgbToHex(r: number, g: number, b: number) { 194 | return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 195 | } 196 | -------------------------------------------------------------------------------- /app/Footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext } from 'react'; 4 | import PreferencesContext from '@/contexts/PreferencesContext'; 5 | 6 | 7 | export default function Footer() { 8 | const { preferences, setPreferences } = useContext(PreferencesContext); 9 | 10 | function toggleAnimations() { 11 | preferences.animations = !preferences.animations; 12 | setPreferences({ ...preferences }); 13 | } 14 | 15 | return ( 16 |
17 |

18 | b01lers CTF platform - 19 | Backend powered by rCTF - 20 | Frontend made with 🤍 by ky28059 21 |

22 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { logout } from '@/util/auth'; 6 | 7 | // Components 8 | import CenteredModal from '@/components/CenteredModal'; 9 | 10 | 11 | export default function LogoutButton() { 12 | const [open, setOpen] = useState(false); 13 | const router = useRouter(); 14 | 15 | async function logoutCallback() { 16 | setOpen(false); 17 | await logout(); 18 | router.refresh(); 19 | } 20 | 21 | return ( 22 | <> 23 | 29 | 30 | 35 |

36 | Log out 37 |

38 |

39 | This will log you out on your current device. 40 |

41 | 42 |
43 | 49 | 50 | 56 |
57 |
58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | 3 | // Components 4 | import NavLink from '@/app/NavLink'; 5 | import NavWrapper from '@/app/NavWrapper'; 6 | import LogoutButton from '@/app/LogoutButton'; 7 | 8 | // Utils 9 | import { AUTH_COOKIE_NAME } from '@/util/config'; 10 | 11 | 12 | export default async function NavBar() { 13 | const c = await cookies(); 14 | const authed = c.has(AUTH_COOKIE_NAME); 15 | 16 | return ( 17 | 18 | Home 19 | Scoreboard 20 | 21 | {authed ? ( 22 | <> 23 | Challenges 24 | Profile 25 | 26 | 27 | ) : ( 28 | <> 29 | Register 30 | Login 31 | 32 | )} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/NavLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ReactNode } from 'react'; 4 | import Link from 'next/link'; 5 | import { usePathname } from 'next/navigation'; 6 | 7 | 8 | type NavLinkProps = { 9 | href: string, 10 | children: ReactNode 11 | } 12 | export default function NavLink(props: NavLinkProps) { 13 | const pathname = usePathname(); 14 | const active = pathname === props.href; 15 | 16 | return ( 17 | 21 | {props.children} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/NavWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ReactNode } from 'react'; 4 | import { useScroll } from '@/hooks/useScroll'; 5 | 6 | 7 | export default function NavWrapper(props: { children: ReactNode }) { 8 | const scroll = useScroll(); 9 | 10 | return ( 11 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/ScrollableBackground.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext } from 'react'; 4 | import PreferencesContext from '@/contexts/PreferencesContext'; 5 | 6 | 7 | export default function ScrollableBackground() { 8 | const { preferences } = useContext(PreferencesContext); 9 | 10 | return ( 11 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/admin/challs/preview/AdminChallengesPreviewAlert.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | 4 | export default function AdminChallengesPreviewAlert() { 5 | return ( 6 |
7 |

You are previewing the challenges page as an admin.

8 |

9 | Return to challenges 10 |

11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/admin/challs/preview/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | // Components 5 | import Filters from '@/app/challenges/Filters'; 6 | import Challenges from '@/app/challenges/Challenges'; 7 | import DisplayToggle from '@/app/challenges/DisplayToggle'; 8 | import AdminChallengesPreviewAlert from '@/app/admin/challs/preview/AdminChallengesPreviewAlert'; 9 | 10 | // Utils 11 | import { AUTH_COOKIE_NAME } from '@/util/config'; 12 | import { getAdminChallenges } from '@/util/admin'; 13 | 14 | 15 | export default async function AdminChallengesPreview() { 16 | const c = await cookies(); 17 | 18 | const token = c.get(AUTH_COOKIE_NAME)?.value; 19 | if (!token) return redirect('/'); 20 | 21 | const challenges = await getAdminChallenges(token); 22 | if (challenges.kind !== 'goodChallenges') 23 | return redirect('/'); 24 | 25 | // Map admin challenges to other challenge format 26 | const parsed = challenges.data.map(c => ({ ...c, points: c.points.max })); 27 | 28 | return ( 29 |
30 | 31 | 32 | 36 | 40 | 41 | 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { cookies } from 'next/headers'; 3 | import { AUTH_COOKIE_NAME } from '@/util/config'; 4 | 5 | 6 | const ALLOWED_REDIRECTS = [`${process.env.KLODD_URL}/auth`]; 7 | 8 | /** 9 | * "pseudo-oauth" functionality for Klodd. 10 | * See {@link https://klodd.tjcsec.club/install-guide/prerequisites/}. 11 | */ 12 | export async function GET(req: NextRequest) { 13 | const c = await cookies(); 14 | const token = c.get(AUTH_COOKIE_NAME)?.value; 15 | 16 | const params = req.nextUrl.searchParams; 17 | const state = params.get('state'); 18 | const uri = params.get('redirect_uri'); 19 | 20 | if (!token || !state || !uri || !ALLOWED_REDIRECTS.includes(uri)) 21 | return NextResponse.redirect(new URL('/login', req.url)); 22 | 23 | return NextResponse.redirect(`${uri}?state=${encodeURIComponent(state)}&token=${encodeURIComponent(token)}`); 24 | } 25 | -------------------------------------------------------------------------------- /app/challenges/Challenge.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react'; 4 | import Markdown from 'react-markdown'; 5 | import { BiCheck } from 'react-icons/bi'; 6 | 7 | // Components 8 | import SolvesModal from '@/app/challenges/SolvesModal'; 9 | import FlagSubmissionInput from '@/app/challenges/FlagSubmissionInput'; 10 | 11 | // Utils 12 | import type { Challenge } from '@/util/challenges'; 13 | 14 | 15 | export default function Challenge(props: Challenge & { solved: boolean }) { 16 | const [showSolves, setShowSolves] = useState(false); 17 | 18 | return ( 19 |
20 |
21 |

22 | {props.category}/{props.name} 23 | {props.tags && props.tags.length > 0 && ( 24 |
25 | {props.tags.map((t) => ( 26 | 27 | {t} 28 | 29 | ))} 30 |
31 | )} 32 |

33 | 34 | {props.solved && ( 35 | 36 | )} 37 | 38 | 46 |
47 |

48 | {props.author} 49 |

50 | 51 |
52 | 53 | 54 | {props.description} 55 | 56 | 57 | 61 | 62 | {props.files.length > 0 && ( 63 |
64 | {props.files.map((f) => ( 65 | 70 | {f.name} 71 | 72 | ))} 73 |
74 | )} 75 | 76 | 81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /app/challenges/Challenges.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext, useMemo } from 'react'; 4 | 5 | // Components 6 | import Challenge from '@/app/challenges/Challenge'; 7 | import GridChallenge from '@/app/challenges/GridChallenge'; 8 | 9 | // Contexts 10 | import FilterContext from '@/contexts/FilterContext'; 11 | import PreferencesContext from '@/contexts/PreferencesContext'; 12 | 13 | // Utils 14 | import type { Challenge as ChallengeData } from '@/util/challenges'; 15 | import type { Solve } from '@/util/profile'; 16 | 17 | 18 | type ChallengesProps = { 19 | challenges: ChallengeData[] 20 | solves: Solve[] 21 | } 22 | export default function Challenges(props: ChallengesProps) { 23 | const { filter } = useContext(FilterContext); 24 | const { preferences } = useContext(PreferencesContext); 25 | 26 | const solved = new Set(props.solves.map(s => s.name)); 27 | 28 | // Filter by category if any category boxes are checked. 29 | const filtered = useMemo(() => { 30 | let res = (filter.categories.size === 0) 31 | ? props.challenges 32 | : props.challenges.filter((c) => filter.categories.has(c.category)); 33 | 34 | if (!filter.showSolved) 35 | res = res.filter((c) => !solved.has(c.name)); 36 | 37 | return res.toSorted((a, b) => a.points - b.points); 38 | }, [filter, props.challenges]) 39 | 40 | // Group challenges by category for grid layout 41 | // TODO: abstraction with `Filters`, efficiency? 42 | const grouped = useMemo(() => { 43 | const res: { [category: string]: ChallengeData[] } = {}; 44 | 45 | for (const c of filtered) { 46 | if (!(c.category in res)) res[c.category] = []; 47 | res[c.category].push(c); 48 | } 49 | 50 | return res; 51 | }, [filtered]); 52 | 53 | return ( 54 |
55 | {preferences.grid ? Object.entries(grouped).map(([category, challs]) => ( 56 |
57 |

58 | {category} 59 |

60 | 61 |
62 | {challs.map((c) => ( 63 | 68 | ))} 69 |
70 |
71 | )) : filtered.map((c) => ( 72 | 77 | ))} 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/challenges/DisplayToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useContext } from 'react'; 4 | import PreferencesContext from '@/contexts/PreferencesContext'; 5 | 6 | // Icons 7 | import { IoGrid, IoMenu } from 'react-icons/io5'; 8 | 9 | 10 | export default function DisplayToggle() { 11 | const { preferences, setPreferences } = useContext(PreferencesContext); 12 | 13 | function setGrid(grid: boolean) { 14 | setPreferences({ ...preferences, grid }); 15 | } 16 | 17 | return ( 18 |
19 | 25 | 26 | 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/challenges/Filters.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ReactNode, useContext, useMemo } from 'react'; 4 | import FilterContext from '@/contexts/FilterContext'; 5 | 6 | // Types 7 | import type { Challenge as ChallengeData } from '@/util/challenges'; 8 | import type { Solve } from '@/util/profile'; 9 | 10 | 11 | type FiltersProps = { 12 | challenges: ChallengeData[], 13 | solves: Solve[] 14 | } 15 | export default function Filters(props: FiltersProps) { 16 | const { filter, setFilter } = useContext(FilterContext); 17 | 18 | function toggleShowSolved() { 19 | setFilter({ ...filter, showSolved: !filter.showSolved }); 20 | } 21 | 22 | // Mapping of {categoryName: # of challenges in that category} 23 | const totals = useMemo(() => { 24 | const res: { [category: string]: number } = {}; 25 | 26 | for (const c of props.challenges) { 27 | if (!(c.category in res)) res[c.category] = 0; 28 | res[c.category]++; 29 | } 30 | 31 | return res; 32 | }, []); 33 | 34 | // Mapping of {categoryName: # of solved challenges in that category} 35 | const solved = useMemo(() => { 36 | const res: { [category: string]: number } = {}; 37 | 38 | for (const s of props.solves) { 39 | if (!(s.category in res)) res[s.category] = 0; 40 | res[s.category]++; 41 | } 42 | 43 | return res; 44 | }, []); 45 | 46 | return ( 47 |
48 |

Filters

49 | 50 |
51 | {Object.keys(totals).sort((a, b) => a.localeCompare(b)).map((c) => ( 52 | 58 | ))} 59 |
60 |
61 | 62 | Show solved 63 | 64 | ({props.solves.length}/{props.challenges.length} solved) 65 | 66 | 67 |
68 |
69 | ) 70 | } 71 | 72 | type FilterCategoryProps = { 73 | category: string, 74 | totals: { [category: string]: number }, 75 | solved: { [category: string]: number } 76 | } 77 | function FilterCategory(props: FilterCategoryProps) { 78 | const { filter, setFilter } = useContext(FilterContext); 79 | 80 | function toggleCategory() { 81 | if (filter.categories.has(props.category)) 82 | filter.categories.delete(props.category) 83 | else 84 | filter.categories.add(props.category) 85 | 86 | setFilter({ ...filter }); 87 | } 88 | 89 | return ( 90 | 95 | {props.category} 96 | 97 | ({props.solved[props.category] ?? 0}/{props.totals[props.category] ?? 0} solved) 98 | 99 | 100 | ) 101 | } 102 | 103 | type FilterOptionProps = { 104 | name: string, 105 | checked: boolean, 106 | onChange: () => void, 107 | children: ReactNode 108 | } 109 | function FilterOption(props: FilterOptionProps) { 110 | return ( 111 |
112 | 120 | 123 |
124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /app/challenges/FlagSubmissionInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FormEvent, useContext, useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import FlagDispatchContext from '@/contexts/FlagDispatchContext'; 6 | 7 | // Utils 8 | import type { Challenge } from '@/util/challenges'; 9 | import { attemptSubmit } from '@/util/flags'; 10 | 11 | 12 | type FlagSubmissionInputProps = { 13 | challenge: Challenge 14 | solved?: boolean 15 | } 16 | export default function FlagSubmissionInput(props: FlagSubmissionInputProps) { 17 | const [flag, setFlag] = useState(''); 18 | const { acceptFlag, rejectFlag, dispatchNotif } = useContext(FlagDispatchContext); 19 | 20 | const router = useRouter(); 21 | 22 | async function submitFlag(e: FormEvent) { 23 | e.preventDefault(); 24 | 25 | const res = await attemptSubmit(props.challenge.id, flag); 26 | setFlag(''); 27 | 28 | if (res.kind === 'goodFlag') { 29 | acceptFlag(); 30 | router.refresh(); 31 | } else if (res.kind === 'badFlag') { 32 | rejectFlag(); 33 | } else { 34 | dispatchNotif(res.message!, false); 35 | } 36 | } 37 | 38 | return ( 39 |
43 | setFlag(e.target.value)} 49 | /> 50 | 56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /app/challenges/GridChallenge.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react'; 4 | import type { Challenge } from '@/util/challenges'; 5 | import GridChallengeModal from '@/app/challenges/GridChallengeModal'; 6 | import { BiCheck } from 'react-icons/bi'; 7 | 8 | 9 | export default function GridChallenge(props: Challenge & { solved: boolean }) { 10 | const [open, setOpen] = useState(false); 11 | 12 | return ( 13 | <> 14 | 29 | 30 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/challenges/GridChallengeModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { ReactNode } from 'react'; 4 | import { Tab } from '@headlessui/react'; 5 | import Markdown from 'react-markdown'; 6 | import type { Challenge } from '@/util/challenges'; 7 | 8 | // Components 9 | import CenteredModal from '@/components/CenteredModal'; 10 | import FlagSubmissionInput from '@/app/challenges/FlagSubmissionInput'; 11 | import SolvesContent from '@/app/challenges/SolvesContent'; 12 | 13 | // Icons 14 | import { FaDownload } from 'react-icons/fa'; 15 | 16 | 17 | type GridChallengeModalProps = { 18 | open: boolean, 19 | setOpen: (b: boolean) => void, 20 | challenge: Challenge 21 | } 22 | export default function GridChallengeModal(props: GridChallengeModalProps) { 23 | return ( 24 | 29 | 30 | 31 | Challenge 32 | 33 | {props.challenge.solves} Solve{props.challenge.solves === 1 ? '' : 's'} 34 | 35 |
36 | 37 | 38 | 39 | 40 |

41 | {props.challenge.name} 42 |

43 | {props.challenge.tags && props.challenge.tags.length > 0 && ( 44 |
45 | {props.challenge.tags.map((t) => ( 46 | 47 | {t} 48 | 49 | ))} 50 | {/* 51 | {props.challenge.difficulty && ( 52 | 53 | {props.challenge.difficulty} 54 | 55 | )} 56 | */} 57 |
58 | )} 59 |

60 | {props.challenge.points} 61 |

62 | 63 | 64 | {props.challenge.description} 65 | 66 | 67 | {props.challenge.files.length > 0 && ( 68 |
69 | {props.challenge.files.map((d) => ( 70 | 75 | 76 | {d.name} 77 | 78 | ))} 79 |
80 | )} 81 | 82 | 83 |
84 | 85 | 86 | 87 | 88 |
89 | 90 | 91 | ) 92 | } 93 | 94 | function ChallengeTab(props: { children: ReactNode }) { 95 | return ( 96 | 97 | {props.children} 98 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /app/challenges/Solve.tsx: -------------------------------------------------------------------------------- 1 | import type { SolveData } from '@/util/solves'; 2 | import Link from 'next/link'; 3 | import { DateTime } from 'luxon'; 4 | 5 | 6 | export default function Solve(props: SolveData & { rank: number }) { 7 | return ( 8 |
9 |
10 | {props.rank}. 11 |
12 | 13 | 17 | {props.userName} 18 | 19 | 20 |
21 | {DateTime.fromMillis(props.createdAt).toLocaleString(DateTime.DATETIME_MED)} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/challenges/SolvesContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react'; 4 | 5 | // Components 6 | import Solve from '@/app/challenges/Solve'; 7 | import Pagination from '@/components/Pagination'; 8 | 9 | // Utils 10 | import { SOLVES_PAGE_SIZE } from '@/util/config'; 11 | import { getSolves, SolveData } from '@/util/solves'; 12 | import type { Challenge } from '@/util/challenges'; 13 | 14 | // Icons 15 | import { FaRegClock } from 'react-icons/fa6'; 16 | 17 | 18 | type SolvesContentProps = { 19 | challenge: Challenge 20 | } 21 | export default function SolvesContent(props: SolvesContentProps) { 22 | const [solves, setSolves] = useState([]); 23 | const [page, setPage] = useState(0); 24 | 25 | const maxPage = Math.ceil(props.challenge.solves / SOLVES_PAGE_SIZE); 26 | 27 | useEffect(() => { 28 | getSolves(props.challenge.id, 0).then((r) => setSolves(r.data.solves)); 29 | }, []); 30 | 31 | async function updatePage(page: number) { 32 | const res = await getSolves(props.challenge.id, page * SOLVES_PAGE_SIZE); 33 | setSolves(res.data.solves); 34 | setPage(page); 35 | } 36 | 37 | // "No solves" UI 38 | if (props.challenge.solves === 0) { 39 | return ( 40 |
41 | 42 |

{props.challenge.name} has no solves.

43 |
44 | ) 45 | } 46 | 47 | return ( 48 | <> 49 |
50 |
51 |
52 |
#
53 |
Team
54 |
Solve time
55 |
56 |
57 | 58 | {solves.length === 0 ? Array(Math.min(SOLVES_PAGE_SIZE, props.challenge.solves)).fill(0).map((_, i) => ( 59 | // Bare bones "loading UI" to prevent modal resizing after fetch resolution 60 |
61 |
62 | {i + 1}. 63 |
64 | 65 |
Loading
66 |
67 |
68 | )) : solves.map((s, i) => ( 69 | 74 | ))} 75 |
76 | 77 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /app/challenges/SolvesModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import CenteredModal from '@/components/CenteredModal'; 4 | import SolvesContent from '@/app/challenges/SolvesContent'; 5 | 6 | // Utils 7 | import type { Challenge } from '@/util/challenges'; 8 | 9 | 10 | type SolvesModalProps = { 11 | open: boolean, 12 | setOpen: (b: boolean) => void, 13 | challenge: Challenge 14 | } 15 | export default function SolvesModal(props: SolvesModalProps) { 16 | return ( 17 | 22 |

23 | Solves for {props.challenge.name} 24 | 25 | 26 | {props.challenge.category} 27 | 28 |

29 | 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/challenges/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { cookies } from 'next/headers'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | // Components 6 | import Filters from '@/app/challenges/Filters'; 7 | import Challenges from '@/app/challenges/Challenges'; 8 | import DisplayToggle from '@/app/challenges/DisplayToggle'; 9 | import CTFNotStarted from '@/components/CTFNotStarted'; 10 | 11 | // Utils 12 | import { getChallenges } from '@/util/challenges'; 13 | import { getMyProfile } from '@/util/profile'; 14 | import { getAdminChallenges } from '@/util/admin'; 15 | import { AUTH_COOKIE_NAME } from '@/util/config'; 16 | 17 | 18 | export const metadata: Metadata = { 19 | title: 'Challenges' 20 | } 21 | 22 | export default async function ChallengesPage() { 23 | const c = await cookies(); 24 | const token = c.get(AUTH_COOKIE_NAME)!.value; 25 | 26 | const challenges = await getChallenges(token); 27 | const profile = await getMyProfile(token); 28 | 29 | if (profile.kind === 'badToken') 30 | return redirect('/logout'); 31 | 32 | if (challenges.kind !== 'goodChallenges') return ( 33 | 34 | ); 35 | 36 | // Support non-standard properties by sourcing them from the admin endpoint. 37 | const adminData = await getAdminChallData(); 38 | let challs = challenges.data; 39 | 40 | if (adminData) { 41 | // Filter out challs with prereqs that are not met yet 42 | const solved = new Set(profile.data.solves.map((c) => c.id)); 43 | challs = challs.filter((c) => !adminData[c.id].prereqs || adminData[c.id].prereqs!.every((p) => solved.has(p))); 44 | 45 | // Inject additional properties back into client challenges 46 | for (const c of challs) { 47 | c.difficulty = adminData[c.id].difficulty; 48 | c.tags = adminData[c.id].tags; 49 | } 50 | } 51 | 52 | return ( 53 |
54 | 58 | 62 | 63 | 64 |
65 | ); 66 | } 67 | 68 | async function getAdminChallData() { 69 | if (!process.env.ADMIN_TOKEN) return; 70 | 71 | const res = await getAdminChallenges(process.env.ADMIN_TOKEN); 72 | if (res.kind !== 'goodChallenges') return; 73 | 74 | return Object.fromEntries(res.data.map((c) => [c.id, c])); 75 | } 76 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | color-scheme: dark; 7 | } 8 | -------------------------------------------------------------------------------- /app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | 28 | 29 | 30 | 31 | 33 | 53 | 58 | 63 | 67 | 72 | 77 | 82 | 87 | 92 | 93 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import type { Metadata } from 'next'; 3 | import { Inter } from 'next/font/google'; 4 | 5 | // Components 6 | import NavBar from '@/app/NavBar'; 7 | import Equalizer from '@/app/Equalizer'; 8 | import Footer from '@/app/Footer'; 9 | import ScrollableBackground from '@/app/ScrollableBackground'; 10 | 11 | // Providers 12 | import TimeProvider from '@/components/TimeProvider'; 13 | import FilterProvider from '@/components/FilterProvider'; 14 | import PreferencesProvider from '@/components/PreferencesProvider'; 15 | import FlagDispatchProvider from '@/components/FlagDispatchProvider'; 16 | 17 | import '@/app/globals.css'; 18 | 19 | 20 | const inter = Inter({ subsets: ['latin'] }) 21 | 22 | export const metadata: Metadata = { 23 | title: { 24 | absolute: 'b01lers CTF 2025', 25 | template: '%s - b01lers CTF 2025' 26 | }, 27 | description: 'b01lers CTF is jeopardy-style CTF hosted by the b01lers CTF team at Purdue University.', 28 | } 29 | 30 | export default function RootLayout(props: { children: ReactNode }) { 31 | return ( 32 | 33 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {props.children} 45 | 46 | 47 |