├── types.d.ts
├── cypress
├── .gitignore
├── fixtures
│ └── example.json
├── tsconfig.json
├── e2e
│ └── app.cy.ts
└── support
│ ├── e2e.ts
│ └── commands.ts
├── docs
├── pages
│ ├── _meta.json
│ ├── _app.mdx
│ ├── index.mdx
│ └── setup.mdx
├── postcss.config.js
├── theme.config.jsx
├── next.config.js
├── package.json
└── .gitignore
├── pail.png
├── public
├── bucket.png
├── sponsors
│ ├── sagent.png
│ ├── catalyst.png
│ ├── diodes.webp
│ ├── fyrehost.png
│ ├── merrill.png
│ ├── asteralabs.png
│ └── digitalocean.png
├── correct.svg
├── incorrect.svg
├── ctftime.svg
├── loading.svg
└── discord.svg
├── .prettierrc.json
├── postcss.config.js
├── .vscode
└── settings.json
├── src
├── lib
│ ├── ClientUtils.ts
│ ├── Logger.ts
│ ├── prismadb.ts
│ ├── Utils.ts
│ ├── Rankings.ts
│ └── Middleware.ts
├── app
│ ├── loading.tsx
│ ├── layout.tsx
│ ├── api
│ │ ├── user
│ │ │ └── route.ts
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ ├── team
│ │ │ └── route.ts
│ │ ├── settings
│ │ │ ├── [key]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── teams
│ │ │ ├── leave
│ │ │ │ └── route.ts
│ │ │ ├── join
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── challenges
│ │ │ ├── actions.ts
│ │ │ ├── route.ts
│ │ │ ├── host
│ │ │ │ └── [id]
│ │ │ │ │ └── route.ts
│ │ │ ├── [id]
│ │ │ │ └── route.ts
│ │ │ └── solve
│ │ │ │ └── [id]
│ │ │ │ └── route.ts
│ │ ├── rankings
│ │ │ ├── route.ts
│ │ │ └── ctftime
│ │ │ │ └── route.ts
│ │ └── hosts
│ │ │ └── route.ts
│ ├── admin
│ │ ├── hosts
│ │ │ └── page.tsx
│ │ ├── containers
│ │ │ └── page.tsx
│ │ ├── challenges
│ │ │ └── page.tsx
│ │ ├── teams
│ │ │ ├── page.tsx
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── users
│ │ │ ├── page.tsx
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── settings
│ │ │ └── page.tsx
│ ├── rankings
│ │ └── page.tsx
│ ├── account
│ │ └── page.tsx
│ ├── challenges
│ │ └── page.tsx
│ └── page.tsx
├── components
│ ├── Dropdown.tsx
│ ├── Status.tsx
│ ├── Textarea.tsx
│ ├── Error.tsx
│ ├── Popover.tsx
│ ├── Bucket.tsx
│ ├── Code.tsx
│ ├── Navbar.tsx
│ ├── Input.tsx
│ ├── containers
│ │ └── ContainerDetails.tsx
│ ├── Modal.tsx
│ ├── Button.tsx
│ ├── Dialog.tsx
│ ├── challenge
│ │ ├── CreateChallenge.tsx
│ │ └── ChallengeContainer.tsx
│ └── HostContainer.tsx
└── styles
│ ├── globals.css
│ └── Glitch.module.css
├── cypress.config.ts
├── tailwind.config.js
├── .eslintrc.json
├── .env.example
├── next.config.js
├── .gitignore
├── tsconfig.json
├── LICENSE.txt
├── .github
└── workflows
│ └── build.yml
├── README.md
├── package.json
├── prisma
└── schema.prisma
└── CODE_OF_CONDUCT.md
/types.d.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cypress/.gitignore:
--------------------------------------------------------------------------------
1 | /screenshots
2 | /videos
--------------------------------------------------------------------------------
/docs/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": "Introduction"
3 | }
--------------------------------------------------------------------------------
/pail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/pail.png
--------------------------------------------------------------------------------
/public/bucket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/bucket.png
--------------------------------------------------------------------------------
/docs/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 |
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "useTabs": false,
4 | "singleQuote": true
5 | }
--------------------------------------------------------------------------------
/public/sponsors/sagent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/sagent.png
--------------------------------------------------------------------------------
/public/sponsors/catalyst.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/catalyst.png
--------------------------------------------------------------------------------
/public/sponsors/diodes.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/diodes.webp
--------------------------------------------------------------------------------
/public/sponsors/fyrehost.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/fyrehost.png
--------------------------------------------------------------------------------
/public/sponsors/merrill.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/merrill.png
--------------------------------------------------------------------------------
/public/sponsors/asteralabs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/asteralabs.png
--------------------------------------------------------------------------------
/docs/pages/_app.mdx:
--------------------------------------------------------------------------------
1 | export default function App({ Component, pageProps }) {
2 | return
3 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/sponsors/digitalocean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmergencyBucket/pail/HEAD/public/sponsors/digitalocean.png
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/ClientUtils.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function bkct(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/docs/theme.config.jsx:
--------------------------------------------------------------------------------
1 | export default {
2 | logo:
,
3 | project: {
4 | link: 'https://github.com/EmergencyBucket/pail',
5 | },
6 | // ...
7 | }
--------------------------------------------------------------------------------
/public/correct.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "cypress";
2 |
3 | export default defineConfig({
4 | e2e: {
5 | setupNodeEvents(on, config) {
6 | // implement node event listeners here
7 | },
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/src/lib/Logger.ts:
--------------------------------------------------------------------------------
1 | import { createLogger, format, transports } from 'winston';
2 |
3 | const logger = createLogger({
4 | level: 'info',
5 | format: format.combine(format.timestamp(), format.json()),
6 | transports: [new transports.Console()],
7 | });
8 |
9 | export { logger };
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/**/*.{js,ts,jsx,tsx}",
5 | ],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [
10 | require('@tailwindcss/typography'),
11 | ],
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Status, Statuses } from '@/components/Status';
4 |
5 | export default function loading() {
6 | return (
7 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "es5",
6 | "dom"
7 | ],
8 | "types": [
9 | "cypress",
10 | "node"
11 | ]
12 | },
13 | "include": [
14 | "**/*.ts"
15 | ]
16 | }
--------------------------------------------------------------------------------
/docs/next.config.js:
--------------------------------------------------------------------------------
1 | const withNextra = require('nextra')({
2 | theme: 'nextra-theme-docs',
3 | themeConfig: './theme.config.jsx',
4 | })
5 |
6 | module.exports = withNextra()
7 |
8 | // If you have other Next.js configurations, you can pass them as the parameter:
9 | // module.exports = withNextra({ /* other next.js config */ })
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals"
4 | ],
5 | "parser": "@typescript-eslint/parser",
6 | "plugins": [
7 | "prettier",
8 | "@typescript-eslint"
9 | ],
10 | "rules": {
11 | "prettier/prettier": 2,
12 | "@typescript-eslint/no-unused-vars": 2,
13 | "react-hooks/exhaustive-deps": "off"
14 | }
15 | }
--------------------------------------------------------------------------------
/src/lib/prismadb.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | declare global {
4 | var prisma: PrismaClient;
5 | }
6 |
7 | let prisma: PrismaClient;
8 |
9 | if (process.env.NODE_ENV === 'production') {
10 | prisma = new PrismaClient();
11 | } else {
12 | if (!global.prisma) {
13 | global.prisma = new PrismaClient();
14 | }
15 |
16 | prisma = global.prisma;
17 | }
18 |
19 | export default prisma;
20 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "1.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "next": "^13.4.12",
13 | "nextra": "^2.10.0",
14 | "nextra-theme-docs": "^2.10.0",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Environment variables declared in this file are automatically made available to Prisma.
2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
3 |
4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
6 |
7 | DATABASE_URL=
8 | GITHUB_CLIENT_ID=
9 | GITHUB_CLIENT_SECRET=
10 | NEXTAUTH_SECRET=
--------------------------------------------------------------------------------
/cypress/e2e/app.cy.ts:
--------------------------------------------------------------------------------
1 | describe('Homepage', () => {
2 | it('Has our navbar home', () => {
3 | cy.visit('http://localhost:3000/')
4 |
5 | cy.get('a').contains('Home').parents().should('have.attr', 'href', '/');
6 |
7 | cy.get('a').contains('Challenges').parents().should('have.attr', 'href', '/challenges');
8 |
9 | cy.get('a').contains('Rankings').parents().should('have.attr', 'href', '/rankings');
10 |
11 | cy.get('a').contains('Sign in').parents().should('have.attr', 'href', '/api/auth/signin');
12 | })
13 | })
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [{
5 | protocol: 'https',
6 | hostname: 'avatars.githubusercontent.com',
7 | port: '',
8 | pathname: '/u/**',
9 | }]
10 | },
11 | reactStrictMode: false,
12 | experimental: {
13 | serverActions: true,
14 | serverComponentsExternalPackages: ['cpu-features', 'ssh2', 'zlib_sync', 'discord.js'],
15 | },
16 | }
17 |
18 | module.exports = nextConfig
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from '@/components/Navbar';
2 | import '../styles/globals.css';
3 |
4 | export default async function RootLayout({
5 | children,
6 | }: {
7 | children: React.ReactNode;
8 | }) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/cypress/support/e2e.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/e2e.ts is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
--------------------------------------------------------------------------------
/src/app/api/user/route.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import { getServerSession } from 'next-auth';
3 | import { NextResponse } from 'next/server';
4 | import prisma from '@/lib/prismadb';
5 |
6 | export async function GET() {
7 | const session = await getServerSession();
8 |
9 | if (!session) {
10 | return NextResponse.json(
11 | {
12 | Error: 'You must be logged in to preform this action.',
13 | },
14 | {
15 | status: StatusCodes.UNAUTHORIZED,
16 | },
17 | );
18 | }
19 |
20 | let user = await prisma.user.findFirst({
21 | where: {
22 | name: session.user?.name,
23 | },
24 | });
25 |
26 | return NextResponse.json(user);
27 | }
28 |
--------------------------------------------------------------------------------
/docs/pages/index.mdx:
--------------------------------------------------------------------------------
1 | import { Callout } from 'nextra-theme-docs'
2 |
3 | 
4 |
5 | > Pail is a full stack CTF platform developed by the Emergency Bucket CTF team
6 |
7 | ### Railway one-click deploy
8 | [](https://railway.app/new/template/DrJIzA?referralCode=GswMXR)
9 |
10 | #### Features
11 | - GitHub SSO
12 | - Team management
13 | - CTFTime ranking integration
14 | - Docker container support
15 | - Ranking charts
16 | - Discord integration
17 |
18 | ### Screenshots
19 | 
--------------------------------------------------------------------------------
/src/app/admin/hosts/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import HostContainer from '@/components/HostContainer';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Hosts',
8 | };
9 |
10 | export default async function Home() {
11 | if (await admin()) {
12 | return ;
13 | }
14 |
15 | let hosts = await prisma.host.findMany();
16 |
17 | return (
18 |
19 | {hosts.map((host) => (
20 |
21 | ))}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/admin/containers/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import ContainerDetails from '@/components/containers/ContainerDetails';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Containers',
8 | };
9 |
10 | export default async function Home() {
11 | if (await admin()) {
12 | return ;
13 | }
14 |
15 | let container = await prisma.container.findMany();
16 |
17 | return (
18 |
19 | {container.map((cont) => (
20 |
21 | ))}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/Utils.ts:
--------------------------------------------------------------------------------
1 | import { getServerSession } from 'next-auth';
2 | import prisma from './prismadb';
3 |
4 | export async function getUser() {
5 | let session = await getServerSession();
6 |
7 | if (!session) {
8 | return null;
9 | }
10 |
11 | return await prisma.user.findFirst({
12 | where: {
13 | email: session?.user?.email,
14 | },
15 | });
16 | }
17 |
18 | export async function getTeam() {
19 | let session = await getServerSession();
20 |
21 | if (!session) {
22 | return null;
23 | }
24 |
25 | return (
26 | await prisma.user.findFirst({
27 | where: {
28 | email: session?.user?.email,
29 | },
30 | include: {
31 | team: true,
32 | },
33 | })
34 | )?.team;
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/admin/challenges/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { CreateChallenge } from '@/components/challenge/CreateChallenge';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Challenges',
8 | };
9 |
10 | export default async function Home() {
11 | if (await admin()) {
12 | return ;
13 | }
14 |
15 | let challenges = await prisma.challenge.findMany();
16 |
17 | return (
18 |
19 | {challenges.map((chall) => (
20 |
21 | ))}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Dropdown.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export function useComboboxValue(initialValue = '') {
4 | const [value, setValue] = React.useState(initialValue);
5 | return [value, setValue];
6 | }
7 |
8 | export interface SelectProps
9 | extends React.SelectHTMLAttributes {
10 | items: string[];
11 | }
12 |
13 | const Dropdown = React.forwardRef(
14 | ({ items, ...props }, ref) => (
15 |
24 | ),
25 | );
26 | Dropdown.displayName = 'Dropdown';
27 |
28 | export { Dropdown };
29 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from '@next-auth/prisma-adapter';
2 | import GitHubProvider from 'next-auth/providers/github';
3 | import NextAuth, { NextAuthOptions } from 'next-auth';
4 | import prisma from '@/lib/prismadb';
5 |
6 | const authOptions: NextAuthOptions = {
7 | adapter: PrismaAdapter(prisma),
8 | providers: [
9 | GitHubProvider({
10 | clientId: process.env.GITHUB_CLIENT_ID!,
11 | clientSecret: process.env.GITHUB_CLIENT_SECRET!,
12 | }),
13 | ],
14 | callbacks: {
15 | session: async ({ session, user }) => {
16 | return {
17 | ...session,
18 | user: user,
19 | };
20 | },
21 | },
22 | session: {
23 | strategy: 'jwt',
24 | },
25 | };
26 |
27 | const handler = NextAuth(authOptions);
28 |
29 | export { handler as GET, handler as POST };
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true,
21 | "baseUrl": ".",
22 | "paths": {
23 | "@/*": [
24 | "./src/*"
25 | ]
26 | },
27 | "plugins": [
28 | {
29 | "name": "next"
30 | }
31 | ]
32 | },
33 | "include": [
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx",
37 | ".next/types/**/*.ts",
38 | "/home/pail/.next/types/**/*.ts"
39 | ],
40 | "exclude": [
41 | "node_modules"
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Status.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CheckIcon, XIcon } from 'lucide-react';
4 | import { Loader2 } from 'lucide-react';
5 | import React from 'react';
6 |
7 | enum Statuses {
8 | Unsubmitted,
9 | Loading,
10 | Correct,
11 | Incorrect,
12 | }
13 |
14 | interface Props extends React.HTMLAttributes {
15 | status: Statuses;
16 | }
17 |
18 | const Status = ({ status, className }: Props) => {
19 | switch (status) {
20 | case Statuses.Unsubmitted: {
21 | return <>>;
22 | }
23 | case Statuses.Loading: {
24 | return (
25 |
26 | );
27 | }
28 | case Statuses.Correct: {
29 | return ;
30 | }
31 | case Statuses.Incorrect: {
32 | return ;
33 | }
34 | }
35 | };
36 |
37 | export { Status, Statuses };
38 |
--------------------------------------------------------------------------------
/docs/pages/setup.mdx:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | Pail is a full stack NextJS application meaning only one web service needs to be run along with a PostgreSQL database.
4 | The easiest way to deploy is via Railway which will manage nearly all settings for you.
5 |
6 | # Manual Setup
7 |
8 | 1. Clone the project off of GitHub.
9 | 2. Install all dependencies by running
10 | ```bash
11 | npm install -g yarn
12 | yarn install
13 | ```
14 | 3. Install + setup PostgreSQL and set an environment variable ``DATABASE_URL_NON_POOLING`` to the full connection URL.
15 | 4. Also set a ``NEXTAUTH_SECRET`` in your ``.env`` file (this should be completely random).
16 | 5. Setup GitHub sign in via the tutorial [here](https://next-auth.js.org/providers/github) (Set the ``GITHUB_CLIENT_ID`` and ``GITHUB_CLIENT_SECRET`` in your environment variables).
17 | 6. Optionally set the ``DOCKER_USERNAME`` and ``DOCKER_PASSWORD`` to pull images from the GitHub container registry.
18 | 7. Push the db schema and build the website by running ``yarn build``.
19 |
20 |
--------------------------------------------------------------------------------
/src/components/Textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { bkct } from '@/lib/ClientUtils';
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = 'Textarea';
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/public/incorrect.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
24 |
--------------------------------------------------------------------------------
/src/app/admin/teams/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { Button } from '@/components/Button';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Teams',
8 | };
9 |
10 | export default async function Home() {
11 | if (await admin()) {
12 | return ;
13 | }
14 |
15 | let teams = await prisma.team.findMany({
16 | orderBy: [
17 | {
18 | name: 'desc',
19 | },
20 | ],
21 | });
22 |
23 | return (
24 |
25 | {teams.map((team) => (
26 |
34 | ))}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/admin/users/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { Button } from '@/components/Button';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Users',
8 | };
9 |
10 | export default async function Home() {
11 | if (await admin()) {
12 | return ;
13 | }
14 |
15 | let users = await prisma.user.findMany({
16 | orderBy: [
17 | {
18 | name: 'desc',
19 | },
20 | ],
21 | });
22 |
23 | return (
24 |
25 | {users.map((user) => (
26 |
34 | ))}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Error.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | reason: string;
3 | }
4 |
5 | const Error = ({ reason }: Props) => {
6 | return (
7 |
8 |
9 |
23 |
24 |
{reason}
25 |
26 |
27 | );
28 | };
29 |
30 | export { Error };
31 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Emergency Bucket
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 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* width */
6 | ::-webkit-scrollbar {
7 | width: 10px;
8 | }
9 |
10 | /* Track */
11 | ::-webkit-scrollbar-track {
12 | background: #3b375e;
13 | /*border-radius: 10px;*/
14 | }
15 |
16 | /* Handle */
17 | ::-webkit-scrollbar-thumb {
18 | background: #68638d;
19 | border-radius: 10px;
20 | }
21 |
22 | /* Handle on hover */
23 | ::-webkit-scrollbar-thumb:hover {
24 | background: #9992bf;
25 | }
26 |
27 | html {
28 | scroll-behavior: smooth;
29 | }
30 |
31 | input[type=number]::-webkit-inner-spin-button {
32 | -webkit-appearance: none;
33 | }
34 |
35 | input[type='file'] {
36 | color: transparent;
37 | }
38 |
39 | ::file-selector-button {
40 | padding-left: 0.5rem;
41 | margin-top: 0.5rem;
42 | margin-bottom: 0.5rem;
43 | width: 100%;
44 | border-width: 2px;
45 | outline: 0;
46 | background-color: #334155;
47 | color: white;
48 | border-color: #64748b;
49 | }
50 |
51 | .hide {
52 | display: none;
53 | }
54 |
55 | .hide:hover {
56 | display:block;
57 | }
58 |
59 | .entry:hover + .hide {
60 | display: block;
61 | }
--------------------------------------------------------------------------------
/src/components/Popover.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as PopoverPrimitive from '@radix-ui/react-popover';
5 |
6 | import { bkct } from '@/lib/ClientUtils';
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent };
32 |
--------------------------------------------------------------------------------
/src/app/api/team/route.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import { getServerSession } from 'next-auth';
3 | import { NextResponse } from 'next/server';
4 | import prisma from '@/lib/prismadb';
5 |
6 | export async function GET() {
7 | const session = await getServerSession();
8 |
9 | if (!session) {
10 | return NextResponse.json(
11 | {
12 | Error: 'You must be logged in to preform this action.',
13 | },
14 | {
15 | status: StatusCodes.UNAUTHORIZED,
16 | },
17 | );
18 | }
19 |
20 | let user = await prisma.user.findFirst({
21 | where: {
22 | name: session.user?.name,
23 | },
24 | });
25 |
26 | if (!user?.teamId) {
27 | return NextResponse.json(
28 | {
29 | Error: 'You are not on a team.',
30 | },
31 | {
32 | status: StatusCodes.BAD_REQUEST,
33 | },
34 | );
35 | }
36 |
37 | let team = await prisma.team.findFirst({
38 | where: {
39 | id: user.teamId,
40 | },
41 | include: {
42 | members: true,
43 | },
44 | });
45 |
46 | team!.members.forEach((member) => {
47 | member.email = '';
48 | });
49 |
50 | return NextResponse.json(team);
51 | }
52 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build site
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | services:
16 | # Label used to access the service container
17 | postgres:
18 | # Docker Hub image
19 | image: postgres
20 | # Provide the password for postgres
21 | env:
22 | POSTGRES_PASSWORD: postgres
23 | # Set health checks to wait until postgres has started
24 | options: >-
25 | --health-cmd pg_isready
26 | --health-interval 10s
27 | --health-timeout 5s
28 | --health-retries 5
29 | ports:
30 | # Maps tcp port 5432 on service container to the host
31 | - 5432:5432
32 |
33 | strategy:
34 | matrix:
35 | node-version: [16.x]
36 |
37 | steps:
38 | - uses: actions/checkout@v3
39 |
40 | - name: Use Node.js ${{ matrix.node-version }}
41 | uses: actions/setup-node@v3
42 | with:
43 | node-version: ${{ matrix.node-version }}
44 | cache: 'yarn'
45 |
46 | - name: Install dependencies
47 | run: yarn install
48 |
49 | - name: Build site
50 | run: yarn build
51 | env:
52 | DATABASE_URL_NON_POOLING: postgresql://postgres:postgres@localhost:5432/postgres
--------------------------------------------------------------------------------
/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************
3 | // This example commands.ts shows you how to
4 | // create various custom commands and overwrite
5 | // existing commands.
6 | //
7 | // For more comprehensive examples of custom
8 | // commands please read more here:
9 | // https://on.cypress.io/custom-commands
10 | // ***********************************************
11 | //
12 | //
13 | // -- This is a parent command --
14 | // Cypress.Commands.add('login', (email, password) => { ... })
15 | //
16 | //
17 | // -- This is a child command --
18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
19 | //
20 | //
21 | // -- This is a dual command --
22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
23 | //
24 | //
25 | // -- This will overwrite an existing command --
26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
27 | //
28 | // declare global {
29 | // namespace Cypress {
30 | // interface Chainable {
31 | // login(email: string, password: string): Chainable
32 | // drag(subject: string, options?: Partial): Chainable
33 | // dismiss(subject: string, options?: Partial): Chainable
34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
35 | // }
36 | // }
37 | // }
--------------------------------------------------------------------------------
/src/app/api/settings/[key]/route.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import isString from 'is-string';
3 | import { admin, Middleware } from '@/lib/Middleware';
4 | import { NextRequest, NextResponse } from 'next/server';
5 | import prisma from '@/lib/prismadb';
6 |
7 | export async function GET(
8 | req: NextRequest,
9 | { params }: { params: { key?: string } },
10 | ) {
11 | const { key } = params;
12 |
13 | if (!isString(key)) {
14 | return NextResponse.json(
15 | {
16 | Error: 'Bad request.',
17 | },
18 | {
19 | status: StatusCodes.BAD_REQUEST,
20 | },
21 | );
22 | }
23 |
24 | let setting = await prisma.setting.findFirst({
25 | where: {
26 | key: key,
27 | },
28 | });
29 |
30 | if (!setting) {
31 | return NextResponse.json(
32 | {
33 | Error: 'This setting was not found.',
34 | },
35 | {
36 | status: StatusCodes.NOT_FOUND,
37 | },
38 | );
39 | }
40 |
41 | if (setting.public) {
42 | return NextResponse.json(setting, {
43 | status: StatusCodes.OK,
44 | });
45 | }
46 |
47 | let middleware = await Middleware([admin()]);
48 | if (middleware) return middleware;
49 |
50 | return NextResponse.json(setting, {
51 | status: StatusCodes.OK,
52 | });
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/Bucket.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import bucket from '../../public/bucket.json';
5 |
6 | const Bucket = () => {
7 | async function load() {
8 | let c: HTMLCanvasElement = document.getElementById(
9 | 'canvas',
10 | ) as HTMLCanvasElement;
11 |
12 | let ctx: CanvasRenderingContext2D = c.getContext(
13 | '2d',
14 | ) as CanvasRenderingContext2D;
15 |
16 | for (let x = 0; x < bucket.length; x++) {
17 | for (let y = bucket[x].length - 1; y >= 0; y--) {
18 | setTimeout(() => {
19 | ctx.beginPath();
20 | if (bucket[x][y][3]) {
21 | ctx.strokeStyle = `#1d1f20`;
22 | ctx.lineWidth = 0.5;
23 | ctx.rect(x * 5 - 0.5, y * 5 - 0.5, 5, 5);
24 | }
25 |
26 | ctx.fillStyle = `rgba(${bucket[x][y][0]}, ${bucket[x][y][1]}, ${bucket[x][y][2]}, ${bucket[x][y][3]})`;
27 | ctx.fillRect(x * 5, y * 5, 4.5, 4.5);
28 | ctx.stroke();
29 | }, y);
30 | }
31 | }
32 | }
33 |
34 | useEffect(() => {
35 | load();
36 | }, []);
37 |
38 | return (
39 |
46 | );
47 | };
48 |
49 | export default Bucket;
50 |
--------------------------------------------------------------------------------
/src/app/api/teams/leave/route.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import { getServerSession } from 'next-auth';
3 | import { NextResponse } from 'next/server';
4 | import prisma from '@/lib/prismadb';
5 |
6 | export async function POST() {
7 | const session = await getServerSession();
8 |
9 | if (!session) {
10 | return NextResponse.json(
11 | {
12 | Error: 'You must be logged in to preform this action.',
13 | },
14 | {
15 | status: StatusCodes.UNAUTHORIZED,
16 | },
17 | );
18 | }
19 |
20 | const user = await prisma.user.findFirst({
21 | where: {
22 | name: session.user?.name,
23 | },
24 | });
25 |
26 | let team = await prisma.team.update({
27 | include: {
28 | members: true,
29 | },
30 | where: {
31 | id: user!.teamId as string,
32 | },
33 | data: {
34 | members: {
35 | disconnect: {
36 | id: user!.id,
37 | },
38 | },
39 | },
40 | });
41 |
42 | if (team.members.length === 0) {
43 | await prisma.solve.deleteMany({
44 | where: {
45 | teamId: user!.teamId as string,
46 | },
47 | });
48 |
49 | await prisma.team.delete({
50 | where: {
51 | id: team.id,
52 | },
53 | });
54 | }
55 |
56 | return NextResponse.json(team, {
57 | status: StatusCodes.OK,
58 | });
59 | }
60 |
--------------------------------------------------------------------------------
/public/ctftime.svg:
--------------------------------------------------------------------------------
1 |
2 |
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | ## Pail is Emergency Bucket CTF's NextJS backend + frontend.
3 |
4 | 
5 | 
6 | [](https://discord.gg/5rCRRZ7Pmg)
7 | 
8 | 
9 |
10 | ### Features:
11 | - Challenge creation
12 | - Github integration
13 | - Rankings
14 | - Team creation/leaving/joining
15 |
16 | ### Coming Soon:
17 | - Discord integration
18 | - Point configuration
19 | - Challenge health check
20 |
21 | ### Technologies used:
22 | - [NextJS](https://nextjs.org/) (API Routes)
23 | - [NextAuth.js](https://next-auth.js.org/)
24 | - [Prisma](https://www.prisma.io/)
25 | - [Ajv](https://ajv.js.org/)
26 | - [Tidy.js](https://pbeshai.github.io/tidy/)
27 |
28 | 
29 |
30 | ## Screenshots
31 | 
32 |
33 | ## Installation
34 | ### Railway one-click (Recommended)
35 | [](https://railway.app/new/template/DrJIzA?referralCode=GswMXR)
36 |
37 | ### Manual
38 | Running and installing Pail requires a Postgres instance and VM or Vercel for the frontend. If you are unable to use Postgres instance, it may be possible to use SQLite by changing the provider in the Prisma schema but this has not been tested. To install normally:
39 |
40 | 1. Clone the repo: ``git clone https://github.com/EmergencyBucket/pail``
41 | 2. Install dependencies: ``yarn install``
42 | 3. Copy the env file and fill it out: ``cp .env.example .env``
43 | 4. Build the project (This will also setup the database): ``yarn build``
44 | 5. Start: ``yarn start``
45 |
--------------------------------------------------------------------------------
/public/loading.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/api/challenges/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { Middleware, admin } from '@/lib/Middleware';
4 | import { Category, Difficulty } from '@prisma/client';
5 | import { revalidatePath } from 'next/cache';
6 |
7 | export async function createChallenge(data: FormData) {
8 | let middleware = await Middleware([admin()]);
9 | if (middleware) return middleware;
10 |
11 | await prisma.challenge.create({
12 | data: {
13 | name: data.get('name')?.toString()!,
14 | description: data.get('description')?.toString()!,
15 | files: data.get('files')?.toString()!.split(','),
16 | image: data.get('image')?.toString()!,
17 | flag: data.get('flag')?.toString()!,
18 | category: data.get('category')?.toString()! as Category,
19 | difficulty: data.get('difficulty')?.toString()! as Difficulty,
20 | solved: undefined,
21 | staticPoints: parseInt(data.get('staticPoints')?.toString()!),
22 | },
23 | });
24 |
25 | revalidatePath('/admin/challenges');
26 | }
27 |
28 | export async function editChallenge(data: FormData) {
29 | let middleware = await Middleware([admin()]);
30 | if (middleware) return middleware;
31 |
32 | await prisma.challenge.update({
33 | where: {
34 | id: data.get('id')?.toString(),
35 | },
36 | data: {
37 | name: data.get('name')?.toString()!,
38 | description: data.get('description')?.toString()!,
39 | files: data.get('files')?.toString()!.split(','),
40 | image: data.get('image')?.toString()!,
41 | flag: data.get('flag')?.toString()!,
42 | category: data.get('category')?.toString()! as Category,
43 | difficulty: data.get('difficulty')?.toString()! as Difficulty,
44 | solved: undefined,
45 | staticPoints: parseInt(data.get('staticPoints')?.toString()!),
46 | },
47 | });
48 |
49 | revalidatePath('/admin/challenges');
50 | }
51 |
52 | export async function deleteChallenge(data: FormData) {
53 | let middleware = await Middleware([admin()]);
54 | if (middleware) return middleware;
55 |
56 | await prisma.challenge.delete({
57 | where: {
58 | id: data.get('id')?.toString(),
59 | },
60 | });
61 |
62 | revalidatePath('/admin/challenges');
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/api/teams/join/route.ts:
--------------------------------------------------------------------------------
1 | import Ajv, { JSONSchemaType } from 'ajv';
2 | import { StatusCodes } from 'http-status-codes';
3 | import { getServerSession } from 'next-auth';
4 | import { NextResponse } from 'next/server';
5 | import prisma from '@/lib/prismadb';
6 |
7 | const ajv = new Ajv();
8 |
9 | interface JoinTeamRequest {
10 | secret: string;
11 | }
12 |
13 | const JoinTeamRequestSchema: JSONSchemaType = {
14 | type: 'object',
15 | properties: {
16 | secret: {
17 | type: 'string',
18 | },
19 | },
20 | required: ['secret'],
21 | };
22 |
23 | const joinTeamRequestValidator = ajv.compile(JoinTeamRequestSchema);
24 |
25 | export async function POST(req: Request) {
26 | const session = await getServerSession();
27 |
28 | if (!session) {
29 | return NextResponse.json(
30 | {
31 | Error: 'You must be logged in to preform this action.',
32 | },
33 | {
34 | status: StatusCodes.UNAUTHORIZED,
35 | },
36 | );
37 | }
38 |
39 | const data = await req.json();
40 |
41 | if (!joinTeamRequestValidator(data)) {
42 | return NextResponse.json(
43 | {
44 | Error: 'Bad request.',
45 | },
46 | {
47 | status: StatusCodes.BAD_REQUEST,
48 | },
49 | );
50 | }
51 |
52 | const user = await prisma.user.findFirst({
53 | where: {
54 | name: session.user?.name!,
55 | },
56 | });
57 |
58 | const team = await prisma.team.findFirst({
59 | where: {
60 | secret: data.secret,
61 | },
62 | include: {
63 | members: true,
64 | },
65 | });
66 |
67 | if (!team) {
68 | return NextResponse.json(
69 | {
70 | Error: 'Bad secret.',
71 | },
72 | {
73 | status: StatusCodes.FORBIDDEN,
74 | },
75 | );
76 | }
77 |
78 | await prisma.team.update({
79 | where: {
80 | secret: data.secret,
81 | },
82 | data: {
83 | members: {
84 | connect: {
85 | id: user!.id,
86 | },
87 | },
88 | },
89 | });
90 |
91 | team!.members.forEach((member) => {
92 | member.email = '';
93 | });
94 |
95 | return NextResponse.json(team, {
96 | status: StatusCodes.OK,
97 | });
98 | }
99 |
--------------------------------------------------------------------------------
/src/styles/Glitch.module.css:
--------------------------------------------------------------------------------
1 | /**********GLITCH ANIMATION**********/
2 | :hover.stack {
3 | display: grid;
4 | grid-template-columns: 1fr;
5 | }
6 |
7 | :hover.stack code {
8 | animation: glitch 1s infinite alternate-reverse, glitch2 0.5s linear infinite;
9 | }
10 |
11 | @keyframes glitch {
12 | 0% {
13 | text-shadow: -2px 3px 0 red, 2px -3px 0 blue;
14 | }
15 |
16 | 10% {
17 | text-shadow: -3px 2px 0 red, 1px -2px 0 blue;
18 | }
19 |
20 | 25% {
21 | text-shadow: none;
22 | transform: none;
23 | }
24 |
25 | 50% {
26 | text-shadow: -2px -3px 0 red, -2px 3px 0 blue;
27 | }
28 |
29 | 75% {
30 | text-shadow: none;
31 | transform: none;
32 | }
33 |
34 | 100% {
35 | text-shadow: none;
36 | transform: none;
37 | }
38 | }
39 |
40 | @keyframes glitch2 {
41 |
42 | 2%,
43 | 64% {
44 | transform: translate(2px, 0) skew(0deg);
45 | }
46 |
47 | 4%,
48 | 60% {
49 | transform: translate(-2px, 0) skew(0deg);
50 | }
51 |
52 | 62% {
53 | transform: translate(0, 0) skew(5deg);
54 | }
55 | }
56 |
57 | :hover.glitch:before,
58 | :hover.glitch:after {
59 | content: attr(id);
60 | position: absolute;
61 | top: 0%;
62 | left: 50%;
63 | transform: translate(-50%);
64 | }
65 |
66 | :hover.glitch:before {
67 | animation: glitchTop 1s linear infinite;
68 | clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
69 | -webkit-clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
70 | }
71 |
72 | @keyframes glitchTop {
73 |
74 | 2%,
75 | 64% {
76 | transform: translate(2px, -2px);
77 | }
78 |
79 | 4%,
80 | 60% {
81 | transform: translate(-2px, 2px);
82 | }
83 |
84 | 62% {
85 | transform: translate(13px, -1px) skew(-13deg);
86 | }
87 | }
88 |
89 | :hover.glitch:after {
90 | animation: glitchBotom 1.5s linear infinite;
91 | clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
92 | -webkit-clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
93 | }
94 |
95 | @keyframes glitchBotom {
96 |
97 | 2%,
98 | 64% {
99 | transform: translate(-2px, 0);
100 | }
101 |
102 | 4%,
103 | 60% {
104 | transform: translate(-2px, 0);
105 | }
106 |
107 | 62% {
108 | transform: translate(-12px, 5px) skew(21deg);
109 | }
110 | }
111 |
112 | .entry:hover {
113 | font-weight: bold;
114 | }
--------------------------------------------------------------------------------
/src/app/api/settings/route.ts:
--------------------------------------------------------------------------------
1 | import Ajv, { JSONSchemaType } from 'ajv';
2 | import { StatusCodes } from 'http-status-codes';
3 | import { admin, Middleware } from '@/lib/Middleware';
4 | import { NextResponse } from 'next/server';
5 | import prisma from '@/lib/prismadb';
6 |
7 | const ajv = new Ajv();
8 |
9 | interface SettingRequest {
10 | key: string;
11 | value: string;
12 | pub: boolean;
13 | }
14 |
15 | const SettingRequestSchema: JSONSchemaType = {
16 | type: 'object',
17 | properties: {
18 | key: { type: 'string' },
19 | value: { type: 'string' },
20 | pub: { type: 'boolean' },
21 | },
22 | required: ['key', 'value', 'pub'],
23 | };
24 |
25 | const settingRequestValidator = ajv.compile(SettingRequestSchema);
26 |
27 | export async function GET() {
28 | let middleware = await Middleware([admin()]);
29 | if (middleware) return middleware;
30 |
31 | let settings = await prisma.setting.findMany();
32 |
33 | return NextResponse.json(settings, {
34 | status: StatusCodes.OK,
35 | });
36 | }
37 |
38 | export async function POST(req: Request) {
39 | let middleware = await Middleware([admin()]);
40 | if (middleware) return middleware;
41 |
42 | let reqsetting = await req.json();
43 |
44 | if (settingRequestValidator(reqsetting)) {
45 | const { key, value, pub } = reqsetting;
46 |
47 | let curr = await prisma.setting.findFirst({
48 | where: {
49 | key: key,
50 | },
51 | });
52 |
53 | if (curr) {
54 | await prisma.setting.update({
55 | where: {
56 | key: key,
57 | },
58 | data: {
59 | value: value,
60 | public: pub,
61 | },
62 | });
63 |
64 | return NextResponse.json(curr, {
65 | status: StatusCodes.OK,
66 | });
67 | }
68 |
69 | let setting = await prisma.setting.create({
70 | data: {
71 | key: key,
72 | value: value,
73 | public: pub,
74 | },
75 | });
76 |
77 | return NextResponse.json(setting, {
78 | status: StatusCodes.CREATED,
79 | });
80 | } else {
81 | return NextResponse.json(
82 | {
83 | Error: 'Bad request.',
84 | },
85 | {
86 | status: StatusCodes.BAD_REQUEST,
87 | },
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/Code.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useCallback, useState } from 'react';
4 | import { Copy, Check } from 'lucide-react';
5 |
6 | // Define an interface for the component's props
7 | interface CodeProps {
8 | children: React.ReactNode;
9 | }
10 |
11 | const Code: React.FC = ({ children }) => {
12 | const [copied, setCopied] = useState(false); // Track whether the text has been copied
13 |
14 | // Define a function to handle the click event and copy the content to the clipboard
15 | const handleCopyToClipboard = useCallback(() => {
16 | if (navigator.clipboard) {
17 | navigator.clipboard.writeText(String(children)).then(
18 | () => {
19 | console.log('Text successfully copied to clipboard');
20 | setCopied(true); // Update the state to indicate that the text has been copied
21 | setTimeout(() => {
22 | setCopied(false); // Reset the state after 1 second to revert the animation
23 | }, 1000);
24 | },
25 | (err) => {
26 | console.error('Unable to copy text to clipboard', err);
27 | },
28 | );
29 | } else {
30 | console.error('Clipboard API not supported');
31 | }
32 | }, [children]);
33 |
34 | return (
35 |
36 |
41 | {children}
42 |
43 |
44 | {copied ? (
45 |
49 | ) : (
50 |
55 | )}
56 |
57 |
58 | );
59 | };
60 |
61 | export default Code;
62 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from './Button';
2 | import { getUser } from '@/lib/Utils';
3 |
4 | const Navbar = async () => {
5 | const user = await getUser();
6 |
7 | return (
8 |
9 |
17 |
18 |
26 |
27 |
35 |
36 | {user && (
37 |
47 | )}
48 |
49 | {user?.admin && (
50 |
58 | )}
59 |
60 |
70 |
71 | );
72 | };
73 |
74 | export default Navbar;
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pail",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "yarn lint --fix && prisma db push && next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "cypress": "cypress open",
11 | "e2e": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"",
12 | "e2e:headless": "start-server-and-test dev http://localhost:3000 \"cypress run --e2e\"",
13 | "component": "cypress open --component",
14 | "component:headless": "cypress run --component"
15 | },
16 | "dependencies": {
17 | "@next-auth/prisma-adapter": "^1.0.7",
18 | "@prisma/client": "^5.3.0",
19 | "@radix-ui/react-popover": "^1.0.6",
20 | "@tailwindcss/typography": "^0.5.10",
21 | "@tidyjs/tidy": "^2.5.2",
22 | "@types/cypress": "^1.1.3",
23 | "@types/dockerode": "^3.3.19",
24 | "@types/http-status-codes": "^1.2.0",
25 | "@types/is-string": "^1.0.0",
26 | "@types/node": "^20.6.0",
27 | "@types/node-forge": "^1.3.5",
28 | "@types/react": "18.2.21",
29 | "@types/react-dom": "18.2.7",
30 | "@typescript-eslint/eslint-plugin": "^6.7.0",
31 | "@typescript-eslint/parser": "^6.7.0",
32 | "ajv": "^8.12.0",
33 | "autoprefixer": "^10.4.15",
34 | "bufferutil": "^4.0.7",
35 | "class-variance-authority": "^0.7.0",
36 | "cmdk": "^0.2.0",
37 | "discord.js": "^14.13.0",
38 | "dockerode": "^3.3.5",
39 | "echarts": "^5.4.3",
40 | "erlpack": "^0.1.4",
41 | "eslint": "8.49.0",
42 | "eslint-config-next": "13.4.19",
43 | "eslint-config-prettier": "^9.0.0",
44 | "eslint-plugin-prettier": "^5.0.0",
45 | "http-status-codes": "^2.2.0",
46 | "is-string": "^1.0.7",
47 | "lucide-react": "^0.277.0",
48 | "marked-react": "^2.0.0",
49 | "next": "^13.4.19",
50 | "next-auth": "^4.23.1",
51 | "postcss": "^8.4.29",
52 | "prettier": "^3.0.3",
53 | "prisma": "^5.3.0",
54 | "react": "^18.2.0",
55 | "react-chartjs-2": "^5.2.0",
56 | "react-dom": "^18.2.0",
57 | "react-markdown": "^8.0.7",
58 | "sharp": "^0.32.5",
59 | "start-server-and-test": "^2.0.0",
60 | "tailwind-merge": "^1.14.0",
61 | "tailwindcss": "^3.3.3",
62 | "ts-node": "^10.9.1",
63 | "typescript": "5.2.2",
64 | "utf-8-validate": "^6.0.3",
65 | "winston": "^3.10.0",
66 | "zlib-sync": "^0.1.8"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { VariantProps, cva } from 'class-variance-authority';
3 |
4 | import { bkct } from '@/lib/ClientUtils';
5 |
6 | const inputVariants = cva(
7 | 'inline-flex items-center justify-center rounded-md text-sm font-mono transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'bg-slate-900 hover:bg-slate-950 dark:hover:bg-gray-200 text-white dark:bg-slate-50 dark:text-slate-900',
13 | outline:
14 | 'bg-transparent border border-slate-200 dark:border-slate-700 dark:text-slate-100 hover:bg-slate-950 dark:hover:bg-gray-900',
15 | subtle: 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-100 hover:bg-slate-950 dark:hover:bg-slate-800 border dark:border-slate-700',
16 | error: 'bg-slate-900 text-white dark:bg-slate-50 hover:bg-slate-950 dark:hover:bg-gray-200 dark:text-slate-900 ring-offset-2 ring ring-rose-500',
17 | success:
18 | 'bg-slate-900 text-white hover:bg-slate-950 dark:hover:bg-gray-200 dark:bg-slate-50 dark:text-slate-900 ring-offset-2 ring ring-emerald-500',
19 | disabled:
20 | 'bg-slate-50 text-white dark:bg-slate-900 dark:text-slate-900 ',
21 | },
22 | size: {
23 | default: 'h-10 py-2 px-4',
24 | sm: 'h-9 px-2 rounded-md',
25 | lg: 'h-11 px-8 rounded-md',
26 | },
27 | },
28 | defaultVariants: {
29 | variant: 'default',
30 | size: 'default',
31 | },
32 | },
33 | );
34 | //@ts-ignore
35 | export interface InputProps
36 | extends VariantProps,
37 | React.InputHTMLAttributes {
38 | placeholder?: string;
39 | }
40 |
41 | const Input = React.forwardRef(
42 | ({ className, variant, size, ...props }, ref) => {
43 | const isDisabled = variant === 'disabled';
44 | return (
45 |
51 | );
52 | },
53 | );
54 | Input.displayName = 'Input';
55 |
56 | export { Input, inputVariants };
57 |
--------------------------------------------------------------------------------
/src/app/api/rankings/route.ts:
--------------------------------------------------------------------------------
1 | import { Challenge, Solve, Team } from '@prisma/client';
2 | import { tidy, mutate, arrange, desc } from '@tidyjs/tidy';
3 | import { StatusCodes } from 'http-status-codes';
4 | import { CTFStart, Middleware } from '@/lib/Middleware';
5 | import { NextResponse } from 'next/server';
6 | import prisma from '@/lib/prismadb';
7 |
8 | function getColor() {
9 | return `rgba(${255 * Math.random()}, ${255 * Math.random()}, ${
10 | 255 * Math.random()
11 | }, 0.25)`;
12 | }
13 |
14 | export async function GET() {
15 | let middleware = await Middleware([CTFStart()]);
16 | if (middleware) return middleware;
17 |
18 | let teams: (Team & {
19 | solves: Solve[];
20 | points?: number;
21 | })[] = await prisma.team.findMany({
22 | include: {
23 | solves: true,
24 | },
25 | });
26 |
27 | let challenges: (Challenge & {
28 | solved: Solve[];
29 | points?: number;
30 | })[] = await prisma.challenge.findMany({
31 | include: {
32 | solved: true,
33 | },
34 | });
35 |
36 | challenges = tidy(
37 | challenges,
38 | mutate({
39 | points: (
40 | challenge: Challenge & {
41 | solved: Solve[];
42 | },
43 | ) =>
44 | challenge.staticPoints
45 | ? challenge.staticPoints
46 | : challenge.solved.length > 150
47 | ? 200
48 | : 500 - challenge.solved.length * 2,
49 | }),
50 | );
51 |
52 | teams = tidy(
53 | teams,
54 | mutate({
55 | points: (
56 | team: Team & {
57 | solves: Solve[];
58 | points?: number;
59 | },
60 | ) => {
61 | let points = 0;
62 | team.solves.forEach((solve) => {
63 | points += challenges.find(
64 | (challenge) => challenge.id == solve.challengeId,
65 | )?.points as number;
66 | });
67 | return points;
68 | },
69 | }),
70 | );
71 |
72 | let rankings: Array<{
73 | label: string;
74 | id: string;
75 | data: number[];
76 | backgroundColor: string;
77 | }> = [];
78 |
79 | teams.forEach((team) => {
80 | rankings.push({
81 | label: team.name,
82 | id: team.id,
83 | data: [team.points ?? 0],
84 | backgroundColor: getColor(),
85 | });
86 | });
87 |
88 | rankings = tidy(rankings, arrange(desc('data')));
89 |
90 | return NextResponse.json(rankings, {
91 | status: StatusCodes.OK,
92 | });
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/api/rankings/ctftime/route.ts:
--------------------------------------------------------------------------------
1 | import { Solve, Challenge, Team } from '@prisma/client';
2 | import { tidy, mutate, arrange, desc } from '@tidyjs/tidy';
3 | import { StatusCodes } from 'http-status-codes';
4 | import { CTFStart, Middleware } from '@/lib/Middleware';
5 | import { NextResponse } from 'next/server';
6 | import prisma from '@/lib/prismadb';
7 |
8 | export async function GET() {
9 | let middleware = await Middleware([CTFStart()]);
10 | if (middleware) return middleware;
11 |
12 | let teams: (Team & {
13 | solves: Solve[];
14 | points?: number;
15 | })[] = await prisma.team.findMany({
16 | include: {
17 | solves: true,
18 | },
19 | });
20 |
21 | let challenges: (Challenge & {
22 | solved: Solve[];
23 | points?: number;
24 | })[] = await prisma.challenge.findMany({
25 | include: {
26 | solved: true,
27 | },
28 | });
29 |
30 | challenges = tidy(
31 | challenges,
32 | mutate({
33 | points: (
34 | challenge: Challenge & {
35 | solved: Solve[];
36 | },
37 | ) =>
38 | challenge.staticPoints
39 | ? challenge.staticPoints
40 | : challenge.solved.length > 150
41 | ? 200
42 | : 500 - challenge.solved.length * 2,
43 | }),
44 | );
45 |
46 | teams = tidy(
47 | teams,
48 | mutate({
49 | points: (
50 | team: Team & {
51 | solves: Solve[];
52 | points?: number;
53 | },
54 | ) => {
55 | let points = 0;
56 | team.solves.forEach((solve) => {
57 | points += challenges.find(
58 | (challenge) => challenge.id == solve.challengeId,
59 | )?.points as number;
60 | });
61 | return points;
62 | },
63 | }),
64 | );
65 |
66 | let rankings: Array<{
67 | team: string;
68 | id: string;
69 | score: number;
70 | pos?: number;
71 | }> = [];
72 |
73 | teams.forEach((team) => {
74 | rankings.push({
75 | team: team.name,
76 | id: team.id,
77 | score: team.points ?? 0,
78 | });
79 | });
80 |
81 | rankings = tidy(rankings, arrange(desc('score')));
82 |
83 | for (let index = 0; index < rankings.length; index++) {
84 | const ranking = rankings[index];
85 | ranking.pos = index + 1;
86 | }
87 |
88 | return NextResponse.json(
89 | {
90 | standings: rankings,
91 | },
92 | {
93 | status: StatusCodes.OK,
94 | },
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/containers/ContainerDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from '@prisma/client';
2 | import prisma from '@/lib/prismadb';
3 | import Dockerode from 'dockerode';
4 | import { Button } from '../Button';
5 | import { Container as ContainerIcon, Flag, User } from 'lucide-react';
6 | import { revalidatePath } from 'next/cache';
7 |
8 | interface Props {
9 | container: Container;
10 | }
11 |
12 | const ContainerDetails = async ({ container }: Props) => {
13 | let user = await prisma.user.findUnique({
14 | where: {
15 | id: container.userId,
16 | },
17 | });
18 |
19 | let challenge = await prisma.challenge.findUnique({
20 | where: {
21 | id: container.challengeId,
22 | },
23 | });
24 |
25 | async function stopAndRemove() {
26 | 'use server';
27 |
28 | let host = await prisma.host.findUnique({
29 | where: {
30 | id: container.hostId,
31 | },
32 | });
33 |
34 | let docker = new Dockerode({
35 | host: host!.remote,
36 | port: host!.port ?? 2375,
37 | ca: host!.ca!,
38 | cert: host!.cert!,
39 | key: host!.key!,
40 | });
41 |
42 | let dockerContainer = docker.getContainer(container.id);
43 |
44 | await dockerContainer.kill();
45 | await dockerContainer.remove();
46 |
47 | await prisma.container.delete({
48 | where: {
49 | id: dockerContainer.id,
50 | },
51 | });
52 |
53 | revalidatePath('/admin/containers');
54 | }
55 |
56 | return (
57 |
58 |
59 |
60 |
61 | {user!.name}
62 |
63 |
64 |
65 |
66 | {container.id.substring(0, 12)}
67 |
68 |
69 |
70 |
71 | {challenge?.name}
72 |
73 |
74 |
83 |
84 | );
85 | };
86 |
87 | export default ContainerDetails;
88 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Button } from './Button';
3 |
4 | interface Props {
5 | visible: boolean;
6 | children?: React.ReactNode;
7 | onClose?: () => void;
8 | }
9 |
10 | const Modal = ({ visible, children, onClose }: Props) => {
11 | const [render, setRender] = useState(visible);
12 |
13 | useEffect(() => {
14 | setRender(visible);
15 | }, [visible]);
16 |
17 | useEffect(() => {
18 | if (!render && onClose) {
19 | onClose();
20 | }
21 | }, [render]);
22 |
23 | useEffect(() => {
24 | const handler = (e: KeyboardEvent) => {
25 | if (e.key === 'Escape') setRender(false);
26 | };
27 |
28 | window.addEventListener('keydown', handler);
29 | return () => {
30 | window.removeEventListener('keydown', handler);
31 | };
32 | }, []);
33 |
34 | return (
35 | <>
36 | {render ? (
37 |
41 |
48 |
49 |
68 |
69 |
{children}
70 |
71 |
72 | ) : (
73 | <>>
74 | )}
75 | >
76 | );
77 | };
78 |
79 | export default Modal;
80 |
--------------------------------------------------------------------------------
/src/app/api/hosts/route.ts:
--------------------------------------------------------------------------------
1 | import Ajv, { JSONSchemaType } from 'ajv';
2 | import { StatusCodes } from 'http-status-codes';
3 | import { admin, Middleware } from '@/lib/Middleware';
4 | import { NextResponse } from 'next/server';
5 | import prisma from '@/lib/prismadb';
6 |
7 | const ajv = new Ajv();
8 |
9 | interface CreateHostRequest {
10 | port?: number;
11 | remote: string;
12 | ip?: string;
13 | ca?: string;
14 | cert?: string;
15 | key?: string;
16 | }
17 |
18 | const CreateHostSchema: JSONSchemaType = {
19 | type: 'object',
20 | properties: {
21 | port: { type: 'integer', nullable: true, minimum: 1, maximum: 65535 },
22 | remote: { type: 'string' },
23 | ip: { type: 'string', nullable: true },
24 | ca: { type: 'string', nullable: true },
25 | cert: { type: 'string', nullable: true },
26 | key: { type: 'string', nullable: true },
27 | },
28 | required: ['remote'],
29 | };
30 |
31 | const createHostValidator = ajv.compile(CreateHostSchema);
32 |
33 | export async function GET() {
34 | let middleware = await Middleware([admin()]);
35 | if (middleware) return middleware;
36 |
37 | const hosts = await prisma.host.findMany();
38 |
39 | return NextResponse.json(hosts, {
40 | status: StatusCodes.OK,
41 | });
42 | }
43 |
44 | export async function POST(req: Request) {
45 | let middleware = await Middleware([admin()]);
46 | if (middleware) return middleware;
47 |
48 | const content = await req.json();
49 |
50 | if (!createHostValidator(content)) {
51 | return NextResponse.json(
52 | {
53 | Error: 'Bad request',
54 | },
55 | {
56 | status: StatusCodes.BAD_REQUEST,
57 | },
58 | );
59 | }
60 |
61 | const host = await prisma.host.create({
62 | data: {
63 | port: content.port,
64 | remote: content.remote,
65 | ip: content.ip,
66 | ca: content.ca,
67 | cert: content.cert,
68 | key: content.key,
69 | },
70 | });
71 |
72 | return NextResponse.json(host, {
73 | status: StatusCodes.CREATED,
74 | });
75 | }
76 |
77 | export async function PATCH(req: Request) {
78 | let middleware = await Middleware([admin()]);
79 | if (middleware) return middleware;
80 |
81 | const content = await req.json();
82 |
83 | if (!createHostValidator(content)) {
84 | return NextResponse.json(
85 | {
86 | Error: 'Bad request',
87 | },
88 | {
89 | status: StatusCodes.BAD_REQUEST,
90 | },
91 | );
92 | }
93 |
94 | const host = await prisma.host.update({
95 | where: {
96 | remote: content.remote,
97 | },
98 | data: {
99 | port: content.port,
100 | ip: content.ip,
101 | ca: content.ca,
102 | cert: content.cert,
103 | key: content.key,
104 | },
105 | });
106 |
107 | return NextResponse.json(host, {
108 | status: StatusCodes.OK,
109 | });
110 | }
111 |
--------------------------------------------------------------------------------
/src/app/api/challenges/route.ts:
--------------------------------------------------------------------------------
1 | import { Category, Challenge, Difficulty, Solve } from '@prisma/client';
2 | import Ajv, { JSONSchemaType } from 'ajv';
3 | import { StatusCodes } from 'http-status-codes';
4 | import { admin, CTFStart, Middleware } from '@/lib/Middleware';
5 | import { NextResponse } from 'next/server';
6 | import prisma from '@/lib/prismadb';
7 |
8 | const ajv = new Ajv();
9 |
10 | interface CreateChallengeRequest {
11 | name: string;
12 | description: string;
13 | files?: string[];
14 | image?: string;
15 | flag: string;
16 | category: string;
17 | difficulty: string;
18 | staticPoints?: number;
19 | }
20 |
21 | const CreateChallengeRequestSchema: JSONSchemaType = {
22 | type: 'object',
23 | properties: {
24 | name: { type: 'string', minLength: 1, maxLength: 100 },
25 | description: { type: 'string', minLength: 1 },
26 | files: { type: 'array', items: { type: 'string' }, nullable: true },
27 | image: { type: 'string', nullable: true },
28 | flag: { type: 'string', minLength: 4 },
29 | category: {
30 | type: 'string',
31 | enum: ['WEB', 'CRYPTO', 'REV', 'PWN', 'MISC'],
32 | },
33 | difficulty: {
34 | type: 'string',
35 | enum: ['EASY', 'MEDIUM', 'HARD'],
36 | },
37 | staticPoints: { type: 'integer', nullable: true },
38 | },
39 | required: ['name', 'description', 'flag'],
40 | };
41 |
42 | const createChallengeRequestValidator = ajv.compile(
43 | CreateChallengeRequestSchema,
44 | );
45 |
46 | export async function GET() {
47 | let middleware = await Middleware([CTFStart()]);
48 | if (middleware) return middleware;
49 |
50 | const challenges: Partial<
51 | Challenge & {
52 | solved: Solve[];
53 | }
54 | >[] = await prisma.challenge.findMany({
55 | include: {
56 | solved: true,
57 | },
58 | });
59 |
60 | challenges.forEach(async (challenge) => {
61 | delete challenge.flag;
62 | delete challenge.solved;
63 | });
64 |
65 | return NextResponse.json(challenges);
66 | }
67 |
68 | export async function POST(req: Request) {
69 | let middleware = await Middleware([admin()]);
70 | if (middleware) return middleware;
71 |
72 | const content = await req.json();
73 |
74 | if (!createChallengeRequestValidator(content)) {
75 | return NextResponse.json(
76 | {
77 | Error: 'Bad request',
78 | },
79 | {
80 | status: StatusCodes.BAD_REQUEST,
81 | },
82 | );
83 | }
84 |
85 | const challenge = await prisma.challenge.create({
86 | data: {
87 | name: content.name,
88 | description: content.description,
89 | files: content.files ?? [],
90 | image: content.image,
91 | flag: content.flag,
92 | category: content.category as Category,
93 | difficulty: content.difficulty as Difficulty,
94 | solved: undefined,
95 | staticPoints: content.staticPoints,
96 | },
97 | });
98 |
99 | return NextResponse.json(challenge, {
100 | status: StatusCodes.CREATED,
101 | });
102 | }
103 |
--------------------------------------------------------------------------------
/src/app/api/teams/route.ts:
--------------------------------------------------------------------------------
1 | import { Team } from '@prisma/client';
2 | import Ajv, { JSONSchemaType } from 'ajv';
3 | import { StatusCodes } from 'http-status-codes';
4 | import { getServerSession } from 'next-auth';
5 | import { NextResponse } from 'next/server';
6 | import prisma from '@/lib/prismadb';
7 |
8 | const ajv = new Ajv();
9 | interface CreateTeamRequest {
10 | name: string;
11 | }
12 |
13 | const CreateTeamRequestSchema: JSONSchemaType = {
14 | type: 'object',
15 | properties: {
16 | name: { type: 'string', minLength: 4, maxLength: 50 },
17 | },
18 | required: ['name'],
19 | };
20 |
21 | const createTeamRequestValidator = ajv.compile(CreateTeamRequestSchema);
22 |
23 | export async function GET() {
24 | const teams: Partial[] = await prisma.team.findMany();
25 |
26 | teams.forEach((team) => {
27 | delete team.secret;
28 | });
29 |
30 | return NextResponse.json(teams, {
31 | status: StatusCodes.OK,
32 | });
33 | }
34 |
35 | export async function POST(req: Request) {
36 | const session = await getServerSession();
37 |
38 | if (!session) {
39 | return NextResponse.json(
40 | {
41 | Error: 'You must be logged in to preform this action.',
42 | },
43 | {
44 | status: StatusCodes.UNAUTHORIZED,
45 | },
46 | );
47 | }
48 |
49 | let teamreq = await req.json();
50 |
51 | if (createTeamRequestValidator(teamreq)) {
52 | const { name } = teamreq;
53 |
54 | const user = await prisma.user.findFirst({
55 | where: {
56 | name: session?.user?.name,
57 | },
58 | });
59 |
60 | if (user?.teamId) {
61 | return NextResponse.json(
62 | {
63 | Error: 'Leave your current team first.',
64 | },
65 | {
66 | status: StatusCodes.BAD_REQUEST,
67 | },
68 | );
69 | }
70 |
71 | const currTeam = await prisma.team.findFirst({
72 | where: {
73 | name: name,
74 | },
75 | });
76 |
77 | if (currTeam) {
78 | return NextResponse.json(
79 | {
80 | Error: 'This team name is already taken.',
81 | },
82 | {
83 | status: StatusCodes.BAD_REQUEST,
84 | },
85 | );
86 | }
87 |
88 | const team = await prisma.team.create({
89 | data: {
90 | name: name,
91 | members: {
92 | connect: {
93 | id: user?.id,
94 | },
95 | },
96 | },
97 | include: {
98 | members: true,
99 | },
100 | });
101 |
102 | return NextResponse.json(team, {
103 | status: StatusCodes.TEMPORARY_REDIRECT,
104 | headers: {
105 | Location: '/account',
106 | },
107 | });
108 | } else {
109 | return NextResponse.json(
110 | {
111 | Error: 'Team name can have a maximum length of 50 characters.',
112 | },
113 | {
114 | status: StatusCodes.BAD_REQUEST,
115 | },
116 | );
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/app/admin/page.tsx:
--------------------------------------------------------------------------------
1 | import { Error } from '@/components/Error';
2 | import { admin } from '@/lib/Middleware';
3 | import { Button } from '@/components/Button';
4 |
5 | export const metadata = {
6 | title: 'EBucket | Admin',
7 | };
8 |
9 | export default async function Home() {
10 | if (await admin()) {
11 | return ;
12 | }
13 |
14 | return (
15 |
16 |
17 |
25 |
26 |
27 |
35 |
36 |
37 |
45 |
46 |
47 |
55 |
56 |
57 |
65 |
66 |
67 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/public/discord.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { VariantProps, cva } from 'class-variance-authority';
5 | import { bkct } from '@/lib/ClientUtils';
6 | import Link from 'next/link';
7 |
8 | const buttonVariants = cva(
9 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800',
10 | {
11 | variants: {
12 | variant: {
13 | default:
14 | 'bg-slate-900 dark:hover:bg-slate-700 dark:hover:text-white text-white dark:bg-slate-50 dark:text-slate-900',
15 | destructive:
16 | 'bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600',
17 | outline:
18 | 'bg-transparent border border-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 dark:border-slate-700 dark:text-slate-100',
19 | subtle: 'hover:bg-slate-800 bg-slate-700 text-slate-100 border border-slate-700',
20 | ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent',
21 | link: 'bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-indigo-500 dark:text-indigo-400 hover:bg-transparent dark:hover:bg-transparent',
22 | unstyled: '',
23 | },
24 | size: {
25 | default: 'h-10 py-2 px-4',
26 | sm: 'h-9 px-2 rounded-md',
27 | lg: 'h-11 px-8 rounded-md',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | },
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | link?: string;
41 | linkClassName?: string;
42 | target?: string;
43 | icon?: React.ReactNode;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | (
48 | {
49 | className,
50 | linkClassName,
51 | target,
52 | variant,
53 | link,
54 | size,
55 | icon,
56 | children,
57 | ...props
58 | },
59 | ref,
60 | ) => {
61 | if (link) {
62 | return (
63 |
64 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
87 | );
88 | },
89 | );
90 | Button.displayName = 'Button';
91 |
92 | export { Button, buttonVariants };
93 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL_NON_POOLING")
11 | }
12 |
13 | model Account {
14 | id String @id @default(cuid())
15 | userId String
16 | type String
17 | provider String
18 | providerAccountId String
19 | refresh_token String? @db.Text
20 | refresh_token_expires_in Int?
21 | access_token String? @db.Text
22 | expires_at Int?
23 | token_type String?
24 | scope String?
25 | id_token String? @db.Text
26 | session_state String?
27 |
28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
29 |
30 | @@unique([provider, providerAccountId])
31 | }
32 |
33 | model Session {
34 | id String @id @default(cuid())
35 | sessionToken String @unique
36 | userId String
37 | expires DateTime
38 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
39 | }
40 |
41 | model User {
42 | id String @id @default(cuid())
43 | name String?
44 | email String? @unique
45 | emailVerified DateTime?
46 | image String?
47 | accounts Account[]
48 | sessions Session[]
49 | team Team? @relation(fields: [teamId], references: [id])
50 | teamId String?
51 | solves Solve[]
52 | admin Boolean @default(false)
53 | containers Container[]
54 | }
55 |
56 | model VerificationToken {
57 | identifier String
58 | token String @unique
59 | expires DateTime
60 |
61 | @@unique([identifier, token])
62 | }
63 |
64 | model Challenge {
65 | id String @id @default(cuid())
66 | name String
67 | description String
68 | files String[]
69 | flag String
70 | solved Solve[]
71 | category Category @default(MISC)
72 | difficulty Difficulty @default(EASY)
73 | firstBlood DateTime?
74 | image String?
75 | staticPoints Int?
76 | containers Container[]
77 | }
78 |
79 | model Team {
80 | id String @id @default(cuid())
81 | name String @unique
82 | secret String @unique @default(cuid())
83 | members User[]
84 | solves Solve[]
85 | }
86 |
87 | model Solve {
88 | id String @id @default(cuid())
89 | challenge Challenge @relation(fields: [challengeId], references: [id])
90 | challengeId String
91 | team Team @relation(fields: [teamId], references: [id])
92 | teamId String
93 | user User @relation(fields: [userId], references: [id])
94 | userId String
95 | time DateTime
96 | }
97 |
98 | model Setting {
99 | key String @id @unique
100 | value String
101 | public Boolean @default(false)
102 | }
103 |
104 | model Host {
105 | id String @id @default(cuid())
106 | port Int?
107 | remote String @unique
108 | ip String?
109 | ca String?
110 | cert String?
111 | key String?
112 | containers Container[]
113 | }
114 |
115 | model Container {
116 | id String @id @unique
117 | host Host @relation(fields: [hostId], references: [id])
118 | hostId String
119 | created DateTime
120 | challenge Challenge @relation(fields: [challengeId], references: [id])
121 | challengeId String
122 | user User @relation(fields: [userId], references: [id])
123 | userId String
124 | }
125 |
126 | enum Category {
127 | WEB
128 | CRYPTO
129 | REV
130 | PWN
131 | MISC
132 | }
133 |
134 | enum Difficulty {
135 | EASY
136 | MEDIUM
137 | HARD
138 | }
139 |
--------------------------------------------------------------------------------
/src/app/api/challenges/host/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import prisma from '@/lib/prismadb';
2 | import Dockerode, { AuthConfig } from 'dockerode';
3 | import { StatusCodes } from 'http-status-codes';
4 | import isString from 'is-string';
5 | import { CTFStart, Middleware, user } from '@/lib/Middleware';
6 | import { NextResponse } from 'next/server';
7 | import { getUser } from '@/lib/Utils';
8 | import { User } from '@prisma/client';
9 |
10 | export async function POST(
11 | req: Request,
12 | { params }: { params: { id?: string } },
13 | ) {
14 | let middleware = await Middleware([CTFStart(), user()]);
15 | if (middleware) return middleware;
16 |
17 | let u = (await getUser()) as User;
18 |
19 | const { id } = params;
20 |
21 | if (!isString(id)) {
22 | return NextResponse.json(
23 | {
24 | Error: 'Bad request.',
25 | },
26 | {
27 | status: StatusCodes.BAD_REQUEST,
28 | },
29 | );
30 | }
31 |
32 | let challenge = await prisma.challenge.findFirst({
33 | where: {
34 | id: id,
35 | },
36 | });
37 |
38 | if (!challenge || !challenge.image) {
39 | return NextResponse.json(
40 | {
41 | Error: 'Challenge not found or does not contain an image.',
42 | },
43 | {
44 | status: StatusCodes.NOT_FOUND,
45 | },
46 | );
47 | }
48 |
49 | let host = await prisma.host.findFirst();
50 |
51 | if (!host) {
52 | return NextResponse.json(
53 | {
54 | Error: 'No host available.',
55 | },
56 | {
57 | status: StatusCodes.SERVICE_UNAVAILABLE,
58 | },
59 | );
60 | }
61 |
62 | let docker = new Dockerode({
63 | host: host.remote,
64 | port: host.port ?? 2375,
65 | ca: host.ca!,
66 | cert: host.cert!,
67 | key: host.key!,
68 | });
69 |
70 | let cont = await prisma.container.findFirst({
71 | where: {
72 | userId: u.id,
73 | challengeId: challenge!.id,
74 | },
75 | });
76 |
77 | // Check if it was created 15 minutes ago
78 | if (cont && cont.created.getTime() - Date.now() > 900000) {
79 | await docker.getContainer(cont.id).kill();
80 | await docker.getContainer(cont.id).remove();
81 | await prisma.container.delete({
82 | where: {
83 | id: cont.id,
84 | },
85 | });
86 | } else if (cont) {
87 | return NextResponse.json(
88 | {
89 | Error: 'You already have a running container for this problem.',
90 | },
91 | {
92 | status: StatusCodes.SERVICE_UNAVAILABLE,
93 | },
94 | );
95 | }
96 |
97 | let auth: AuthConfig = {
98 | username: process.env.DOCKER_USERNAME!,
99 | password: process.env.DOCKER_PASSWORD!,
100 | serveraddress: 'https://ghcr.io',
101 | };
102 |
103 | await docker.pull(challenge.image, { authconfig: auth });
104 |
105 | let container = await docker.createContainer({
106 | Image: challenge.image,
107 | ExposedPorts: {
108 | '80/tcp': {},
109 | },
110 | HostConfig: {
111 | PortBindings: {
112 | '80/tcp': [{ HostPort: '0' }],
113 | },
114 | },
115 | });
116 |
117 | await prisma.container.create({
118 | data: {
119 | id: container.id,
120 | hostId: host.id,
121 | userId: u.id,
122 | challengeId: challenge.id,
123 | created: new Date(),
124 | },
125 | });
126 |
127 | await container.start();
128 |
129 | let port = (await container.inspect()).NetworkSettings.Ports['80/tcp'][0]
130 | .HostPort;
131 |
132 | return NextResponse.json(
133 | {
134 | url: host.ip + ':' + port,
135 | },
136 | {
137 | status: StatusCodes.OK,
138 | },
139 | );
140 | }
141 |
--------------------------------------------------------------------------------
/src/lib/Rankings.ts:
--------------------------------------------------------------------------------
1 | import { Challenge, Solve, Team } from '@prisma/client';
2 | import prisma from './prismadb';
3 | import { tidy, arrange, desc } from '@tidyjs/tidy';
4 |
5 | interface Ranking {
6 | team: Team & {
7 | solves: Solve[];
8 | points?: number;
9 | };
10 | pos: number;
11 | }
12 |
13 | /**
14 | * Gets ranking for all teams
15 | * @returns Rankings for all teams in the @interface Ranking
16 | */
17 | export async function getRankings(): Promise {
18 | let challenges: (Challenge & {
19 | solved: Solve[];
20 | points?: number;
21 | })[] = await prisma.challenge.findMany({
22 | include: {
23 | solved: true,
24 | },
25 | });
26 |
27 | await Promise.all(
28 | challenges.map(async (challenge) => {
29 | challenge.points = await pointValue(challenge);
30 | }),
31 | );
32 |
33 | let teams: (Team & {
34 | solves: Solve[];
35 | points?: number;
36 | })[] = await prisma.team.findMany({
37 | include: {
38 | solves: true,
39 | },
40 | });
41 |
42 | await Promise.all(
43 | teams.map(async (team) => {
44 | team.points = await countPoints(team, challenges);
45 | }),
46 | );
47 |
48 | teams = tidy(teams, arrange(desc('points')));
49 |
50 | return teams.map((team, i) => ({
51 | team: team,
52 | pos: i + 1,
53 | }));
54 | }
55 |
56 | /**
57 | * Calculates the point value of a challenge
58 | * @param challenge The challenge to determine the point value for, can supply solved array or points to make this an instant function
59 | * @returns The point value as a number
60 | * @see getRankings
61 | */
62 | export async function pointValue(
63 | challenge: Challenge & {
64 | solved?: Solve[];
65 | points?: number;
66 | },
67 | ): Promise {
68 | if (challenge.points) {
69 | return challenge.points;
70 | }
71 |
72 | if (!challenge.solved) {
73 | challenge.solved = await prisma.solve.findMany({
74 | where: {
75 | challengeId: challenge.id,
76 | },
77 | });
78 | }
79 |
80 | return challenge.staticPoints
81 | ? challenge.staticPoints
82 | : challenge.solved.length > 150
83 | ? 200
84 | : 500 - challenge.solved.length * 2;
85 | }
86 |
87 | /**
88 | * Gets total point value of team
89 | * @param team The team to count total points for
90 | * @param challenges Challege array, can be supplied with solves or point value to make an instant function
91 | * @returns The point value as a number
92 | */
93 | export async function countPoints(
94 | team: Team & {
95 | solves?: (Solve & {
96 | challenge?: Challenge & {
97 | solved: Solve[];
98 | };
99 | })[];
100 | },
101 | challenges?: (Challenge & {
102 | solved: Solve[];
103 | })[],
104 | ): Promise {
105 | if (!team.solves) {
106 | team.solves = await prisma.solve.findMany({
107 | where: {
108 | teamId: team.id,
109 | },
110 | });
111 | }
112 |
113 | if (team.solves.length == 0) {
114 | return 0;
115 | }
116 |
117 | if (team.solves[0].challenge) {
118 | challenges = team.solves.map((solve) => solve.challenge!);
119 | } else {
120 | let challId = team.solves.map((solve) => solve.challengeId);
121 |
122 | if (!challenges) {
123 | challenges = await prisma.challenge.findMany({
124 | where: {
125 | id: {
126 | in: challId,
127 | },
128 | },
129 | include: { solved: true },
130 | });
131 | } else {
132 | challenges = challenges.filter((challenge) => {
133 | return challenge.solved
134 | .map((solve) => solve.teamId)
135 | .includes(team.id);
136 | });
137 | }
138 | }
139 |
140 | return (
141 | await Promise.all(challenges.map((challenge) => pointValue(challenge!)))
142 | ).reduce((a, b) => a + b);
143 | }
144 |
--------------------------------------------------------------------------------
/src/app/admin/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { Input } from '@/components/Input';
5 | import { Button } from '@/components/Button';
6 |
7 | export const metadata = {
8 | title: 'EBucket | Admin | Settings',
9 | };
10 |
11 | export default async function Home() {
12 | if (await admin()) {
13 | return ;
14 | }
15 |
16 | const settings = [
17 | {
18 | key: 'CTF_START_TIME',
19 | name: 'CTF Start Time',
20 | submit: async (form: FormData) => {
21 | 'use server';
22 |
23 | await prisma.setting.upsert({
24 | where: {
25 | key: 'CTF_START_TIME',
26 | },
27 | update: {
28 | value: form.get('data')!.toString(),
29 | },
30 | create: {
31 | key: 'CTF_START_TIME',
32 | value: form.get('data')!.toString(),
33 | },
34 | });
35 | },
36 | datatype: 'datetime-local',
37 | },
38 | {
39 | key: 'CTF_END_TIME',
40 | name: 'CTF End Time',
41 | submit: async (form: FormData) => {
42 | 'use server';
43 |
44 | await prisma.setting.upsert({
45 | where: {
46 | key: 'CTF_END_TIME',
47 | },
48 | update: {
49 | value: form.get('data')!.toString(),
50 | },
51 | create: {
52 | key: 'CTF_END_TIME',
53 | value: form.get('data')!.toString(),
54 | },
55 | });
56 | },
57 | datatype: 'datetime-local',
58 | },
59 | {
60 | key: 'DISCORD_TOKEN',
61 | name: 'Discord Token',
62 | submit: async (form: FormData) => {
63 | 'use server';
64 |
65 | await prisma.setting.upsert({
66 | where: {
67 | key: 'DISCORD_TOKEN',
68 | },
69 | update: {
70 | value: form.get('data')!.toString(),
71 | },
72 | create: {
73 | key: 'DISCORD_TOKEN',
74 | value: form.get('data')!.toString(),
75 | },
76 | });
77 | },
78 | datatype: 'password',
79 | },
80 | ];
81 |
82 | return (
83 |
84 | {settings.map((setting) => (
85 |
120 | ))}
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/src/components/Dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as DialogPrimitive from '@radix-ui/react-dialog';
5 | import { X } from 'lucide-react';
6 |
7 | import { bkct } from '@/lib/ClientUtils';
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = ({
14 | className,
15 | children,
16 | ...props
17 | }: DialogPrimitive.DialogPortalProps) => (
18 |
19 |
20 | {children}
21 |
22 |
23 | );
24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
25 |
26 | const DialogOverlay = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ));
39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
40 |
41 | const DialogContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
46 |
47 |
55 | {children}
56 |
57 |
58 | Close
59 |
60 |
61 |
62 | ));
63 | DialogContent.displayName = DialogPrimitive.Content.displayName;
64 |
65 | const DialogHeader = ({
66 | className,
67 | ...props
68 | }: React.HTMLAttributes) => (
69 |
76 | );
77 | DialogHeader.displayName = 'DialogHeader';
78 |
79 | const DialogFooter = ({
80 | className,
81 | ...props
82 | }: React.HTMLAttributes) => (
83 |
90 | );
91 | DialogFooter.displayName = 'DialogFooter';
92 |
93 | const DialogTitle = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
105 | ));
106 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
107 |
108 | const DialogDescription = React.forwardRef<
109 | React.ElementRef,
110 | React.ComponentPropsWithoutRef
111 | >(({ className, ...props }, ref) => (
112 |
117 | ));
118 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
119 |
120 | export {
121 | Dialog,
122 | DialogTrigger,
123 | DialogContent,
124 | DialogHeader,
125 | DialogFooter,
126 | DialogTitle,
127 | DialogDescription,
128 | };
129 |
--------------------------------------------------------------------------------
/src/app/api/challenges/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import isString from 'is-string';
3 | import { admin, Middleware } from '@/lib/Middleware';
4 | import { NextRequest, NextResponse } from 'next/server';
5 | import prisma from '@/lib/prismadb';
6 | import Ajv, { JSONSchemaType } from 'ajv';
7 | import { Category, Difficulty } from '@prisma/client';
8 |
9 | export async function DELETE(
10 | req: NextRequest,
11 | { params }: { params: { id?: string } },
12 | ) {
13 | let middleware = await Middleware([admin()]);
14 | if (middleware) return middleware;
15 |
16 | const { id } = params;
17 |
18 | if (!isString(id)) {
19 | return NextResponse.json(
20 | {
21 | Error: 'Bad request.',
22 | },
23 | {
24 | status: StatusCodes.BAD_REQUEST,
25 | },
26 | );
27 | }
28 |
29 | let challenge = await prisma.challenge.findFirst({
30 | where: {
31 | id: id,
32 | },
33 | include: {
34 | solved: true,
35 | },
36 | });
37 |
38 | if (!challenge) {
39 | return NextResponse.json(
40 | {
41 | Error: 'Challenge not found.',
42 | },
43 | {
44 | status: StatusCodes.NOT_FOUND,
45 | },
46 | );
47 | }
48 |
49 | await prisma.solve.deleteMany({
50 | where: {
51 | challengeId: challenge.id,
52 | },
53 | });
54 |
55 | await prisma.challenge.delete({
56 | where: {
57 | id: id,
58 | },
59 | });
60 |
61 | return NextResponse.json(
62 | {
63 | Error: 'Challenge deleted.',
64 | },
65 | {
66 | status: StatusCodes.OK,
67 | },
68 | );
69 | }
70 |
71 | const ajv = new Ajv();
72 |
73 | interface EditChallengeRequest {
74 | name?: string;
75 | description?: string;
76 | files?: string[];
77 | image?: string;
78 | flag?: string;
79 | category?: string;
80 | difficulty?: string;
81 | staticPoints?: number;
82 | }
83 |
84 | const EditChallengeSchema: JSONSchemaType = {
85 | type: 'object',
86 | properties: {
87 | name: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
88 | description: { type: 'string', nullable: true, minLength: 1 },
89 | files: { type: 'array', nullable: true, items: { type: 'string' } },
90 | image: { type: 'string', nullable: true },
91 | flag: { type: 'string', nullable: true, minLength: 4 },
92 | category: {
93 | type: 'string',
94 | nullable: true,
95 | enum: ['WEB', 'CRYPTO', 'REV', 'PWN', 'MISC'],
96 | },
97 | difficulty: {
98 | type: 'string',
99 | nullable: true,
100 | enum: ['EASY', 'MEDIUM', 'HARD'],
101 | },
102 | staticPoints: { type: 'integer', nullable: true },
103 | },
104 | };
105 |
106 | const editChallengeValidator = ajv.compile(EditChallengeSchema);
107 |
108 | export async function PATCH(
109 | req: NextRequest,
110 | { params }: { params: { id?: string } },
111 | ) {
112 | let middleware = await Middleware([admin()]);
113 | if (middleware) return middleware;
114 |
115 | const { id } = params;
116 |
117 | if (!isString(id)) {
118 | return NextResponse.json(
119 | {
120 | Error: 'Bad request.',
121 | },
122 | {
123 | status: StatusCodes.BAD_REQUEST,
124 | },
125 | );
126 | }
127 |
128 | const content = await req.json();
129 |
130 | if (!editChallengeValidator(content)) {
131 | return NextResponse.json(
132 | {
133 | Error: 'Bad request',
134 | },
135 | {
136 | status: StatusCodes.BAD_REQUEST,
137 | },
138 | );
139 | }
140 |
141 | let challenge = await prisma.challenge.update({
142 | where: {
143 | id: id,
144 | },
145 | data: {
146 | name: content.name,
147 | description: content.description,
148 | files: content.files
149 | ? content.files.filter((s) => s.length).length
150 | ? content.files
151 | : []
152 | : content.files,
153 | image: content.image,
154 | flag: content.flag,
155 | category: content.category as Category | undefined,
156 | difficulty: content.difficulty as Difficulty | undefined,
157 | staticPoints: content.staticPoints,
158 | },
159 | });
160 |
161 | return NextResponse.json(challenge, {
162 | status: StatusCodes.OK,
163 | });
164 | }
165 |
--------------------------------------------------------------------------------
/src/lib/Middleware.ts:
--------------------------------------------------------------------------------
1 | import { StatusCodes } from 'http-status-codes';
2 | import { getServerSession } from 'next-auth';
3 | import { NextResponse } from 'next/server';
4 | import prisma from '@/lib/prismadb';
5 |
6 | async function Middleware(middlewares: Promise[]) {
7 | return (await Promise.all(middlewares)).find((m) => m);
8 | }
9 |
10 | /**
11 | * Checks if a user can access a route depending on the CTF start time
12 | * @returns A response if the request does not pass the middleware
13 | */
14 | async function CTFStart(): Promise {
15 | let start = await prisma.setting.findFirst({
16 | where: {
17 | key: 'CTF_START_TIME',
18 | },
19 | });
20 |
21 | if (start && parseInt(start.value) > new Date().getTime()) {
22 | let session = await getServerSession();
23 |
24 | if (session) {
25 | let user = await prisma.user.findFirst({
26 | where: {
27 | name: session.user?.name,
28 | },
29 | });
30 |
31 | if (user?.admin) {
32 | return undefined;
33 | }
34 | }
35 |
36 | return NextResponse.json(
37 | {
38 | Error: 'This CTF has not started yet!',
39 | },
40 | {
41 | status: StatusCodes.FORBIDDEN,
42 | },
43 | );
44 | }
45 |
46 | return undefined;
47 | }
48 |
49 | /**
50 | * Checks if a user can access a route depending on the CTF end time
51 | * @returns True if the user does not pass and false otherwise
52 | */
53 | async function CTFEnd(): Promise {
54 | let end = await prisma.setting.findFirst({
55 | where: {
56 | key: 'CTF_END_TIME',
57 | },
58 | });
59 |
60 | if (end && parseInt(end.value) < new Date().getTime()) {
61 | let session = await getServerSession();
62 |
63 | if (session) {
64 | let user = await prisma.user.findFirst({
65 | where: {
66 | name: session.user?.name,
67 | },
68 | });
69 |
70 | if (user?.admin) {
71 | return undefined;
72 | }
73 | }
74 |
75 | return NextResponse.json(
76 | {
77 | Error: 'This CTF has ended!',
78 | },
79 | {
80 | status: StatusCodes.FORBIDDEN,
81 | },
82 | );
83 | }
84 |
85 | return undefined;
86 | }
87 |
88 | /**
89 | * Checks if a user is an admin
90 | * @returns True if the user does not pass and false otherwise
91 | */
92 | async function admin(): Promise {
93 | let session = await getServerSession();
94 |
95 | if (!session) {
96 | return NextResponse.json(
97 | {
98 | Error: 'You must be an admin to preform this action!',
99 | },
100 | {
101 | status: StatusCodes.FORBIDDEN,
102 | },
103 | );
104 | }
105 |
106 | let user = await prisma.user.findFirst({
107 | where: {
108 | name: session.user?.name,
109 | },
110 | });
111 |
112 | if (user && user.admin) {
113 | return undefined;
114 | }
115 |
116 | return NextResponse.json(
117 | {
118 | Error: 'You must be an admin to preform this action!',
119 | },
120 | {
121 | status: StatusCodes.FORBIDDEN,
122 | },
123 | );
124 | }
125 |
126 | /**
127 | * Checks if a user is on a team
128 | * @returns True if the user does not pass and false otherwise
129 | */
130 | async function teamMember(): Promise {
131 | let session = await getServerSession();
132 |
133 | if (session) {
134 | let user = await prisma.user.findFirst({
135 | where: {
136 | name: session.user?.name,
137 | },
138 | include: {
139 | team: true,
140 | },
141 | });
142 |
143 | if (user?.team) {
144 | return undefined;
145 | }
146 | }
147 |
148 | return NextResponse.json(
149 | {
150 | Error: 'You must be on a team to preform this action!',
151 | },
152 | {
153 | status: StatusCodes.FORBIDDEN,
154 | },
155 | );
156 | }
157 |
158 | async function user(): Promise {
159 | let session = await getServerSession();
160 |
161 | if (session) {
162 | return undefined;
163 | }
164 |
165 | return NextResponse.json(
166 | {
167 | Error: 'You must be a registered user to preform this action!',
168 | },
169 | {
170 | status: StatusCodes.FORBIDDEN,
171 | },
172 | );
173 | }
174 |
175 | export { Middleware, CTFStart, CTFEnd, admin, teamMember, user };
176 |
--------------------------------------------------------------------------------
/src/components/challenge/CreateChallenge.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Button } from '../Button';
5 | import Modal from '../Modal';
6 | import { Challenge } from '@prisma/client';
7 | import { Input } from '../Input';
8 | import { Dropdown } from '../Dropdown';
9 | import { Textarea } from '../Textarea';
10 | import {
11 | createChallenge,
12 | deleteChallenge,
13 | editChallenge,
14 | } from '@/app/api/challenges/actions';
15 |
16 | interface Props {
17 | className?: string;
18 | challenge?: Challenge;
19 | }
20 |
21 | const CreateChallenge = ({ className, challenge }: Props) => {
22 | const [open, setOpen] = useState(false);
23 |
24 | return (
25 | <>
26 | setOpen(false)}>
27 | Challenge
28 |
88 |
89 |
119 | >
120 | );
121 | };
122 |
123 | export { CreateChallenge };
124 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | github@ebucket.dev.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/src/app/admin/teams/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { revalidatePath } from 'next/cache';
5 |
6 | export const metadata = {
7 | title: 'EBucket | Admin | Teams',
8 | };
9 |
10 | export default async function Home({
11 | params: { id },
12 | }: {
13 | params: { id: string };
14 | }) {
15 | if (await admin()) {
16 | return ;
17 | }
18 |
19 | let team = await prisma.team.findFirst({
20 | where: {
21 | id: id,
22 | },
23 | include: {
24 | members: true,
25 | },
26 | });
27 |
28 | if (!team) {
29 | return ;
30 | }
31 |
32 | let solves = await prisma.solve.findMany({
33 | where: {
34 | teamId: team?.id,
35 | },
36 | include: {
37 | challenge: true,
38 | },
39 | });
40 |
41 | async function removeMember(data: FormData) {
42 | 'use server';
43 |
44 | await prisma.team.update({
45 | where: {
46 | id: team!.id,
47 | },
48 | data: {
49 | members: {
50 | disconnect: [
51 | {
52 | id: data.get('id') as string,
53 | },
54 | ],
55 | },
56 | },
57 | });
58 |
59 | revalidatePath(`/admin/teams/${team!.id}`);
60 | }
61 |
62 | async function removeSolve(data: FormData) {
63 | 'use server';
64 |
65 | await prisma.solve.delete({
66 | where: {
67 | id: data.get('id') as string,
68 | },
69 | });
70 |
71 | revalidatePath(`/admin/teams/${team!.id}`);
72 | }
73 |
74 | return (
75 |
76 |
77 |
78 | {team?.name}
79 |
80 |
81 |
82 |
83 |
84 |
Name
85 |
{team?.name}
86 |
87 |
88 |
ID
89 |
{team?.id}
90 |
91 |
92 |
93 |
94 | {team && (
95 |
96 |
97 | {team.name}'s members
98 |
99 |
100 |
101 |
102 | {team?.members.map((user) => (
103 |
107 | {user.name}
108 |
118 |
119 | ))}
120 |
121 | )}
122 |
123 |
124 |
125 | {solves && (
126 |
127 |
128 | {team!.name}'s Solves
129 |
130 |
131 |
132 |
133 | {solves.map((solve) => (
134 |
138 | {solve.challenge.name}
139 |
149 |
150 | ))}
151 |
152 | )}
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/src/app/api/challenges/solve/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import Ajv, { JSONSchemaType } from 'ajv';
2 | import { StatusCodes } from 'http-status-codes';
3 | import isString from 'is-string';
4 | import { CTFEnd, CTFStart, Middleware, teamMember } from '@/lib/Middleware';
5 | import { getServerSession } from 'next-auth';
6 | import { NextResponse } from 'next/server';
7 | import prisma from '@/lib/prismadb';
8 | import {
9 | Client,
10 | EmbedBuilder,
11 | Events,
12 | GatewayIntentBits,
13 | TextChannel,
14 | } from 'discord.js';
15 | import { User } from '@prisma/client';
16 |
17 | const ajv = new Ajv();
18 | interface SolveChallengeRequest {
19 | flag: string;
20 | }
21 |
22 | const SolveChallengeRequest: JSONSchemaType = {
23 | type: 'object',
24 | properties: {
25 | flag: { type: 'string' },
26 | },
27 | required: ['flag'],
28 | };
29 |
30 | const solveChallengeRequestValidator = ajv.compile(SolveChallengeRequest);
31 |
32 | export async function POST(
33 | req: Request,
34 | { params }: { params: { id?: string } },
35 | ) {
36 | let middleware = await Middleware([CTFStart(), CTFEnd(), teamMember()]);
37 | if (middleware) return middleware;
38 |
39 | const { id } = params;
40 |
41 | const session = await getServerSession();
42 |
43 | if (!isString(id)) {
44 | return NextResponse.json(
45 | {
46 | Error: 'Bad request.',
47 | },
48 | {
49 | status: StatusCodes.BAD_REQUEST,
50 | },
51 | );
52 | }
53 |
54 | const user: User = (await prisma.user.findFirst({
55 | where: {
56 | name: session!.user?.name,
57 | },
58 | })) as User;
59 |
60 | const team = await prisma.team.findFirst({
61 | where: {
62 | id: user?.teamId as string,
63 | },
64 | });
65 |
66 | const challenge = await prisma.challenge.findFirst({
67 | where: {
68 | id: id as string,
69 | },
70 | include: {
71 | solved: true,
72 | },
73 | });
74 |
75 | if (!challenge) {
76 | return NextResponse.json(
77 | {
78 | Error: 'Challenge not found.',
79 | },
80 | {
81 | status: StatusCodes.NOT_FOUND,
82 | },
83 | );
84 | }
85 |
86 | const data = await req.json();
87 |
88 | if (!solveChallengeRequestValidator(data)) {
89 | return NextResponse.json(
90 | {
91 | Error: 'Bad request.',
92 | },
93 | {
94 | status: StatusCodes.BAD_REQUEST,
95 | },
96 | );
97 | }
98 |
99 | if (challenge.flag === data.flag) {
100 | let time = new Date();
101 |
102 | const solvedTeam = await prisma.team.update({
103 | data: {
104 | solves: {
105 | create: {
106 | challenge: {
107 | connect: {
108 | id: challenge.id,
109 | },
110 | },
111 | user: {
112 | connect: {
113 | id: user.id,
114 | },
115 | },
116 | time: time,
117 | },
118 | },
119 | },
120 | where: {
121 | id: team!.id,
122 | },
123 | include: {
124 | solves: true,
125 | },
126 | });
127 |
128 | if (
129 | solvedTeam.solves.filter(
130 | (solve) => solve.challengeId == challenge.id,
131 | ).length > 1
132 | ) {
133 | await prisma.solve.deleteMany({
134 | where: {
135 | time: time,
136 | teamId: team!.id,
137 | challengeId: challenge.id,
138 | },
139 | });
140 |
141 | return NextResponse.json(
142 | {
143 | Error: 'Your team has already solved this challenge.',
144 | },
145 | {
146 | status: StatusCodes.CONFLICT,
147 | },
148 | );
149 | }
150 |
151 | if (challenge.solved.length == 0) {
152 | let token = process.env.DISCORD_TOKEN;
153 |
154 | let channel = await prisma.setting.findFirst({
155 | where: {
156 | key: 'DISCORD_CHANNEL',
157 | },
158 | });
159 |
160 | const firstBlood = new EmbedBuilder()
161 | .setColor(0x4361ee)
162 | .setTitle('First Blood!')
163 | .setURL('https://ctf.ebucket.dev')
164 | .setAuthor({
165 | name: user.name!,
166 | iconURL: user.image!,
167 | url: `https://github.com/${user.name!.replace(' ', '')}`,
168 | })
169 | .setDescription(`${team?.name} has solved ${challenge.name}!`)
170 | .setTimestamp();
171 |
172 | const client = new Client({ intents: [GatewayIntentBits.Guilds] });
173 |
174 | client.once(Events.ClientReady, (client) => {
175 | (
176 | client.channels.cache.get(
177 | channel?.value as string,
178 | ) as TextChannel
179 | ).send({ embeds: [firstBlood] });
180 | });
181 |
182 | client.login(token);
183 | }
184 |
185 | return NextResponse.json(
186 | {
187 | Message: 'Correct flag.',
188 | },
189 | {
190 | status: StatusCodes.OK,
191 | },
192 | );
193 | }
194 |
195 | return NextResponse.json(
196 | {
197 | Error: 'Wrong flag.',
198 | },
199 | {
200 | status: StatusCodes.BAD_REQUEST,
201 | },
202 | );
203 | }
204 |
--------------------------------------------------------------------------------
/src/app/rankings/page.tsx:
--------------------------------------------------------------------------------
1 | import { Challenge, Solve } from '@prisma/client';
2 | import prisma from '@/lib/prismadb';
3 | import { CTFStart, Middleware } from '@/lib/Middleware';
4 | import { Error } from '@/components/Error';
5 | import { getRankings, pointValue } from '@/lib/Rankings';
6 | import { getTeam } from '@/lib/Utils';
7 | import { StarIcon } from 'lucide-react';
8 | import * as echarts from 'echarts';
9 |
10 | export const metadata = {
11 | title: 'EBucket | Rankings',
12 | };
13 |
14 | // eslint-disable-next-line
15 | function getColor(num: number) {
16 | return `hsl(${num % 360}, 100%, 50%)`;
17 | }
18 |
19 | export default async function Home() {
20 | let middleware = await Middleware([CTFStart()]);
21 | if (middleware)
22 | return ;
23 |
24 | let myTeam = await getTeam();
25 |
26 | let users = await prisma.user.findMany({
27 | include: {
28 | solves: true,
29 | },
30 | });
31 |
32 | let challenges: (Challenge & {
33 | solved: Solve[];
34 | points?: number;
35 | })[] = await prisma.challenge.findMany({
36 | include: {
37 | solved: true,
38 | },
39 | });
40 |
41 | challenges = await Promise.all(
42 | challenges.map(async (chall) => {
43 | chall.points = await pointValue(chall);
44 | return chall;
45 | }),
46 | );
47 |
48 | let rankings: Array<{
49 | label: string;
50 | id: string;
51 | data: number[];
52 | solves: Solve[];
53 | }> = [];
54 |
55 | rankings = await (
56 | await getRankings()
57 | ).map((ranking) => ({
58 | label: ranking.team.name,
59 | id: ranking.team.id,
60 | data: [ranking.team.points ?? 0],
61 | solves: ranking.team.solves,
62 | }));
63 |
64 | let top10 = rankings.slice(0, 9);
65 |
66 | function renderChart() {
67 | const chart = echarts.init(null, null, {
68 | renderer: 'svg',
69 | ssr: true,
70 | width: 400,
71 | height: 300,
72 | });
73 |
74 | chart.setOption({
75 | xAxis: {
76 | type: 'category',
77 | data: top10.map((t) => t.label),
78 | },
79 | yAxis: {
80 | type: 'value',
81 | },
82 | series: [
83 | {
84 | data: top10.map((t) => t.data[0]),
85 | type: 'bar',
86 | animationDelay: (idx: any) => {
87 | return idx * 100;
88 | },
89 | },
90 | ],
91 | });
92 |
93 | return chart.renderToSVGString();
94 | }
95 |
96 | return (
97 | <>
98 |
99 |
100 | {rankings &&
101 | rankings.map((team, i) => (
102 |
103 |
106 |
107 | {myTeam && team.id === myTeam.id && (
108 |
109 | )}
110 |
111 | {i +
112 | 1 +
113 | ' - ' +
114 | team.label +
115 | ' - ' +
116 | team.data[0]}
117 |
118 |
119 |
120 |
121 | {team.solves &&
122 | team.solves.map((solve) => (
123 |
127 |
128 | {
129 | challenges.find(
130 | (challenge) =>
131 | challenge.id ==
132 | solve.challengeId,
133 | )!.name
134 | }
135 | -
136 | {
137 | users.find(
138 | (user) =>
139 | user.id ==
140 | solve.userId,
141 | )!.name
142 | }
143 |
144 |
145 | ))}
146 |
147 |
148 | ))}
149 |
150 |
154 |
155 | >
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/app/admin/users/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { admin } from '@/lib/Middleware';
2 | import { Error } from '@/components/Error';
3 | import prisma from '@/lib/prismadb';
4 | import { Button } from '@/components/Button';
5 | import Image from 'next/image';
6 | import { revalidatePath } from 'next/cache';
7 |
8 | export const metadata = {
9 | title: 'EBucket | Admin | Users',
10 | };
11 |
12 | export default async function Home({
13 | params: { id },
14 | }: {
15 | params: { id: string };
16 | }) {
17 | if (await admin()) {
18 | return ;
19 | }
20 |
21 | let user = await prisma.user.findUniqueOrThrow({
22 | where: {
23 | id: id,
24 | },
25 | });
26 |
27 | let team = (
28 | await prisma.user.findFirst({
29 | where: {
30 | email: user.email,
31 | },
32 | include: {
33 | team: {
34 | include: {
35 | members: true,
36 | },
37 | },
38 | },
39 | })
40 | )?.team;
41 |
42 | let solves = await prisma.solve.findMany({
43 | where: {
44 | userId: id,
45 | },
46 | include: {
47 | challenge: true,
48 | },
49 | });
50 |
51 | async function leaveTeam() {
52 | 'use server';
53 |
54 | let team = await prisma.team.update({
55 | include: {
56 | members: true,
57 | },
58 | where: {
59 | id: user.teamId as string,
60 | },
61 | data: {
62 | members: {
63 | disconnect: {
64 | id: user!.id,
65 | },
66 | },
67 | },
68 | });
69 |
70 | if (team.members.length === 0) {
71 | await prisma.solve.deleteMany({
72 | where: {
73 | teamId: user!.teamId as string,
74 | },
75 | });
76 |
77 | await prisma.team.delete({
78 | where: {
79 | id: team.id,
80 | },
81 | });
82 | }
83 |
84 | revalidatePath(`/admin/users/${id}`);
85 | }
86 |
87 | async function removeSolve(data: FormData) {
88 | let solveId = data.get('solveId')?.valueOf() as string;
89 |
90 | await prisma.solve.delete({
91 | where: {
92 | id: solveId,
93 | },
94 | });
95 |
96 | revalidatePath(`/admin/users/${id}`);
97 | }
98 |
99 | return (
100 |
101 |
102 |
109 |
110 | {user?.name}
111 |
112 |
113 |
114 |
115 |
116 |
Email
117 |
{user?.email}
118 |
119 |
120 |
ID
121 |
{user?.id}
122 |
123 |
124 |
125 |
126 | {team && (
127 |
128 |
129 | {team.name}'s members
130 |
131 |
132 |
133 |
134 | {team?.members.map((user) => (
135 |
139 | {user.name}
140 |
141 | ))}
142 |
143 |
144 |
145 |
148 |
149 | )}
150 |
151 |
152 |
153 | {solves.length > 0 && (
154 |
155 |
156 | {team!.name}'s Solves
157 |
158 |
159 |
160 |
161 | {solves.map((solve) => (
162 |
166 | {solve.challenge.name}
167 |
177 |
178 | ))}
179 |
180 | )}
181 |
182 |
183 | );
184 | }
185 |
--------------------------------------------------------------------------------
/src/app/account/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/Button';
2 | import { Input } from '@/components/Input';
3 | import { getUser } from '@/lib/Utils';
4 | import prisma from '@/lib/prismadb';
5 | import { revalidatePath } from 'next/cache';
6 | import Image from 'next/image';
7 |
8 | export const metadata = {
9 | title: 'EBucket | Account',
10 | };
11 |
12 | async function submitJoin(data: FormData) {
13 | 'use server';
14 |
15 | let user = await getUser();
16 |
17 | await prisma.team.update({
18 | where: {
19 | secret: data.get('secret')! as string,
20 | },
21 | data: {
22 | members: {
23 | connect: {
24 | id: user!.id,
25 | },
26 | },
27 | },
28 | });
29 |
30 | revalidatePath('/account');
31 | }
32 |
33 | async function submitCreate(data: FormData) {
34 | 'use server';
35 |
36 | let user = await getUser();
37 |
38 | await prisma.team.create({
39 | data: {
40 | name: data.get('name') as string,
41 | members: {
42 | connect: {
43 | id: user?.id,
44 | },
45 | },
46 | },
47 | include: {
48 | members: true,
49 | },
50 | });
51 |
52 | revalidatePath('/account');
53 | }
54 |
55 | async function leaveTeam() {
56 | 'use server';
57 |
58 | let user = await getUser();
59 |
60 | let team = await prisma.team.update({
61 | include: {
62 | members: true,
63 | },
64 | where: {
65 | id: user!.teamId as string,
66 | },
67 | data: {
68 | members: {
69 | disconnect: {
70 | id: user!.id,
71 | },
72 | },
73 | },
74 | });
75 |
76 | if (team.members.length === 0) {
77 | await prisma.solve.deleteMany({
78 | where: {
79 | teamId: user!.teamId as string,
80 | },
81 | });
82 |
83 | await prisma.team.delete({
84 | where: {
85 | id: team.id,
86 | },
87 | });
88 | }
89 |
90 | revalidatePath('/account');
91 | }
92 |
93 | export default async function Home() {
94 | let user = await getUser();
95 |
96 | let team = (
97 | await prisma.user.findFirst({
98 | where: {
99 | email: user?.email,
100 | },
101 | include: {
102 | team: {
103 | include: {
104 | members: true,
105 | },
106 | },
107 | },
108 | })
109 | )?.team;
110 |
111 | return (
112 |
113 |
114 |
121 |
122 | {user?.name}
123 |
124 |
125 |
126 |
127 |
128 |
Email
129 |
{user?.email}
130 |
131 |
132 |
ID
133 |
{user?.id}
134 |
135 |
136 |
137 |
138 | {team && (
139 |
140 |
141 | {team.name}'s members
142 |
143 |
144 |
145 |
146 | {team?.members.map((user) => (
147 |
151 | {user.name}
152 |
153 | ))}
154 |
155 |
156 |
157 |
160 |
161 | )}
162 | {!team && (
163 | <>
164 |
177 |
178 |
190 | >
191 | )}
192 |
193 |
194 | );
195 | }
196 |
--------------------------------------------------------------------------------
/src/components/HostContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { FormEvent, useRef, useState } from 'react';
4 | import { Button } from './Button';
5 | import Modal from './Modal';
6 | import { Status, Statuses } from './Status';
7 | import { Host } from '@prisma/client';
8 | import { Input } from './Input';
9 |
10 | interface Props {
11 | className?: string;
12 | data?: Partial;
13 | }
14 |
15 | const HostContainer = ({ className, data }: Props) => {
16 | const [open, setOpen] = useState(false);
17 |
18 | const [certStatus, setCertStatus] = useState(Statuses.Loading);
19 |
20 | let ssl = useRef({
21 | ca: '',
22 | cert: '',
23 | key: '',
24 | });
25 |
26 | async function submit(event: FormEvent) {
27 | event.preventDefault();
28 |
29 | const target = event.target as typeof event.target & {
30 | port: { value: string };
31 | remote: { value: string };
32 | ip: { value: string };
33 | };
34 |
35 | await fetch(`/api/hosts`, {
36 | method: data ? 'PATCH' : 'POST',
37 | body: JSON.stringify({
38 | port: parseInt(target.port.value),
39 | remote: target.remote.value,
40 | ip: target.ip.value,
41 | ca: ssl.current.ca,
42 | cert: ssl.current.cert,
43 | key: ssl.current.key,
44 | }),
45 | });
46 | }
47 |
48 | async function validateSSL(event: FormEvent) {
49 | const target = event.target as typeof event.target & {
50 | files: File[];
51 | name: string;
52 | };
53 |
54 | if (!target.files) {
55 | return;
56 | }
57 |
58 | setCertStatus(Statuses.Loading);
59 |
60 | let cert = target.files[0];
61 |
62 | let reader = new FileReader();
63 |
64 | reader.readAsText(cert);
65 |
66 | reader.onload = (evt) => {
67 | switch (target.name) {
68 | case 'ca': {
69 | ssl.current.ca = evt.target?.result as string;
70 | break;
71 | }
72 | case 'cert': {
73 | ssl.current.cert = evt.target?.result as string;
74 | break;
75 | }
76 | case 'key': {
77 | ssl.current.key = evt.target?.result as string;
78 | break;
79 | }
80 | }
81 |
82 | if (!ssl.current.ca) {
83 | return;
84 | }
85 |
86 | try {
87 | if (ssl.current.ca && ssl.current.cert && ssl.current.key) {
88 | setCertStatus(Statuses.Correct);
89 | }
90 | } catch (e) {
91 | setCertStatus(Statuses.Incorrect);
92 | }
93 | };
94 | }
95 |
96 | return (
97 | <>
98 | setOpen(false)}>
99 | Create a host
100 |
157 |
158 |
182 | >
183 | );
184 | };
185 |
186 | export default HostContainer;
187 |
--------------------------------------------------------------------------------
/src/components/challenge/ChallengeContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Challenge, Solve } from '@prisma/client';
4 | import { FormEvent, useState } from 'react';
5 | import { Button } from '../Button';
6 | import Modal from '../Modal';
7 | import { Status, Statuses } from '@/components/Status';
8 | import { useRouter } from 'next/navigation';
9 | import { Input } from '../Input';
10 | import Code from '../Code';
11 | import { DownloadIcon, PowerIcon } from 'lucide-react';
12 | import Markdown from 'marked-react';
13 |
14 | interface Props {
15 | challenge: Omit<
16 | Challenge & {
17 | points: number;
18 | solved: Solve[];
19 | done: boolean;
20 | },
21 | 'flag'
22 | >;
23 | }
24 |
25 | const Challenge = ({ challenge }: Props) => {
26 | const [open, setOpen] = useState(false);
27 |
28 | const [status, setStatus] = useState(Statuses.Unsubmitted);
29 |
30 | const [url, setUrl] = useState('Instance Not Started');
31 |
32 | const router = useRouter();
33 |
34 | async function submit(event: FormEvent) {
35 | event.preventDefault();
36 |
37 | setStatus(Statuses.Loading);
38 |
39 | const target = event.target as typeof event.target & {
40 | flag: { value: string };
41 | };
42 |
43 | let req = await fetch(`/api/challenges/solve/${challenge.id}`, {
44 | method: 'POST',
45 | body: JSON.stringify({
46 | flag: target.flag.value,
47 | }),
48 | });
49 |
50 | setStatus(req.status == 200 ? Statuses.Correct : Statuses.Incorrect);
51 |
52 | setTimeout(() => {
53 | router.refresh();
54 | }, 500);
55 | }
56 |
57 | async function requestContainer(event: FormEvent) {
58 | event.preventDefault();
59 |
60 | setUrl('Loading');
61 |
62 | let req = await fetch(`/api/challenges/host/${challenge.id}`, {
63 | method: 'POST',
64 | });
65 |
66 | let res = await req.json();
67 |
68 | if (req.ok) {
69 | setUrl(res.url);
70 | } else {
71 | alert('You are on a 60 second cooldown for hosting instances.');
72 | }
73 | }
74 |
75 | return (
76 | <>
77 | setOpen(false)}>
78 |
79 |
80 | {challenge.category} / {challenge.name}
81 |
82 |
83 | {challenge.difficulty}
84 |
85 | ({challenge.points}pts / {challenge.solved.length}{' '}
86 | solves)
87 |
88 |
89 |
90 |
91 | {challenge.description}
92 |
93 |
94 |
95 | {challenge.image && (
96 |
97 |
98 | {url}
99 |
100 |
106 |
107 | )}
108 |
133 |
134 | {challenge.files.length > 0 && (
135 | <>
136 |
137 |
138 | Attachments
139 |
140 | >
141 | )}
142 | {challenge.files.map((file) => (
143 |
157 | ))}
158 |
159 |
160 |
179 | >
180 | );
181 | };
182 |
183 | export default Challenge;
184 |
--------------------------------------------------------------------------------
/src/app/challenges/page.tsx:
--------------------------------------------------------------------------------
1 | import ChallengeContainer from '@/components/challenge/ChallengeContainer';
2 | import { CTFStart, Middleware, teamMember } from '@/lib/Middleware';
3 | import prisma from '@/lib/prismadb';
4 | import { Category, Challenge, Solve } from '@prisma/client';
5 | import { arrange, asc, tidy } from '@tidyjs/tidy';
6 | import { Error } from '@/components/Error';
7 | import { countPoints, pointValue } from '@/lib/Rankings';
8 | import { getTeam, getUser } from '@/lib/Utils';
9 | import Image from 'next/image';
10 |
11 | export const metadata = {
12 | title: 'EBucket | Challenges',
13 | };
14 |
15 | function exclude(
16 | challenge: Challenge,
17 | keys: Key[],
18 | ): Omit {
19 | for (let key of keys) {
20 | delete challenge[key];
21 | }
22 | return challenge;
23 | }
24 |
25 | export default async function Home() {
26 | let middleware = await Middleware([CTFStart(), teamMember()]);
27 | if (middleware)
28 | return ;
29 |
30 | let user = await getUser();
31 |
32 | let team = await getTeam();
33 |
34 | let challenges: Partial<
35 | Challenge & {
36 | points: number;
37 | solved: Solve[];
38 | done: boolean;
39 | }
40 | >[] = await prisma.challenge.findMany({
41 | include: {
42 | solved: true,
43 | },
44 | });
45 |
46 | challenges.forEach((chall) => {
47 | let inc: boolean = true;
48 | chall.solved!.forEach((solve) => {
49 | if (solve.teamId === user?.teamId) {
50 | inc = false;
51 | }
52 | });
53 | chall.done = !inc;
54 | });
55 |
56 | await Promise.all(
57 | challenges.map(async (challenge) => {
58 | challenge.points = await pointValue(
59 | challenge as Challenge & {
60 | solved?: Solve[];
61 | },
62 | );
63 | }),
64 | );
65 |
66 | let challengesWithoutSecrets = challenges.map((chall) =>
67 | exclude(chall, ['flag']),
68 | );
69 |
70 | challengesWithoutSecrets = tidy(challenges, arrange(asc('points')));
71 |
72 | return (
73 | <>
74 |
75 |
76 | Solved:{' '}
77 | {challengesWithoutSecrets.filter((c) => c.done).length}
78 |
79 |
80 | Unsolved:{' '}
81 | {challengesWithoutSecrets.filter((c) => !c.done).length}
82 |
83 |
{team?.name}
84 |
85 | {await countPoints(team!)}
86 | {' '}
92 |
93 |
94 | Web
95 |
96 | {challengesWithoutSecrets
97 | .filter((c) => c.category == Category.WEB)
98 | .map((challenge) => (
99 |
110 | }
111 | />
112 | ))}
113 |
114 | Crypto
115 |
116 | {challengesWithoutSecrets
117 | .filter((c) => c.category == Category.CRYPTO)
118 | .map((challenge) => (
119 |
130 | }
131 | />
132 | ))}
133 |
134 | Rev
135 |
136 | {challengesWithoutSecrets
137 | .filter((c) => c.category == Category.REV)
138 | .map((challenge) => (
139 |
150 | }
151 | />
152 | ))}
153 |
154 | Pwn
155 |
156 | {challengesWithoutSecrets
157 | .filter((c) => c.category == Category.PWN)
158 | .map((challenge) => (
159 |
170 | }
171 | />
172 | ))}
173 |
174 | Misc
175 |
176 | {challengesWithoutSecrets
177 | .filter((c) => c.category == Category.MISC)
178 | .map((challenge) => (
179 |
190 | }
191 | />
192 | ))}
193 |
194 | >
195 | );
196 | }
197 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Bucket from '@/components/Bucket';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 |
5 | export const metadata = {
6 | title: 'EBucket | Home',
7 | description:
8 | "Bucket CTF will be and online, jeopardy-style CTF, and we'll have a plethora of info security challenges. Our challenge categories include web exploitation (web), cryptography (crypto), reverse engineering (rev), binary exploitation (pwn), and miscellaneous other categories (misc).",
9 | openGraph: {
10 | title: 'BucketCTF 2023',
11 | description:
12 | 'BucketCTF is an online jeopardy-style CTF from April 7th to April 9th with over $3000 in prizes.',
13 | url: 'https://ctf.ebucket.dev',
14 | siteName: 'BucketCTF',
15 | images: [
16 | {
17 | url: 'https://ctf.ebucket.dev/bucket.png',
18 | height: 512,
19 | width: 512,
20 | alt: 'logo',
21 | },
22 | ],
23 | locale: 'en-US',
24 | type: 'website',
25 | },
26 | };
27 |
28 | export default function Home() {
29 | return (
30 | <>
31 |
32 |
33 | BucketCTF[2024]
34 |
35 |
36 |
37 |
38 | Welcome to Emergency Bucket's inaugural Capture The
39 | Flag competition. Bucket CTF will be an online,
40 | jeopardy-style CTF, and we'll have a plethora of info
41 | security challenges. Our challenge categories include web
42 | exploitation (web), cryptography (crypto), reverse
43 | engineering (rev), binary exploitation (pwn), and
44 | miscellaneous other categories (misc).
45 |
46 |
47 |
52 |
63 |
64 |
69 |
80 |
81 |
82 |
83 | Prizes
84 |
85 |
89 | 🥇 - $500* and $1250 in DO Credits
90 |
91 |
95 | 🥈 - $300* and $500 in DO Credits
96 |
97 |
101 | 🥉 - $200* and $250 in DO Credits
102 |
103 |
104 |
105 | Sponsors
106 |
107 |
108 |
113 |
124 |
125 |
130 |
141 |
142 |
147 |
158 |
159 |
164 |
175 |
176 |
181 |
192 |
193 |
198 |
209 |
210 |
215 |
226 |
227 |
228 |
229 | >
230 | );
231 | }
232 |
--------------------------------------------------------------------------------