├── .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 |
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 |
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 |
15 |
16 | Each team must have a valid email address that should serve as the point of contact.
17 |
18 |
19 | There is no limit on team size, and teams can be from anywhere.
20 |
21 | {/*
22 |
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 |
27 | */}
28 |
29 | Flags are of the format{' '}
30 | {'bctf{[ -~]+}'}
{' '}
31 | unless otherwise noted on the challenge description. No brute-force guessing flags.
32 |
33 |
34 | No flag or hint sharing. Do not solicit or accept hints or guidance from any person except through
35 | official support channels.
36 |
37 |
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 |
41 |
42 | Do not perform any sort of online bruteforce against any of our systems.
43 |
44 | Learn as much as you can, and have a good time!
45 | Pay it forward.
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 |
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 |
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 |
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 |
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 | setOpen(true)}
26 | >
27 | Log out
28 |
29 |
30 |
35 |
36 | Log out
37 |
38 |
39 | This will log you out on your current device.
40 |
41 |
42 |
43 | setOpen(false)}
46 | >
47 | Cancel
48 |
49 |
50 |
54 | Log out
55 |
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 | 0 ? ' bg-black/30 backdrop-blur-md' : '')}>
12 | {props.children}
13 |
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 |
setShowSolves(true)}
41 | >
42 | {props.solves} solve{props.solves === 1 ? '' : 's'}
43 | {' / '}
44 | {props.points} point{props.points === 1 ? '' : 's'}
45 |
46 |
47 |
48 | {props.author}
49 |
50 |
51 |
52 |
53 |
54 | {props.description}
55 |
56 |
57 |
61 |
62 | {props.files.length > 0 && (
63 |
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 | setGrid(false)}
22 | >
23 |
24 |
25 |
26 | setGrid(true)}
29 | >
30 |
31 |
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 |
121 | {props.children}
122 |
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 |
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 | setOpen(true)}
17 | >
18 |
19 | {props.solved && (
20 |
21 | )}
22 |
23 | {props.name}
24 |
25 |
26 | {props.points}
27 |
28 |
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 |
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 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/app/login-handler/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { login } from '@/util/auth';
3 |
4 |
5 | export async function GET(req: NextRequest) {
6 | const params = req.nextUrl.searchParams;
7 |
8 | const token = params.get('token');
9 | if (!token)
10 | return NextResponse.redirect(new URL('/login', req.url));
11 |
12 | const res = await login(token);
13 | if ('error' in res)
14 | return NextResponse.redirect(new URL(`/login?error=${res.error}`, req.url));
15 |
16 | return NextResponse.redirect(new URL('/profile', req.url));
17 | }
18 |
--------------------------------------------------------------------------------
/app/login/LoginContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react';
4 | import { useRouter } from 'next/navigation';
5 | import Link from 'next/link';
6 | import { login } from '@/util/auth';
7 |
8 | // Components
9 | import IconInput from '@/components/IconInput';
10 |
11 | // Icons
12 | import { FaAddressCard } from 'react-icons/fa6';
13 |
14 |
15 | type LoginContentProps = {
16 | error?: string
17 | }
18 | export default function LoginContent(props: LoginContentProps) {
19 | const [teamToken, setTeamToken] = useState('');
20 | const [error, setError] = useState(props.error ?? '');
21 |
22 | const router = useRouter();
23 |
24 | async function loginCallback(teamToken: string) {
25 | const res = await login(teamToken);
26 |
27 | if ('error' in res) return setError(res.error!);
28 |
29 | router.push('/profile');
30 | router.refresh();
31 | }
32 |
33 | return (
34 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { redirect } from 'next/navigation';
3 |
4 | // Components
5 | import LoginContent from '@/app/login/LoginContent';
6 |
7 |
8 | export const metadata: Metadata = {
9 | title: 'Log in'
10 | }
11 |
12 | export default async function Login({ searchParams }: { searchParams: Promise<{ token?: string, error?: string }> }) {
13 | const token = (await searchParams).token;
14 | const error = (await searchParams).error;
15 |
16 | // Automatically sign in if the `token` search parameter is set.
17 | if (token) return redirect(`/login-handler?token=${encodeURIComponent(token)}`)
18 |
19 | return (
20 |
21 |
22 | Log in to b01lers CTF
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/app/logout/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { cookies } from 'next/headers';
3 | import { AUTH_COOKIE_NAME } from '@/util/config';
4 |
5 |
6 | export async function GET(req: Request) {
7 | const c = await cookies();
8 | c.delete(AUTH_COOKIE_NAME);
9 |
10 | return NextResponse.redirect(new URL('/', req.url));
11 | }
12 |
--------------------------------------------------------------------------------
/app/profile/DivisionSelector.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Listbox } from '@headlessui/react';
4 | import AnimatedListbox from '@/components/AnimatedListbox';
5 |
6 | // Icons
7 | import { FaAddressBook } from 'react-icons/fa6';
8 | import { PiCaretUpDown } from 'react-icons/pi';
9 |
10 |
11 | type DivisionSelectorProps = {
12 | division: string,
13 | setDivision: (d: string) => void,
14 | divisions: string[],
15 | divisionNames: { [id: string]: string }
16 | }
17 | export default function DivisionSelector(props: DivisionSelectorProps) {
18 | return (
19 |
25 |
26 |
27 | {props.divisionNames[props.division]}
28 |
29 |
30 |
31 |
32 |
33 | Division
34 |
35 |
36 | {props.divisions.map((d) => (
37 |
42 | {props.divisionNames[d]}
43 |
44 | ))}
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/app/profile/MyProfileInfo.tsx:
--------------------------------------------------------------------------------
1 | import type { MyProfileData } from '@/util/profile';
2 |
3 | // Components
4 | import TeamInvite from '@/app/profile/TeamInvite';
5 | import UpdateInformation from '@/app/profile/UpdateInformation';
6 |
7 | // Utils
8 | import { getConfig } from '@/util/config';
9 |
10 |
11 | /**
12 | * The sidebar of "my team" specific actions on the profile page.
13 | */
14 | export default async function MyProfileInfo(props: MyProfileData) {
15 | const config = await getConfig();
16 |
17 | return (
18 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/profile/Profile.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 |
3 | // Components
4 | import ProfileCard from '@/app/profile/ProfileCard';
5 | import ProfileSolves from '@/app/profile/ProfileSolves';
6 | import { getChallenges } from '@/util/challenges';
7 |
8 | // Utils
9 | import type { ProfileData } from '@/util/profile';
10 | import { AUTH_COOKIE_NAME, getConfig } from '@/util/config';
11 |
12 |
13 | export default async function Profile(props: ProfileData) {
14 | const c = await cookies();
15 | const token = c.get(AUTH_COOKIE_NAME)?.value;
16 |
17 | const challs = token
18 | ? await getChallenges(token)
19 | : null;
20 |
21 | const config = await getConfig();
22 |
23 | return (
24 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/profile/ProfileCard.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import type { IconType } from 'react-icons';
3 | import type { ProfileData } from '@/util/profile';
4 | import type { Challenge } from '@/util/challenges';
5 | import type { CTFConfig } from '@/util/config';
6 |
7 | // Components
8 | import ProfileStats from '@/app/profile/ProfileStats';
9 |
10 | // Icons
11 | import { FaAddressBook, FaTrophy } from 'react-icons/fa6';
12 | import { MdBarChart } from 'react-icons/md';
13 |
14 | // Utils
15 | import { pluralize } from '@/util/strings';
16 |
17 |
18 | type ProfileCardProps = ProfileData & {
19 | challs?: Challenge[] | null,
20 | config: CTFConfig
21 | }
22 | export default function ProfileCard(props: ProfileCardProps) {
23 | const divisionName = props.config.divisions[props.division];
24 |
25 | return (
26 |
27 |
28 |
29 | {props.name}
30 |
31 |
32 |
33 | {props.score} total points
34 |
35 |
36 | {props.divisionPlace ? (
37 | `${pluralize(props.divisionPlace)} place in the ${divisionName} division`
38 | ) : (
39 | 'Unranked'
40 | )}
41 |
42 |
43 | {props.globalPlace ? (
44 | `${pluralize(props.globalPlace)} place across all teams`
45 | ) : (
46 | 'Unranked'
47 | )}
48 |
49 |
50 | {divisionName} division
51 |
52 |
53 |
54 | {props.challs && (
55 |
59 | )}
60 |
61 | )
62 | }
63 |
64 | type ProfileCardStatisticProps = {
65 | icon: IconType,
66 | children: ReactNode
67 | }
68 | function ProfileCardStatistic(props: ProfileCardStatisticProps) {
69 | const Icon = props.icon;
70 |
71 | return (
72 |
73 |
74 | {props.children}
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/app/profile/ProfileSolve.tsx:
--------------------------------------------------------------------------------
1 | import type { Solve } from '@/util/profile';
2 | import { DateTime } from 'luxon';
3 |
4 |
5 | export default function ProfileSolve(props: Solve) {
6 | return (
7 |
8 |
9 | {props.name}
10 |
11 | {props.category}
12 |
13 |
14 |
15 |
16 | {DateTime.fromMillis(props.createdAt).toLocaleString(DateTime.DATETIME_MED)}
17 |
18 |
19 |
20 | {props.points}
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/app/profile/ProfileSolves.tsx:
--------------------------------------------------------------------------------
1 | import type { ProfileData } from '@/util/profile';
2 | import ProfileSolve from '@/app/profile/ProfileSolve';
3 |
4 |
5 | export default function ProfileSolves(props: ProfileData) {
6 | return (
7 |
8 |
9 | Solves
10 |
11 |
12 |
13 |
14 |
15 |
Challenge
16 |
Solve time
17 |
Points
18 |
19 |
20 |
21 | {props.solves.map((s) => (
22 |
26 | ))}
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/app/profile/ProfileStats.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useMemo } from 'react';
4 | import {
5 | PolarAngleAxis,
6 | PolarGrid,
7 | Radar,
8 | RadarChart,
9 | ResponsiveContainer,
10 | Tooltip
11 | } from 'recharts';
12 |
13 | // Types
14 | import type { ProfileData } from '@/util/profile';
15 | import type { Challenge } from '@/util/challenges';
16 |
17 |
18 | export default function ProfileStats(props: ProfileData & { challs: Challenge[] }) {
19 | const data = useMemo(() => {
20 | const res: { [name: string]: { solves: number, total: number } } = {};
21 |
22 | for (const c of props.challs) {
23 | if (!res[c.category]) res[c.category] = { solves: 0, total: 0 };
24 | res[c.category].total++;
25 | }
26 |
27 | for (const c of props.solves) {
28 | if (!res[c.category]) res[c.category] = { solves: 0, total: 0 };
29 | res[c.category].solves++;
30 | }
31 |
32 | return Object.entries(res)
33 | .sort((a, b) => a[0].localeCompare(b[0]))
34 | .map(([name, data]) => ({ name, percent: data.solves / data.total }));
35 | }, [props.solves]);
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
48 | [(percent * 100).toFixed(2) + '%', 'Percent solved']}
53 | />
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/profile/TeamInvite.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Disclosure } from '@headlessui/react';
4 | import { BsChevronUp } from 'react-icons/bs';
5 |
6 |
7 | export default function TeamInvite(props: { token: string }) {
8 | const url = `https://b01lersc.tf/login?token=${encodeURIComponent(props.token)}`;
9 |
10 | function copy() {
11 | void navigator.clipboard.writeText(url);
12 | }
13 |
14 | return (
15 |
16 |
17 | Team invite
18 |
19 |
20 | Send this invite URL to your teammates so they can log in.
21 |
22 |
23 |
24 |
25 |
26 | Reveal
27 |
28 |
29 |
30 |
34 | Copy
35 |
36 |
37 |
38 |
39 | {url}
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/app/profile/UpdateInformation.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FormEvent, useContext, useState } from 'react';
4 | import { useRouter } from 'next/navigation';
5 |
6 | // Components
7 | import IconInput from '@/components/IconInput';
8 | import DivisionSelector from '@/app/profile/DivisionSelector';
9 |
10 | // Utils
11 | import type { CTFConfig } from '@/util/config';
12 | import { MyProfileData, updateEmail, updateProfile, UpdateProfilePayload } from '@/util/profile';
13 | import FlagDispatchContext from '@/contexts/FlagDispatchContext';
14 |
15 | // Icons
16 | import { FaCircleUser } from 'react-icons/fa6';
17 | import { FaEnvelopeOpen } from 'react-icons/fa';
18 |
19 |
20 | export default function UpdateInformation(props: MyProfileData & { config: CTFConfig }) {
21 | const [name, setName] = useState(props.name);
22 | const [email, setEmail] = useState(props.email);
23 | const [division, setDivision] = useState(props.division);
24 |
25 | const { dispatchNotif } = useContext(FlagDispatchContext);
26 | const { refresh } = useRouter();
27 |
28 | async function updateInfoCallback(e: FormEvent) {
29 | e.preventDefault();
30 |
31 | if (name === props.name && email === props.email && division === props.division)
32 | return dispatchNotif('Nothing to update!', false);
33 |
34 | // Update name and division
35 | if (name !== props.name || division !== props.division) {
36 | const payload: UpdateProfilePayload = {};
37 | if (name !== props.name) payload.name = name;
38 | if (division !== props.division) payload.division = division;
39 |
40 | const res = await updateProfile(payload);
41 | if (res.error) return dispatchNotif(res.error, false);
42 |
43 | dispatchNotif('Successfully updated user information.', true);
44 | }
45 |
46 | if (email !== props.email) {
47 | const res = await updateEmail(email);
48 | if (res.error) return dispatchNotif(res.error, false);
49 |
50 | dispatchNotif('Confirmation email sent.', true);
51 | }
52 |
53 | refresh();
54 | }
55 |
56 | return (
57 |
58 |
59 | Update information
60 |
61 |
62 | This will change how your team appears on the scoreboard.
63 | You may only change your team's name once every 10 minutes.
64 |
65 |
66 |
100 |
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/app/profile/[id]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { IoMdCloseCircle } from 'react-icons/io';
2 |
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
8 |
9 |
10 |
404.
11 |
12 | The requested profile was not found.
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/profile/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 |
4 | // Components
5 | import Profile from '@/app/profile/Profile';
6 |
7 | // Utils
8 | import { getProfile } from '@/util/profile';
9 |
10 |
11 | export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise {
12 | const data = await getProfile((await params).id);
13 | if (data.kind === 'badUnknownUser') return notFound();
14 |
15 | return {
16 | title: data.data.name
17 | }
18 | }
19 |
20 | export default async function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
21 | const data = await getProfile((await params).id);
22 | if (data.kind === 'badUnknownUser') return notFound();
23 |
24 | return (
25 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/app/profile/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 Profile from '@/app/profile/Profile';
7 | import MyProfileInfo from '@/app/profile/MyProfileInfo';
8 |
9 | // Utils
10 | import { getMyProfile } from '@/util/profile';
11 | import { AUTH_COOKIE_NAME } from '@/util/config';
12 |
13 |
14 | export const metadata: Metadata = {
15 | title: 'Profile'
16 | }
17 |
18 | export default async function ProfilePage() {
19 | const c = await cookies();
20 |
21 | const token = c.get(AUTH_COOKIE_NAME)!.value;
22 | const data = await getMyProfile(token);
23 |
24 | if (data.kind === 'badToken')
25 | return redirect('/logout');
26 |
27 | return (
28 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/recover/RecoverContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FormEvent, useState } from 'react';
4 | import { recover } from '@/util/auth';
5 |
6 | // Components
7 | import IconInput from '@/components/IconInput';
8 |
9 | // Icons
10 | import { FaEnvelope } from 'react-icons/fa6';
11 | import { FaEnvelopeOpen } from 'react-icons/fa';
12 |
13 |
14 | export default function RecoverContent() {
15 | const [email, setEmail] = useState('');
16 |
17 | const [error, setError] = useState('');
18 | const [recovered, setRecovered] = useState(false);
19 |
20 | async function registerCallback(e: FormEvent) {
21 | e.preventDefault();
22 |
23 | const res = await recover(email);
24 | if ('error' in res) return setError(res.error!);
25 |
26 | setRecovered(true);
27 | }
28 |
29 | return !recovered ? (
30 | <>
31 |
32 | Recover your b01lers CTF account
33 |
34 |
35 |
62 | >
63 | ) : (
64 |
65 |
66 | Recovery email sent!
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/app/recover/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import RecoverContent from '@/app/recover/RecoverContent';
3 |
4 |
5 | export const metadata: Metadata = {
6 | title: 'Recover your account'
7 | }
8 |
9 | export default function Recover() {
10 | return (
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/app/register/RegisterContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FormEvent, useState } from 'react';
4 | import { registerWithEmailVerification } from '@/util/auth';
5 |
6 | // Components
7 | import IconInput from '@/components/IconInput';
8 |
9 | // Icons
10 | import { FaCircleUser, FaEnvelope } from 'react-icons/fa6';
11 | import { FaEnvelopeOpen } from 'react-icons/fa';
12 |
13 |
14 | export default function RegisterContent() {
15 | const [name, setName] = useState('');
16 | const [email, setEmail] = useState('');
17 |
18 | const [error, setError] = useState('');
19 | const [registered, setRegistered] = useState(false);
20 |
21 | async function registerCallback(e: FormEvent) {
22 | e.preventDefault();
23 |
24 | if (name.length <= 1)
25 | return setError('Please specify a name longer than 1 character.')
26 |
27 | const res = await registerWithEmailVerification(email, name);
28 | if ('error' in res) return setError(res.error!);
29 |
30 | setRegistered(true);
31 | }
32 |
33 | return !registered ? (
34 | <>
35 |
36 | Register for b01lers CTF
37 |
38 |
39 | Please register one account per team.
40 |
41 |
42 |
78 | >
79 | ) : (
80 |
81 |
82 | Verification email sent!
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/app/register/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import RegisterContent from '@/app/register/RegisterContent';
3 |
4 |
5 | export const metadata: Metadata = {
6 | title: 'Register'
7 | }
8 |
9 | export default function Register() {
10 | return (
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/app/scoreboard/Scoreboard.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import ScoreboardEntry from '@/app/scoreboard/ScoreboardEntry';
4 | import Pagination from '@/components/Pagination';
5 |
6 | // Utils
7 | import { LeaderboardData } from '@/util/scoreboard';
8 | import { SCOREBOARD_PAGE_SIZE } from '@/util/config';
9 |
10 |
11 | type ScoreboardProps = LeaderboardData & {
12 | name?: string,
13 | page: number,
14 | setPage: (p: number) => void,
15 | maxScore: number
16 | }
17 | export default function Scoreboard(props: ScoreboardProps) {
18 | const maxPage = Math.ceil(props.total / SCOREBOARD_PAGE_SIZE);
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
#
26 |
Team
27 |
Points
28 |
29 |
30 |
31 | {props.leaderboard.map((d, i) => (
32 |
39 | ))}
40 |
41 |
42 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/app/scoreboard/ScoreboardContent.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useRouter } from 'next/navigation';
5 |
6 | // Components
7 | import ScoreboardGraph from '@/app/scoreboard/ScoreboardGraph';
8 | import ScoreboardFilters from '@/app/scoreboard/ScoreboardFilters';
9 | import Scoreboard from '@/app/scoreboard/Scoreboard';
10 |
11 | // Utils
12 | import { getGraph, getScoreboard, GraphEntryData, LeaderboardData } from '@/util/scoreboard';
13 | import { CTFConfig, SCOREBOARD_PAGE_SIZE } from '@/util/config';
14 |
15 |
16 | type ScoreboardContentProps = {
17 | graph: GraphEntryData[],
18 | scoreboard: LeaderboardData,
19 | config: CTFConfig,
20 | name?: string
21 | }
22 | export default function ScoreboardContent(props: ScoreboardContentProps) {
23 | const [division, setDivision] = useState('all');
24 |
25 | const [graph, setGraph] = useState(props.graph);
26 | const [scoreboard, setScoreboard] = useState(props.scoreboard);
27 | const [page, setPage] = useState(0);
28 |
29 | // The max score in the current division
30 | const [maxScore, setMaxScore] = useState(props.scoreboard.leaderboard[0]?.score ?? 0);
31 |
32 | async function updateDivision(div: string) {
33 | const graphRes = await getGraph(div);
34 | if (graphRes.kind !== 'goodLeaderboard') return;
35 |
36 | const scoreboardRes = await getScoreboard(0, div);
37 | if (scoreboardRes.kind !== 'goodLeaderboard') return;
38 |
39 | setGraph(graphRes.data.graph);
40 | setScoreboard(scoreboardRes.data);
41 |
42 | setMaxScore(scoreboardRes.data.leaderboard[0]?.score ?? 0);
43 | setPage(0);
44 |
45 | setDivision(div);
46 | }
47 |
48 | async function updatePage(page: number) {
49 | const res = await getScoreboard(page * SCOREBOARD_PAGE_SIZE, division);
50 | if (res.kind !== 'goodLeaderboard') return;
51 |
52 | setScoreboard(res.data);
53 | setPage(page);
54 | }
55 |
56 | // Re-fetch and merge scoreboard data periodically
57 | const router = useRouter();
58 | useEffect(() => {
59 | router.refresh(); // TODO: don't call this always to avoid excess rerenders?
60 | const id = setInterval(() => router.refresh(), 1000 * 60);
61 | return () => clearInterval(id);
62 | }, []);
63 |
64 | return (
65 | <>
66 |
67 |
68 |
69 |
74 |
81 |
82 | >
83 | )
84 | }
85 |
86 | function scoreboardEqual(a: LeaderboardData, b: LeaderboardData) {
87 | if (a.total !== b.total) return false;
88 |
89 | return a.leaderboard.every((d, i) => d.id === b.leaderboard[i].id
90 | && d.name === b.leaderboard[i].name
91 | && d.score === b.leaderboard[i].score);
92 | }
93 |
94 | function graphsEqual(a: GraphEntryData[], b: GraphEntryData[]) {
95 | if (a.length !== b.length) return false;
96 |
97 | for (let i = 0; i < a.length; i++) {
98 | if (a[i].name !== b[i].name) return false;
99 | if (a[i].id !== b[i].id) return false;
100 |
101 | const pointsEqual = a[i].points.every((p, j) => p.score === b[i].points[j].score && p.time === b[i].points[j].time);
102 | if (!pointsEqual) return false;
103 | }
104 |
105 | return true;
106 | }
107 |
--------------------------------------------------------------------------------
/app/scoreboard/ScoreboardEntry.tsx:
--------------------------------------------------------------------------------
1 | import type { LeaderboardEntry } from '@/util/scoreboard';
2 | import Link from 'next/link';
3 |
4 |
5 | type ScoreboardEntryExtraProps = {
6 | rank: number,
7 | percent: number,
8 | selected: boolean
9 | }
10 | export default function ScoreboardEntry(props: LeaderboardEntry & ScoreboardEntryExtraProps) {
11 | return (
12 |
13 |
14 | {props.rank}
15 |
16 |
17 |
21 | {props.name}
22 |
23 |
24 |
25 |
29 |
30 | {props.score} point{props.score !== 1 ? 's' : ''}
31 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/app/scoreboard/ScoreboardFilters.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import DivisionSelector from '@/app/profile/DivisionSelector';
4 | import type { CTFConfig } from '@/util/config';
5 |
6 |
7 | type ScoreboardFiltersProps = {
8 | division: string,
9 | setDivision: (s: string) => void,
10 | config: CTFConfig
11 | }
12 | export default function ScoreboardFilters(props: ScoreboardFiltersProps) {
13 | return (
14 |
15 |
16 | Filter by division
17 |
18 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/app/scoreboard/ScoreboardGraph.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react';
4 | import { DateTime } from 'luxon';
5 | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
6 | import type { GraphEntryData } from '@/util/scoreboard';
7 |
8 |
9 | export default function ScoreboardGraph(props: { graph: GraphEntryData[] }) {
10 | const [focused, setFocused] = useState('');
11 |
12 | return (
13 |
14 |
15 |
25 |
26 | DateTime.fromMillis(t).toLocaleString()}
33 | />
34 |
38 | DateTime.fromMillis(t).toLocaleString(DateTime.DATETIME_FULL)}
40 | wrapperClassName="!bg-background !border-tertiary rounded !px-4 !py-2 text-sm [&>ul]:!pt-1"
41 | labelClassName="text-xs pb-1 text-secondary border-b border-secondary"
42 | />
43 | setFocused(data.value)}
45 | onMouseLeave={() => setFocused('')}
46 | />
47 | {props.graph.map((p, i) => (
48 |
58 | ))}
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | const colors = [
66 | '#FF1E1E',
67 | '#FF3232',
68 | '#FF4545',
69 | '#FF5656',
70 | '#FF6565',
71 | '#FF7373',
72 | '#FF8080',
73 | '#FF8C8C',
74 | '#FF9696',
75 | '#FFA0A0'
76 | ]
77 |
--------------------------------------------------------------------------------
/app/scoreboard/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { cookies } from 'next/headers';
3 |
4 | // Components
5 | import ScoreboardContent from '@/app/scoreboard/ScoreboardContent';
6 | import CTFNotStarted from '@/components/CTFNotStarted';
7 |
8 | // Utils
9 | import { getGraph, getScoreboard } from '@/util/scoreboard';
10 | import { getMyProfile } from '@/util/profile';
11 | import { AUTH_COOKIE_NAME, getConfig } from '@/util/config';
12 |
13 |
14 | export const metadata: Metadata = {
15 | title: 'Scoreboard'
16 | }
17 |
18 | export default async function ScoreboardPage() {
19 | const c = await cookies();
20 |
21 | const scoreboard = await getScoreboard();
22 | const graph = await getGraph();
23 |
24 | const token = c.get(AUTH_COOKIE_NAME)?.value;
25 | const profile = token
26 | ? await getMyProfile(token)
27 | : undefined;
28 |
29 | const config = await getConfig();
30 |
31 | return (scoreboard.kind === 'goodLeaderboard' && graph.kind === 'goodLeaderboard') ? (
32 |
33 |
39 |
40 | ) : (
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/app/verify/VerifyButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { AUTH_COOKIE_NAME } from '@/util/config';
5 | import { verify } from '@/util/auth';
6 |
7 |
8 | type VerifyButtonProps = {
9 | token: string
10 | }
11 | export default function VerifyButton(props: VerifyButtonProps) {
12 | const router = useRouter();
13 |
14 | async function verifyEmail() {
15 | const res = await verify(props.token);
16 | if (res.kind === 'goodEmailSet')
17 | return router.push('/profile'); // TODO: display error message instead?
18 | if (res.kind !== 'goodRegister' && res.kind !== 'goodVerify')
19 | return router.push('/register');
20 |
21 | document.cookie = `${AUTH_COOKIE_NAME}=${res.data.authToken};`;
22 | router.replace('/profile');
23 | router.refresh();
24 | }
25 |
26 | return (
27 |
31 | Log in
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/app/verify/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { redirect } from 'next/navigation';
3 |
4 | // Components
5 | import VerifyButton from '@/app/verify/VerifyButton';
6 |
7 |
8 | export const metadata: Metadata = {
9 | title: 'Verify'
10 | }
11 |
12 | export default async function Verify({ searchParams }: { searchParams: Promise<{ token: string }> }) {
13 | const token = (await searchParams).token;
14 | if (!token)
15 | return redirect('/register');
16 |
17 | return (
18 |
19 |
20 | Verify your email
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/components/AnimatedListbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ReactNode } from 'react';
4 | import { ListboxOptions } from '@headlessui/react';
5 |
6 |
7 | // A reusable component to wrap a dropdown animation around a `ListboxOptions`.
8 | type AnimatedListboxProps = {
9 | children: ReactNode,
10 | modal?: boolean,
11 | className?: string
12 | }
13 | export default function AnimatedListbox(props: AnimatedListboxProps) {
14 | return (
15 |
20 | {props.children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/CTFNotStarted.tsx:
--------------------------------------------------------------------------------
1 | import { FaRegClock } from 'react-icons/fa6';
2 |
3 |
4 | export default function CTFNotStarted() {
5 | return (
6 |
7 |
8 |
9 |
10 | b01lers CTF has not started yet.
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/components/CenteredModal.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
3 |
4 |
5 | // A reusable component to wrap a transition and dialog overlay around a screen-centered div.
6 | type CenteredModalProps = {
7 | isOpen: boolean,
8 | setIsOpen: (isOpen: boolean) => void,
9 | className: string,
10 | children: ReactNode
11 | }
12 | export default function CenteredModal(props: CenteredModalProps) {
13 | const { isOpen, setIsOpen, className, children } = props;
14 |
15 | return (
16 | setIsOpen(false)}
20 | >
21 |
25 |
26 |
30 | {children}
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/components/FilterProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ReactNode, useState } from 'react';
4 | import FilterContext, { defaultFilter } from '@/contexts/FilterContext';
5 |
6 |
7 | export default function FilterProvider(props: { children: ReactNode }) {
8 | const [filter, setFilter] = useState(defaultFilter);
9 |
10 | return (
11 |
12 | {props.children}
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/components/FlagDispatchProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Fragment, ReactElement, ReactNode, useRef, useState } from 'react';
4 | import { Transition } from '@headlessui/react';
5 |
6 | // Utils
7 | import FlagDispatchContext from '@/contexts/FlagDispatchContext';
8 | import { shuffle } from '@/util/random';
9 |
10 |
11 | export default function FlagDispatchProvider(props: { children: ReactNode }) {
12 | const rejectVideoRefs = useRef([]);
13 | const acceptVideoRefs = useRef([]);
14 |
15 | const appleBottomJeansRef = useRef(null);
16 | const gunRef = useRef(null);
17 |
18 | const wrongFlagsSubmitted = useRef(0);
19 |
20 | const rejectQueue = useRef([]);
21 | const acceptQueue = useRef([]);
22 |
23 | const [notifs, setNotifs] = useState([]);
24 |
25 | function dispatchNotif(message: string, success: boolean) {
26 | setNotifs((n) => [...n, {message} ]);
27 |
28 | setTimeout(() => setNotifs((n) => {
29 | n.shift();
30 | return [...n];
31 | }), 5000);
32 | }
33 |
34 | function rejectFlag() {
35 | if (!rejectQueue.current.length) {
36 | const available = rejectVideoRefs.current.filter((s) => !!s);
37 | if (!available.length) return;
38 |
39 | rejectQueue.current = shuffle(available);
40 | }
41 |
42 | // Play a special video at 15 wrong flags submitted in a row
43 | if (appleBottomJeansRef.current && wrongFlagsSubmitted.current === 15) {
44 | rejectQueue.current.unshift(appleBottomJeansRef.current);
45 | }
46 |
47 | // Play another special video at 30 wrong flags submitted in a row
48 | if (gunRef.current && wrongFlagsSubmitted.current >= 30) {
49 | rejectQueue.current.unshift(gunRef.current);
50 | wrongFlagsSubmitted.current = 0;
51 | }
52 |
53 | const video = rejectQueue.current.shift()!;
54 | video.currentTime = 0;
55 | void video.play();
56 |
57 | wrongFlagsSubmitted.current++;
58 | }
59 |
60 | function acceptFlag() {
61 | if (!acceptQueue.current.length) {
62 | const available = acceptVideoRefs.current.filter((s) => !!s);
63 | if (!available.length) return;
64 |
65 | acceptQueue.current = shuffle(available);
66 | }
67 |
68 | const video = acceptQueue.current.shift()!;
69 | video.currentTime = 0;
70 | void video.play();
71 |
72 | wrongFlagsSubmitted.current = 0;
73 | }
74 |
75 | function appendToRejectVideos(r: HTMLVideoElement) {
76 | rejectVideoRefs.current.push(r);
77 | }
78 |
79 | function appendToAcceptVideos(r: HTMLVideoElement) {
80 | acceptVideoRefs.current.push(r);
81 | }
82 |
83 | return (
84 |
85 | {Array(6).fill(0).map((_, i) => (
86 | e.currentTarget.hidden = false}
91 | onEnded={(e) => e.currentTarget.hidden = true}
92 | key={i}
93 | >
94 |
95 |
96 |
97 | ))}
98 | {Array(6).fill(0).map((_, i) => (
99 | e.currentTarget.hidden = false}
104 | onEnded={(e) => e.currentTarget.hidden = true}
105 | key={i}
106 | >
107 |
108 |
109 |
110 | ))}
111 |
112 | e.currentTarget.hidden = false}
117 | onEnded={(e) => e.currentTarget.hidden = true}
118 | >
119 |
120 |
121 |
122 | e.currentTarget.hidden = false}
127 | onEnded={(e) => e.currentTarget.hidden = true}
128 | >
129 |
130 |
131 |
132 |
133 |
134 | {notifs}
135 |
136 |
137 | {props.children}
138 |
139 | )
140 | }
141 |
142 | function Notification(props: { success?: boolean, children: ReactNode }) {
143 | return (
144 |
155 |
156 | {props.children}
157 |
158 |
159 | )
160 | }
161 |
--------------------------------------------------------------------------------
/components/IconInput.tsx:
--------------------------------------------------------------------------------
1 | import type { InputHTMLAttributes } from 'react';
2 | import type { IconType } from 'react-icons';
3 |
4 |
5 | type IconInputProps = InputHTMLAttributes & {
6 | icon: IconType
7 | }
8 | export default function IconInput(props: IconInputProps) {
9 | const { icon: Icon, ...inputProps } = props;
10 |
11 | return (
12 |
13 |
14 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/components/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
3 |
4 |
5 | type PaginationProps = {
6 | page: number,
7 | maxPage: number,
8 | setPage: (n: number) => void
9 | }
10 | export default function Pagination(props: PaginationProps) {
11 | const { page, maxPage, setPage } = props;
12 | const visiblePages = 9;
13 |
14 | function decrementPage() {
15 | void setPage(page - 1);
16 | }
17 |
18 | function incrementPage() {
19 | void setPage(page + 1);
20 | }
21 |
22 | // Calculate pagination parameters
23 | // Code borrowed and modified from https://github.com/redpwn/rctf/blob/master/client/src/components/pagination.js
24 | const { pages, startPage, endPage } = useMemo(() => {
25 | // Follow the google pagination principle of always showing 10 items
26 | let startPage, endPage;
27 | if (maxPage <= visiblePages) {
28 | // Display all
29 | startPage = 1;
30 | endPage = maxPage;
31 | } else {
32 | // We need to hide some pages
33 | startPage = page - Math.ceil((visiblePages - 1) / 2)
34 | endPage = page + Math.floor((visiblePages - 1) / 2)
35 | if (startPage < 1) {
36 | startPage = 1;
37 | endPage = visiblePages;
38 | } else if (endPage > maxPage) {
39 | endPage = maxPage
40 | startPage = maxPage - visiblePages + 1
41 | }
42 | if (startPage > 1) {
43 | startPage += 2
44 | }
45 | if (endPage < maxPage) {
46 | endPage -= 2
47 | }
48 | }
49 |
50 | const pages = [] // ...Array((endPage + 1) - startPage).keys()].map(i => startPage + i)
51 | for (let i = startPage; i <= endPage; i++) {
52 | pages.push(i)
53 | }
54 | return { pages, startPage, endPage }
55 | }, [maxPage, page, visiblePages]);
56 |
57 | return (
58 |
59 |
64 |
65 |
66 |
67 | {startPage > 1 && (
68 | <>
69 |
74 |
...
75 | >
76 | )}
77 |
78 | {pages.map((i) => (
79 |
85 | ))}
86 |
87 | {endPage < maxPage && (
88 | <>
89 |
...
90 |
95 | >
96 | )}
97 |
98 |
= maxPage - 1}
102 | >
103 |
104 |
105 |
106 | )
107 | }
108 |
109 |
110 | type PaginationItemProps = {
111 | page: number,
112 | setPage: (p: number) => void,
113 | i: number
114 | }
115 | function PaginationItem(props: PaginationItemProps) {
116 | const { page, setPage, i } = props;
117 |
118 | return (
119 | setPage(i - 1)}
122 | >
123 | {i}
124 |
125 | )
126 | }
127 |
--------------------------------------------------------------------------------
/components/PreferencesProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
4 | import PreferencesContext, { defaultPreferences } from '@/contexts/PreferencesContext';
5 |
6 |
7 | export default function PreferencesProvider(props: { children: ReactNode }) {
8 | const [preferences, setPreferences] = useState(defaultPreferences);
9 | const hasRetrievedPreferences = useRef(false);
10 |
11 | useLayoutEffect(() => {
12 | // TODO: better way of doing this?
13 | if (!hasRetrievedPreferences.current) {
14 | hasRetrievedPreferences.current = true;
15 |
16 | const raw = localStorage.getItem('preferences');
17 | if (!raw) return;
18 | setPreferences({ ...defaultPreferences, ...JSON.parse(raw) }) // TODO: eventually need deepmerge here
19 |
20 | return;
21 | }
22 |
23 | localStorage.setItem('preferences', JSON.stringify(preferences));
24 | }, [preferences])
25 |
26 | return (
27 |
28 | {props.children}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/components/SectionHeader.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { FaHashtag } from 'react-icons/fa6';
3 |
4 |
5 | export default function SectionHeader(props: { id: string, children: ReactNode }) {
6 | return (
7 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/components/TimeProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ReactNode, useEffect, useState } from 'react';
4 | import { DateTime } from 'luxon';
5 | import CurrentTimeContext from '@/contexts/CurrentTimeContext';
6 |
7 |
8 | export default function TimeProvider(props: { children: ReactNode }) {
9 | const [time, setTime] = useState(DateTime.now());
10 |
11 | // Update current time every 100ms
12 | useEffect(() => {
13 | const id = setInterval(() => setTime(DateTime.now()), 100);
14 | return () => clearInterval(id);
15 | }, []);
16 |
17 | return (
18 |
19 | {props.children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/conf.d/01-ui.yaml:
--------------------------------------------------------------------------------
1 | ctfName: b01lers CTF
2 | meta:
3 | description: 'A description of your CTF'
4 | imageUrl: 'https://example.com'
5 | homeContent: 'A description of your CTF. Markdown supported.'
6 |
--------------------------------------------------------------------------------
/conf.d/02-ctf.yaml:
--------------------------------------------------------------------------------
1 | origin: https://b01lersc.tf
2 | divisions:
3 | open: Open
4 | purdue: Purdue
5 | divisionACLs:
6 | - match: domain
7 | value: purdue.edu
8 | divisions:
9 | - purdue
10 | - open
11 | - match: any
12 | value: ''
13 | divisions:
14 | - open
15 | startTime: 1745017200000
16 | endTime: 1745190000000
17 |
--------------------------------------------------------------------------------
/conf.d/03-db.yaml:
--------------------------------------------------------------------------------
1 | database:
2 | sql:
3 | host: postgres
4 | user: rctf
5 | database: rctf
6 | redis:
7 | host: redis
8 | migrate: before
9 |
--------------------------------------------------------------------------------
/conf.d/04-email.example.yaml:
--------------------------------------------------------------------------------
1 | email:
2 | from: no-reply@email.b01lers.com
3 | provider:
4 | name: 'emails/mailgun'
5 | options:
6 | apiKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-xxxxxxxx-xxxxxxxx'
7 | domain: 'email.b01lers.com'
8 | logoUrl: 'https://b01lersc.tf/icon.svg'
9 |
--------------------------------------------------------------------------------
/conf.d/05-uploads.example.yaml:
--------------------------------------------------------------------------------
1 | uploadProvider:
2 | name: 'uploads/gcs'
3 | options:
4 | bucketName: uploads
5 | credentials:
6 | private_key: |-
7 | -----BEGIN PRIVATE KEY-----
8 | ABCDABCD
9 | -----END PRIVATE KEY-----
10 | client_email: service-account-name@project-id.iam.gserviceaccount.com
11 |
--------------------------------------------------------------------------------
/conf.d/06-secrets.example.yaml:
--------------------------------------------------------------------------------
1 | tokenKey: 'base64string='
2 |
--------------------------------------------------------------------------------
/contexts/CurrentTimeContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 | import { DateTime } from 'luxon';
3 |
4 |
5 | const CurrentTimeContext = createContext(DateTime.now());
6 | export default CurrentTimeContext;
7 |
--------------------------------------------------------------------------------
/contexts/FilterContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 |
3 |
4 | type Filter = {
5 | categories: Set,
6 | showSolved: boolean,
7 | }
8 |
9 | export const defaultFilter: Filter = {
10 | categories: new Set(),
11 | showSolved: false
12 | }
13 |
14 | type FilterContext = {
15 | filter: Filter,
16 | setFilter: (f: Filter) => void
17 | }
18 | const FilterContext = createContext({
19 | filter: defaultFilter,
20 | setFilter: () => {}
21 | });
22 | export default FilterContext;
23 |
--------------------------------------------------------------------------------
/contexts/FlagDispatchContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 |
3 |
4 | type FlagDispatchContext = {
5 | rejectFlag: () => void,
6 | acceptFlag: () => void,
7 | dispatchNotif: (m: string, success: boolean) => void
8 | }
9 |
10 | const FlagDispatchContext = createContext({
11 | rejectFlag: () => {},
12 | acceptFlag: () => {},
13 | dispatchNotif: () => {}
14 | });
15 | export default FlagDispatchContext;
16 |
--------------------------------------------------------------------------------
/contexts/PreferencesContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 |
3 |
4 | type Preferences = {
5 | grid: boolean,
6 | animations: boolean,
7 | }
8 |
9 | export const defaultPreferences: Preferences = {
10 | grid: false,
11 | animations: true
12 | }
13 |
14 | type PreferencesContext = {
15 | preferences: Preferences,
16 | setPreferences: (f: Preferences) => void
17 | }
18 | const PreferencesContext = createContext({
19 | preferences: defaultPreferences,
20 | setPreferences: () => {}
21 | });
22 | export default PreferencesContext;
23 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.2'
2 | services:
3 | rctf:
4 | image: redpwn/rctf:${RCTF_GIT_REF}
5 | restart: always
6 | ports:
7 | - '9000:80'
8 | networks:
9 | - rctf
10 | env_file:
11 | - .env
12 | environment:
13 | - PORT=80
14 | volumes:
15 | - ./conf.d:/app/conf.d
16 | depends_on:
17 | - redis
18 | - postgres
19 | redis:
20 | image: redis:6.0.6
21 | restart: always
22 | command: ["redis-server", "--requirepass", "${RCTF_REDIS_PASSWORD}"]
23 | networks:
24 | - rctf
25 | volumes:
26 | - ./data/rctf-redis:/data
27 | postgres:
28 | image: postgres:12.3
29 | restart: always
30 | environment:
31 | - POSTGRES_PASSWORD=${RCTF_DATABASE_PASSWORD}
32 | - POSTGRES_USER=rctf
33 | - POSTGRES_DB=rctf
34 | networks:
35 | - rctf
36 | volumes:
37 | - ./data/rctf-postgres:/var/lib/postgresql/data
38 | bctf:
39 | container_name: bctf
40 | build:
41 | dockerfile: ./Dockerfile
42 | context: ../bctf
43 | ports:
44 | - "9123:3000"
45 | restart: always
46 |
47 | networks:
48 | rctf: {}
49 |
--------------------------------------------------------------------------------
/hooks/useIsMounted.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from 'react';
2 |
3 |
4 | // Utility hook to allow dynamic client components to display static data before hydration and avoid hydration errors.
5 | export function useIsMounted() {
6 | const [mounted, setMounted] = useState(false);
7 |
8 | useLayoutEffect(() => {
9 | setMounted(true);
10 | }, []);
11 |
12 | return mounted;
13 | }
14 |
--------------------------------------------------------------------------------
/hooks/useScroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 |
4 | // Returns the current vertical scroll of the window, in pixels.
5 | export function useScroll() {
6 | const [scroll, setScroll] = useState(0);
7 |
8 | useEffect(() => {
9 | setScroll(window.scrollY);
10 | document.addEventListener('scroll', () => setScroll(window.scrollY));
11 | return () => document.removeEventListener('scroll', () => setScroll(window.scrollY));
12 | }, []);
13 |
14 | return scroll;
15 | }
16 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { AUTH_COOKIE_NAME } from '@/util/config';
3 |
4 |
5 | export function middleware(request: NextRequest) {
6 | const token = request.cookies.get(AUTH_COOKIE_NAME)?.value;
7 | if (token) return;
8 |
9 | return NextResponse.redirect(new URL('/login', request.url));
10 | }
11 |
12 | export const config = {
13 | matcher: ['/challenges', '/profile'],
14 | }
15 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const RCTF_BASE = 'http://ctf.b01lers.com:9000';
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = {
5 | env: {
6 | API_BASE: `${RCTF_BASE}/api/v1`,
7 | KLODD_URL: 'https://instancer.b01lersc.tf'
8 | },
9 | logging: {
10 | fetches: {
11 | fullUrl: true,
12 | },
13 | },
14 | async rewrites() {
15 | // Rewrite attempts to call the rCTF backend to their actual destination.
16 | return [
17 | {
18 | source: '/api/v1/:path*',
19 | destination: `${this.env.API_BASE}/:path*`
20 | },
21 | {
22 | source: '/uploads',
23 | destination: `${RCTF_BASE}/uploads`,
24 | }
25 | ]
26 | }
27 | }
28 |
29 | module.exports = nextConfig
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bctf",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^2.2.4",
13 | "@headlessui/tailwindcss": "^0.2.2",
14 | "autoprefixer": "^10.4.20",
15 | "luxon": "^3.6.1",
16 | "next": "^15.3.2",
17 | "postcss": "^8.4.41",
18 | "react": "^19.1.0",
19 | "react-dom": "^19.1.0",
20 | "react-icons": "^5.5.0",
21 | "react-markdown": "^9.0.1",
22 | "recharts": "^2.15.3",
23 | "tailwindcss": "^3.4.10",
24 | "typescript": "5.3.3"
25 | },
26 | "devDependencies": {
27 | "@types/luxon": "^3.4.2",
28 | "@types/node": "^22.5.0",
29 | "@types/react": "^18.3.4",
30 | "@types/react-dom": "^18.3.0"
31 | },
32 | "overrides": {
33 | "react-is": "^19.0.0-beta-26f2496093-20240514"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/background.jpg
--------------------------------------------------------------------------------
/public/assets/background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/background.webp
--------------------------------------------------------------------------------
/public/assets/background2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/background2.webp
--------------------------------------------------------------------------------
/public/assets/background3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/background3.webp
--------------------------------------------------------------------------------
/public/assets/logo-new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/logo-new.png
--------------------------------------------------------------------------------
/public/assets/logo-uwu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/logo-uwu.png
--------------------------------------------------------------------------------
/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
19 |
21 |
22 |
24 | image/svg+xml
25 |
27 |
28 |
29 |
30 |
31 |
33 |
53 |
58 |
61 |
66 |
67 |
72 |
77 |
82 |
87 |
92 |
97 |
102 |
107 |
112 |
113 |
--------------------------------------------------------------------------------
/public/assets/sponsors/CERIAS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/sponsors/CERIAS.png
--------------------------------------------------------------------------------
/public/assets/sponsors/blackwired_combined.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/sponsors/blackwired_combined.png
--------------------------------------------------------------------------------
/public/assets/sponsors/caesar-creek.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/sponsors/caesar-creek.jpg
--------------------------------------------------------------------------------
/public/assets/sponsors/google-cloud.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/sponsors/google-cloud.png
--------------------------------------------------------------------------------
/public/assets/sponsors/ottersec.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/assets/sponsors/zellic.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/public/assets/videos/failed-vp9-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed-vp9-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed1-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed1-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed1-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed1-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/failed2-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed2-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed2-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed2-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/failed3-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed3-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed3-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed3-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/failed4-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed4-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed4-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed4-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/failed5-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed5-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed5-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed5-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/failed6-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed6-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/failed6-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/failed6-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/special-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/special-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/special-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/special-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/special2-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/special2-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/special2-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/special2-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success1-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success1-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success1-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success1-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success2-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success2-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success2-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success2-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success3-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success3-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success3-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success3-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success4-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success4-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success4-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success4-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success5-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success5-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success5-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success5-safari.mov
--------------------------------------------------------------------------------
/public/assets/videos/success6-chrome.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success6-chrome.webm
--------------------------------------------------------------------------------
/public/assets/videos/success6-safari.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ky28059/bctf/76abadb1338b3e33684b43ae3843c3ef3457a3c1/public/assets/videos/success6-safari.mov
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 | import headlessuiPlugin from '@headlessui/tailwindcss';
3 |
4 |
5 | const config: Config = {
6 | content: [
7 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
8 | './components/**/*.{js,ts,jsx,tsx,mdx}',
9 | './app/**/*.{js,ts,jsx,tsx,mdx}',
10 | ],
11 | theme: {
12 | extend: {
13 | animation: {
14 | "loop-scroll": "loop-scroll 45s linear infinite",
15 | "mark-rotate": "mark-rotate 8s ease-in-out alternate infinite",
16 | "mark-pivot-rotate": "mark-pivot-rotate 9s ease-in-out alternate infinite"
17 | },
18 | keyframes: {
19 | "loop-scroll": {
20 | // Magic numbers, do not touch >:(
21 | from: { transform: "translateY(0) translateX(0) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))" },
22 | to: {
23 | // Translate x: 7.5 * scale
24 | // Translate y: 35.26 * scale
25 | transform: "translateY(calc(0.3526 * var(--tw-scale-y) * max(100vh, 100vw))) translateX(calc(0.075 * var(--tw-scale-x) * max(100vh, 100vw))) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))"
26 | },
27 | },
28 | "mark-rotate": {
29 | from: { transform: 'rotateY(-30deg)' },
30 | to: { transform: 'rotateY(30deg)' }
31 | },
32 | "mark-pivot-rotate": {
33 | from: { transform: 'rotateZ(8deg)' },
34 | to: { transform: 'rotateZ(-8deg)' }
35 | }
36 | },
37 | colors: {
38 | background: '#111',
39 | theme: '#c22026',
40 | 'theme-dark': '#9A1B1F',
41 | 'theme-bright': '#ff1e1e',
42 | success: '#0dd157',
43 | primary: '#BABABA',
44 | secondary: '#757575',
45 | tertiary: '#404040'
46 | }
47 | },
48 | container: {
49 | center: true,
50 | padding: {
51 | DEFAULT: '0.75rem',
52 | sm: '2rem',
53 | lg: '4rem',
54 | xl: '5rem',
55 | '2xl': '6rem',
56 | }
57 | },
58 | },
59 | plugins: [headlessuiPlugin],
60 | }
61 | export default config;
62 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/util/admin.ts:
--------------------------------------------------------------------------------
1 | import type { Challenge } from '@/util/challenges';
2 | import type { BadTokenResponse } from '@/util/errors';
3 |
4 |
5 | export type AdminChallenge = Exclude & {
6 | flag: string,
7 | points: { min: number, max: number }
8 |
9 | prereqs?: string[], // Non-standard
10 | }
11 |
12 | type AdminChallengesResponse = {
13 | kind: 'goodChallenges',
14 | message: string,
15 | data: AdminChallenge[]
16 | }
17 |
18 | export async function getAdminChallenges(token: string): Promise {
19 | const res = await fetch(`${process.env.API_BASE}/admin/challs`, {
20 | headers: { 'Authorization': `Bearer ${token}` }
21 | });
22 |
23 | return res.json();
24 | }
25 |
--------------------------------------------------------------------------------
/util/auth.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers';
4 | import { redirect } from 'next/navigation';
5 |
6 | // Utils
7 | import type { BadTokenVerificationResponse } from '@/util/errors';
8 | import { AUTH_COOKIE_NAME } from '@/util/config';
9 |
10 |
11 | // The response type of the registration endpoint if email verification is *not* enabled.
12 | type RegisterResponse = {
13 | kind: 'goodRegister',
14 | message: string,
15 | data: {
16 | authToken: string
17 | }
18 | }
19 |
20 | type EmailAlreadyExistsResponse = {
21 | kind: 'badKnownEmail',
22 | message: 'An account with this email already exists.',
23 | data: null
24 | }
25 |
26 | type RegistrationNotAllowedResponse = {
27 | kind: 'badCompetitionNotAllowed',
28 | message: 'You are not allowed to join this CTF.',
29 | data: null
30 | }
31 |
32 | type BadNameResponse = {
33 | kind: 'badName',
34 | message: 'The name should only use english letters, numbers, and symbols.',
35 | data: null
36 | }
37 |
38 | type RegisterError = EmailAlreadyExistsResponse | RegistrationNotAllowedResponse | BadNameResponse;
39 |
40 | export async function register(email: string, name: string) {
41 | const res: RegisterResponse | RegisterError = await (await fetch(`${process.env.API_BASE}/auth/register`, {
42 | method: 'POST',
43 | headers: { 'Content-Type': 'application/json' },
44 | body: JSON.stringify({ email, name })
45 | })).json();
46 |
47 | if (res.kind !== 'goodRegister')
48 | return { error: res.message };
49 |
50 | const c = await cookies();
51 | c.set(AUTH_COOKIE_NAME, res.data.authToken);
52 |
53 | return { ok: true };
54 | }
55 |
56 | // The response type of the registration endpoint if email verification *is* enabled.
57 | type EmailVerificationResponse = {
58 | data: null
59 | kind: 'goodVerifySent'
60 | message: 'The account verification email was sent.'
61 | }
62 |
63 | export async function registerWithEmailVerification(email: string, name: string) {
64 | const res: EmailVerificationResponse | RegisterError = await (await fetch(`${process.env.API_BASE}/auth/register`, {
65 | method: 'POST',
66 | headers: { 'Content-Type': 'application/json' },
67 | body: JSON.stringify({ email, name })
68 | })).json();
69 |
70 | if (res.kind !== 'goodVerifySent')
71 | return { error: res.message };
72 |
73 | return { ok: true };
74 | }
75 |
76 | type EmailVerifiedResponse = {
77 | kind: 'goodVerify',
78 | message: 'The email was verified.',
79 | data: {
80 | authToken: string
81 | }
82 | }
83 |
84 | type EmailChangeVerifiedResponse = {
85 | kind: 'goodEmailSet',
86 | message: 'The email was successfully updated.',
87 | data: null
88 | }
89 |
90 | export async function verify(verifyToken: string) {
91 | const res: RegisterResponse | BadTokenVerificationResponse | EmailVerifiedResponse | EmailChangeVerifiedResponse = await (await fetch(`${process.env.API_BASE}/auth/verify`, {
92 | method: 'POST',
93 | headers: { 'Content-Type': 'application/json' },
94 | body: JSON.stringify({ verifyToken })
95 | })).json();
96 |
97 | console.log(verifyToken, res);
98 |
99 | return res;
100 | }
101 |
102 | type LoginResponse = {
103 | kind: 'goodLogin',
104 | message: string,
105 | data: {
106 | authToken: string
107 | }
108 | }
109 |
110 | export async function login(token: string) {
111 | const res: LoginResponse | BadTokenVerificationResponse = await (await fetch(`${process.env.API_BASE}/auth/login`, {
112 | method: 'POST',
113 | headers: { 'Content-Type': 'application/json' },
114 | body: JSON.stringify({ teamToken: token })
115 | })).json();
116 |
117 | if (res.kind !== 'goodLogin')
118 | return { error: res.message };
119 |
120 | const c = await cookies();
121 | c.set(AUTH_COOKIE_NAME, res.data.authToken);
122 |
123 | return { ok: true };
124 | }
125 |
126 | export async function logout() {
127 | const c = await cookies();
128 | c.delete(AUTH_COOKIE_NAME);
129 |
130 | return redirect('/');
131 | }
132 |
133 | type UnknownEmailResponse = {
134 | kind: 'badUnknownEmail',
135 | message: 'The account does not exist.',
136 | data: null
137 | }
138 |
139 | export async function recover(email: string) {
140 | const res: EmailVerificationResponse | UnknownEmailResponse = await (await fetch(`${process.env.API_BASE}/auth/recover`, {
141 | method: 'POST',
142 | headers: { 'Content-Type': 'application/json' },
143 | body: JSON.stringify({ email })
144 | })).json();
145 |
146 | if (res.kind !== 'goodVerifySent')
147 | return { error: res.message }
148 |
149 | return { ok: true };
150 | }
151 |
--------------------------------------------------------------------------------
/util/challenges.ts:
--------------------------------------------------------------------------------
1 | import type { CTFEndedResponse } from '@/util/errors';
2 |
3 |
4 | export type Challenge = {
5 | name: string,
6 | id: string,
7 | files: FileData[]
8 | category: string,
9 | author: string,
10 | description: string,
11 | sortWeight: number,
12 | solves: number,
13 | points: number,
14 | } & Partial
15 |
16 | type NonStandardChallProps = {
17 | difficulty: string,
18 | tags: string[],
19 | }
20 |
21 | type FileData = {
22 | url: string,
23 | name: string
24 | }
25 |
26 | type ChallengesResponse = {
27 | kind: 'goodChallenges',
28 | message: string,
29 | data: Challenge[]
30 | }
31 |
32 | export async function getChallenges(token: string): Promise {
33 | const res = await fetch(`${process.env.API_BASE}/challs`, {
34 | headers: { 'Authorization': `Bearer ${token}` }
35 | });
36 | return await res.json();
37 | }
38 |
--------------------------------------------------------------------------------
/util/config.ts:
--------------------------------------------------------------------------------
1 | export const SOLVES_PAGE_SIZE = 10;
2 | export const SCOREBOARD_PAGE_SIZE = 100;
3 |
4 | export const AUTH_COOKIE_NAME = 'ctf_clearance';
5 |
6 |
7 | export type CTFConfig = {
8 | // Controlled by `01-ui.yaml`; for the most part, we don't care about these.
9 | ctfName: string,
10 | meta: {
11 | description: string,
12 | url: string,
13 | },
14 | homeContent: string,
15 |
16 | // Controlled by `02-ctf.yaml`
17 | origin: string,
18 | divisions: { [id: string]: string }, // {id: name}
19 | startTime: number, // epoch ms
20 | endTime: number, // epoch ms
21 | }
22 |
23 | type ConfigResponse = {
24 | kind: 'goodClientConfig',
25 | message: 'The client config was retrieved.',
26 | data: CTFConfig
27 | }
28 |
29 | export async function getConfig(): Promise {
30 | const res = await fetch(`${process.env.API_BASE}/integrations/client/config`);
31 | return res.json();
32 | }
33 |
--------------------------------------------------------------------------------
/util/errors.ts:
--------------------------------------------------------------------------------
1 | export type BadTokenResponse = {
2 | kind: 'badToken',
3 | message: 'The token provided is invalid.',
4 | data: null
5 | }
6 |
7 | export type BadTokenVerificationResponse = {
8 | kind: 'badTokenVerification',
9 | message: 'The token provided is invalid.',
10 | data: null
11 | }
12 |
13 | export type UserNotFoundResponse = {
14 | kind: 'badUnknownUser',
15 | message: 'The user does not exist.',
16 | data: null
17 | }
18 |
19 | export type RateLimitResponse = {
20 | kind: 'badRateLimit',
21 | message: 'You are trying this too fast',
22 | data: {
23 | timeLeft: number // ms
24 | }
25 | }
26 |
27 | export type CTFNotStartedResponse = {
28 | kind: 'badNotStarted',
29 | message: 'The CTF has not started yet.',
30 | data: null
31 | }
32 |
33 | export type CTFEndedResponse = {
34 | kind: 'badEnded',
35 | message: 'The CTF has ended.',
36 | data: null
37 | }
38 |
--------------------------------------------------------------------------------
/util/flags.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import type { CTFEndedResponse } from '@/util/errors';
4 | import { cookies } from 'next/headers';
5 | import { AUTH_COOKIE_NAME } from '@/util/config';
6 |
7 |
8 | type GoodFlagResponse = {
9 | kind: 'goodFlag',
10 | message: 'The flag is correct.',
11 | data: null
12 | }
13 |
14 | type BadFlagResponse = {
15 | kind: 'badFlag',
16 | message: 'The flag was incorrect.',
17 | data: null
18 | }
19 |
20 | export async function attemptSubmit(
21 | id: string,
22 | flag: string
23 | ) {
24 | const c = await cookies();
25 | const token = c.get(AUTH_COOKIE_NAME)?.value;
26 |
27 | if (!token) return { kind: 'badToken', message: 'Missing token' }; // TODO: hacky?
28 |
29 | const res: GoodFlagResponse | BadFlagResponse | CTFEndedResponse = await (await fetch(`${process.env.API_BASE}/challs/${id}/submit`, {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/json',
33 | 'Authorization': `Bearer ${token}`
34 | },
35 | body: JSON.stringify({ flag })
36 | })).json();
37 |
38 | return res;
39 | }
40 |
--------------------------------------------------------------------------------
/util/profile.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import type { BadTokenResponse, RateLimitResponse, UserNotFoundResponse } from '@/util/errors';
4 | import { cookies } from 'next/headers';
5 | import { AUTH_COOKIE_NAME } from '@/util/config';
6 |
7 |
8 | export type ProfileData = {
9 | name: string,
10 | ctftimeId: null,
11 | division: string,
12 | score: number,
13 | globalPlace: number | null,
14 | divisionPlace: number | null,
15 | solves: Solve[]
16 | }
17 |
18 | export type MyProfileData = ProfileData & {
19 | // bloods: [],
20 | teamToken: string,
21 | allowedDivisions: string[],
22 | id: string,
23 | email: string
24 | }
25 |
26 | export type Solve = {
27 | category: string,
28 | name: string,
29 | points: number,
30 | solves: number,
31 | id: string,
32 | createdAt: number // epoch ms
33 | }
34 |
35 | type ProfileResponse = {
36 | kind: 'goodUserData',
37 | message: string,
38 | data: T
39 | }
40 |
41 | export async function getProfile(id: string): Promise | UserNotFoundResponse> {
42 | const res = await fetch(`${process.env.API_BASE}/users/${id}`, {
43 | cache: 'no-store' // TODO: devise clever revalidate-on-demand scheme for this?
44 | });
45 | return res.json();
46 | }
47 |
48 | export async function getMyProfile(token: string): Promise | BadTokenResponse> {
49 | const res = await fetch(`${process.env.API_BASE}/users/me`, {
50 | headers: { 'Authorization': `Bearer ${token}` }
51 | });
52 | return res.json();
53 | }
54 |
55 | type UpdateUserResponse = {
56 | kind: 'goodUserUpdate',
57 | message: 'Your account was successfully updated',
58 | data: {
59 | user: {
60 | name: string,
61 | email: string,
62 | division: string
63 | }
64 | }
65 | }
66 |
67 | export type UpdateProfilePayload = {
68 | name?: string,
69 | division?: string
70 | }
71 | export async function updateProfile(payload: UpdateProfilePayload) {
72 | const c = await cookies();
73 |
74 | const token = c.get(AUTH_COOKIE_NAME)?.value;
75 | if (!token)
76 | return { error: 'Not authenticated.' };
77 |
78 | const res: UpdateUserResponse | RateLimitResponse = await (await fetch(`${process.env.API_BASE}/users/me`, {
79 | method: 'PATCH',
80 | headers: {
81 | 'Content-Type': 'application/json',
82 | 'Authorization': `Bearer ${token}`
83 | },
84 | body: JSON.stringify(payload)
85 | })).json();
86 |
87 | if (res.kind === 'badRateLimit')
88 | return { error: `You are doing this too fast! Try again in ${res.data.timeLeft} ms.` };
89 |
90 | return { ok: true };
91 | }
92 |
93 | type UpdateEmailResponse = {
94 | kind: 'goodVerifySent',
95 | message: 'The account verification email was sent.',
96 | data: null
97 | }
98 |
99 | type DivisionNotAllowedResponse = {
100 | kind: 'badEmailChangeDivision',
101 | message: 'You are not allowed to stay in your division with this email.',
102 | data: null
103 | }
104 |
105 | export async function updateEmail(email: string) {
106 | const c = await cookies();
107 |
108 | const token = c.get(AUTH_COOKIE_NAME)?.value;
109 | if (!token)
110 | return { error: 'Not authenticated.' };
111 |
112 | const emailRes: UpdateEmailResponse | DivisionNotAllowedResponse = await (await fetch(`${process.env.API_BASE}/users/me/auth/email`, {
113 | method: 'PUT',
114 | headers: {
115 | 'Content-Type': 'application/json',
116 | 'Authorization': `Bearer ${token}`
117 | },
118 | body: JSON.stringify({ email })
119 | })).json();
120 |
121 | if (emailRes.kind !== 'goodVerifySent')
122 | return { error: emailRes.message };
123 |
124 | return { ok: true };
125 | }
126 |
--------------------------------------------------------------------------------
/util/random.ts:
--------------------------------------------------------------------------------
1 | export function getRandom(arr: T[]) {
2 | const index = Math.floor(Math.random() * arr.length);
3 | return arr[index];
4 | }
5 |
6 | export function shuffle(arr: T[]) {
7 | const array = [...arr];
8 |
9 | for (let i = array.length - 1; i > 0; i--) {
10 | const j = Math.floor(Math.random() * (i + 1));
11 | [array[i], array[j]] = [array[j], array[i]];
12 | }
13 |
14 | return array;
15 | }
16 |
--------------------------------------------------------------------------------
/util/scoreboard.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import type { CTFNotStartedResponse } from '@/util/errors';
4 | import { SCOREBOARD_PAGE_SIZE } from '@/util/config';
5 |
6 |
7 | export type LeaderboardEntry = {
8 | id: string,
9 | name: string,
10 | score: number
11 | }
12 |
13 | export type LeaderboardData = {
14 | total: number,
15 | leaderboard: LeaderboardEntry[]
16 | }
17 |
18 | type LeaderboardResponse = {
19 | kind: 'goodLeaderboard',
20 | message: string,
21 | data: LeaderboardData
22 | }
23 |
24 | export async function getScoreboard(offset: number = 0, division?: string): Promise {
25 | const endpoint = `${process.env.API_BASE}/leaderboard/now?limit=${SCOREBOARD_PAGE_SIZE}&offset=${offset}`
26 | + (division && division !== 'all' ? `&division=${division}` : '');
27 |
28 | const res = await fetch(endpoint, {
29 | cache: 'no-store'
30 | });
31 | return res.json();
32 | }
33 |
34 | type PointsData = {
35 | time: number,
36 | score: number
37 | }
38 |
39 | export type GraphEntryData = {
40 | id: string,
41 | name: string,
42 | points: PointsData[]
43 | }
44 |
45 | type GraphResponse = {
46 | kind: 'goodLeaderboard',
47 | message: 'The leaderboard was retrieved.',
48 | data: {
49 | graph: GraphEntryData[]
50 | }
51 | }
52 |
53 | export async function getGraph(division?: string): Promise {
54 | const endpoint = `${process.env.API_BASE}/leaderboard/graph?limit=10` + (division && division !== 'all' ? `&division=${division}` : '');
55 |
56 | const res = await fetch(endpoint, {
57 | cache: 'no-store'
58 | });
59 | return res.json();
60 | }
61 |
--------------------------------------------------------------------------------
/util/solves.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { SOLVES_PAGE_SIZE } from '@/util/config';
4 |
5 |
6 | export type SolveData = {
7 | id: string,
8 | createdAt: number,
9 | userId: string,
10 | userName: string
11 | }
12 |
13 | type SolvesResponse = {
14 | kind: 'goodChallengeSolves',
15 | message: string,
16 | data: {
17 | solves: SolveData[]
18 | }
19 | }
20 |
21 | export async function getSolves(id: string, offset: number): Promise {
22 | const res = await fetch(`${process.env.API_BASE}/challs/${id}/solves?limit=${SOLVES_PAGE_SIZE}&offset=${offset}`);
23 | return res.json();
24 | }
25 |
--------------------------------------------------------------------------------
/util/strings.ts:
--------------------------------------------------------------------------------
1 | export function pluralize(num: number) {
2 | if (num % 10 === 1 && num !== 11) return `${num}st`; // 21st, but not 11th
3 | if (num % 10 === 2 && num !== 12) return `${num}nd`; // 32nd, but not 12th
4 | if (num % 10 === 3 && num !== 13) return `${num}rd`; // 63rd, but not 13th
5 | return `${num}th`
6 | }
7 |
--------------------------------------------------------------------------------