├── plugins └── .gitkeep ├── apps ├── server │ ├── src │ │ ├── auth.ts │ │ ├── index.test.ts │ │ ├── entrypoints │ │ │ ├── shell.ts │ │ │ ├── www.ts │ │ │ └── worker.ts │ │ ├── generate_swagger.ts │ │ ├── hooks │ │ │ ├── authz.ts │ │ │ ├── rate_limit.ts │ │ │ └── authn.ts │ │ ├── routes │ │ │ ├── admin_setup.ts │ │ │ ├── admin_scoreboard.ts │ │ │ ├── admin_audit_log.ts │ │ │ ├── _util.ts │ │ │ └── admin_config.ts │ │ ├── config.ts │ │ └── util │ │ │ ├── domain.ts │ │ │ └── domain.test.ts │ ├── .gitignore │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── esbuild.mjs │ └── package.json └── web │ ├── src │ ├── lib │ │ ├── index.ts │ │ ├── utils │ │ │ ├── clipboard.ts │ │ │ ├── ordinal.ts │ │ │ ├── redirect.ts │ │ │ ├── team-flags.ts │ │ │ ├── country.ts │ │ │ ├── time.ts │ │ │ └── challenges.ts │ │ ├── constants │ │ │ ├── difficulties.ts │ │ │ └── categories.ts │ │ ├── api │ │ │ ├── types.ts │ │ │ └── index.svelte.ts │ │ ├── components │ │ │ ├── challenges │ │ │ │ ├── DifficultyChip.svelte │ │ │ │ └── ChallengeModal.svelte │ │ │ ├── config │ │ │ │ ├── JsonConfigEditor.svelte │ │ │ │ └── SchemaForm.svelte │ │ │ └── Toast.svelte │ │ ├── state │ │ │ ├── config.svelte.ts │ │ │ └── captcha.svelte.ts │ │ ├── stores │ │ │ └── toast.ts │ │ └── static_export │ │ │ ├── config.ts │ │ │ └── utils.ts │ ├── routes │ │ ├── +layout.js │ │ ├── admin │ │ │ ├── config │ │ │ │ └── +layout.svelte │ │ │ ├── +page.svelte │ │ │ └── challenges │ │ │ │ └── new │ │ │ │ └── +page.svelte │ │ ├── teams │ │ │ └── [id] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ ├── +page.svelte │ │ ├── stats │ │ │ └── +page.svelte │ │ ├── team │ │ │ └── +page.svelte │ │ ├── settings │ │ │ ├── preferences │ │ │ │ └── +page.svelte │ │ │ └── +layout.svelte │ │ ├── +error.svelte │ │ └── auth │ │ │ ├── +layout.svelte │ │ │ └── +page.svelte │ ├── app.d.ts │ ├── app.html │ └── app.css │ ├── .npmrc │ ├── static │ └── favicon.png │ ├── postcss.config.js │ ├── vite.config.ts │ ├── docker-init │ └── setup.sh │ ├── .gitignore │ ├── svelte.config.js │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── tailwind.config.ts ├── core ├── api │ ├── .gitignore │ ├── src │ │ ├── types.ts │ │ ├── enums.ts │ │ ├── params.ts │ │ ├── token.ts │ │ └── query.ts │ ├── tsconfig.json │ └── package.json ├── schema │ ├── .env │ ├── .gitignore │ ├── .kysely-codegenrc.json │ └── package.json ├── plugin-auth │ └── src │ │ └── password_routes.ts ├── openapi-spec │ ├── .gitignore │ ├── tsconfig.json │ └── package.json ├── server-core │ ├── src │ │ ├── worker │ │ │ ├── types.ts │ │ │ ├── registry.ts │ │ │ └── signalled.ts │ │ ├── util │ │ │ ├── date.ts │ │ │ ├── filter.ts │ │ │ ├── string.ts │ │ │ ├── promises.ts │ │ │ ├── time.ts │ │ │ ├── limit_keys.ts │ │ │ ├── stopwatch.ts │ │ │ ├── pgerror.ts │ │ │ ├── graph.ts │ │ │ ├── coleascer.ts │ │ │ ├── stopwatch.test.ts │ │ │ ├── object.ts │ │ │ ├── local_cache.ts │ │ │ ├── pgerror.test.ts │ │ │ ├── paginator.ts │ │ │ ├── arrays.ts │ │ │ ├── semaphore.ts │ │ │ ├── message_compression.ts │ │ │ └── single_value_cache.ts │ │ ├── types │ │ │ ├── audit_log.ts │ │ │ ├── pagination.ts │ │ │ ├── enums.ts │ │ │ ├── primitives.ts │ │ │ └── fastify.ts │ │ ├── services │ │ │ ├── key.ts │ │ │ ├── email │ │ │ │ ├── types.ts │ │ │ │ ├── dummy.ts │ │ │ │ └── nodemailer.ts │ │ │ ├── challenge │ │ │ │ └── types.ts │ │ │ ├── file │ │ │ │ └── types.ts │ │ │ ├── scoreboard │ │ │ │ ├── index.test.ts │ │ │ │ └── worker.ts │ │ │ ├── audit_log.ts │ │ │ ├── cache.test.ts │ │ │ ├── token.ts │ │ │ └── lock.ts │ │ ├── dao │ │ │ ├── util.ts │ │ │ ├── award.ts │ │ │ ├── file.ts │ │ │ ├── config.ts │ │ │ └── audit_log.ts │ │ └── clients │ │ │ ├── nats.ts │ │ │ └── redis.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ └── package.json ├── mod-auth │ ├── src │ │ ├── error.ts │ │ ├── api_schema.ts │ │ ├── const.ts │ │ ├── oauth_jwks.ts │ │ ├── identity_routes.ts │ │ ├── hash_util.test.ts │ │ ├── index.ts │ │ ├── hash_util.ts │ │ └── password_provider.ts │ ├── tsconfig.json │ └── package.json ├── mod-tickets │ ├── src │ │ ├── util.ts │ │ └── schema │ │ │ ├── api.ts │ │ │ └── datatypes.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ └── package.json └── mod-captcha │ ├── tsconfig.json │ ├── package.json │ └── src │ ├── provider.ts │ └── service.ts ├── tools ├── noctfcli │ ├── src │ │ └── noctfcli │ │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── delete.py │ │ │ ├── list_cmd.py │ │ │ ├── show.py │ │ │ └── validate.py │ │ │ ├── preprocessor.py │ │ │ ├── __init__.py │ │ │ ├── exceptions.py │ │ │ ├── cli.py │ │ │ └── config.py │ ├── .gitignore │ └── README.md ├── static_exporter │ └── .gitignore └── anonymiser │ └── anonymise.sql ├── .prettierignore ├── .env.dev ├── .gitignore ├── .dockerignore ├── .vscode └── settings.json ├── prettier.config.mjs ├── docker-compose.dev.yml ├── migrations ├── 1741472210283_scoring.ts ├── util.ts ├── 1744115051874_team_tag.ts ├── 1733650351430_challenge.ts ├── 1752615351508_announcement.ts └── 1728179364677_config.ts ├── kysely.config.ts ├── pnpm-workspace.yaml ├── docker-compose.yml ├── package.json ├── Dockerfile ├── .github └── workflows │ └── build-docker.yml ├── eslint.config.mjs └── dev.sh /plugins/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/server/src/auth.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/api/.gitignore: -------------------------------------------------------------------------------- 1 | codegen/ -------------------------------------------------------------------------------- /core/schema/.env: -------------------------------------------------------------------------------- 1 | ../../.env -------------------------------------------------------------------------------- /apps/web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /core/plugin-auth/src/password_routes.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/schema/.gitignore: -------------------------------------------------------------------------------- 1 | /src/codegen.d.ts -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | /logs/ 2 | /files/ 3 | -------------------------------------------------------------------------------- /core/openapi-spec/.gitignore: -------------------------------------------------------------------------------- 1 | src/schema.d.ts 2 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .svelte-kit/ 4 | -------------------------------------------------------------------------------- /apps/web/src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | export const ssr = false; 2 | -------------------------------------------------------------------------------- /tools/static_exporter/.gitignore: -------------------------------------------------------------------------------- 1 | export/ 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | TOKEN_SECRET=dont-use-this-in-prod-unless-you-want-to-get-hacked 2 | ENABLE_SWAGGER=1 -------------------------------------------------------------------------------- /apps/server/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | 3 | it("Dummy test to pass", () => {}); 4 | -------------------------------------------------------------------------------- /apps/web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noctf-project/noCTF/HEAD/apps/web/static/favicon.png -------------------------------------------------------------------------------- /core/server-core/src/worker/types.ts: -------------------------------------------------------------------------------- 1 | export interface BaseWorker { 2 | run(): Promise; 3 | dispose(): void; 4 | } 5 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | /.env 4 | core/api-client/src/schema.d.ts 5 | /apps/server/swagger.json 6 | venv/ 7 | __pycache__/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/dist/ 3 | **/.svelte-kit/ 4 | apps/server/files/ 5 | app/server/logs/ 6 | **/venv/ 7 | **/__pycache__/ -------------------------------------------------------------------------------- /apps/web/src/routes/admin/config/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {@render children()} 6 | -------------------------------------------------------------------------------- /apps/web/src/routes/admin/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/schema/.kysely-codegenrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "dialect": "postgres", 3 | "outFile": "./dist/index.d.ts", 4 | "logLevel": "info", 5 | "url": "env(POSTGRES_URL)" 6 | } -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | }); 7 | -------------------------------------------------------------------------------- /apps/server/src/entrypoints/shell.ts: -------------------------------------------------------------------------------- 1 | import { start } from "node:repl"; 2 | import { server } from "../index.ts"; 3 | 4 | const { context } = start(); 5 | context.server = server; 6 | server.ready(async () => {}); 7 | -------------------------------------------------------------------------------- /core/server-core/src/util/date.ts: -------------------------------------------------------------------------------- 1 | export const MaxDate = (date: Date, ...dates: Date[]) => { 2 | for (const d of dates) { 3 | if (d > date) { 4 | date = d; 5 | } 6 | } 7 | return date; 8 | }; 9 | -------------------------------------------------------------------------------- /apps/web/docker-init/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | API_BASE_URL=$(printf '%s' "$API_BASE_URL" | sed -e 's/[&/\]/\\&/g') 4 | sed -i "s/___REPLACEME_NOCTF_API_BASE_URL___/$API_BASE_URL/g" /public/_app/immutable/chunks/*.js 5 | "$@" -------------------------------------------------------------------------------- /apps/web/src/routes/admin/challenges/new/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /core/mod-auth/src/error.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from "@noctf/server-core/errors"; 2 | 3 | export class UserNotFoundError extends NotFoundError { 4 | constructor(message = "User not found") { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/generate_swagger.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "node:fs"; 2 | import { server } from "./index.ts"; 3 | 4 | server.ready(() => { 5 | writeFileSync(process.argv[2], JSON.stringify(server.swagger())); 6 | process.exit(0); 7 | }); 8 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | trailingComma: "all", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: false, 6 | plugins: ["prettier-plugin-svelte"], 7 | overrides: [{ files: "*.svelte", options: { parser: "svelte" } }], 8 | }; 9 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | export const copyToClipboard = (text: string) => { 2 | if (navigator.clipboard) { 3 | navigator.clipboard.writeText(text).catch((err) => { 4 | console.error("Failed to copy text: ", err); 5 | }); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /core/mod-tickets/src/util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export const FilterUndefined = (obj: any) => 3 | Object.keys(obj).reduce((acc: any, key) => { 4 | if (obj[key] !== undefined) acc[key] = obj[key]; 5 | return acc; 6 | }, {}); 7 | -------------------------------------------------------------------------------- /core/server-core/src/util/filter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export const FilterUndefined = (obj: any) => 3 | Object.keys(obj).reduce((acc: any, key) => { 4 | if (obj[key] !== undefined) acc[key] = obj[key]; 5 | return acc; 6 | }, {}); 7 | -------------------------------------------------------------------------------- /core/server-core/src/types/audit_log.ts: -------------------------------------------------------------------------------- 1 | import type { ActorType } from "./enums.ts"; 2 | 3 | export type AuditLogActor = { 4 | type: ActorType; 5 | id?: string | number; 6 | }; 7 | 8 | export type AuditParams = { 9 | actor?: AuditLogActor; 10 | message?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/src/routes/teams/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /apps/web/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | Welcome to {configState.siteConfig?.name || "noCTF"} 8 |
9 |
10 | -------------------------------------------------------------------------------- /core/server-core/src/util/string.ts: -------------------------------------------------------------------------------- 1 | export const NormalizeName = (s: string) => 2 | s 3 | .normalize("NFKD") 4 | .replace(/[\u0300-\u036f]/g, "") 5 | .toLowerCase(); 6 | 7 | export const NormalizeEmail = (s: string) => 8 | s 9 | .toLowerCase() 10 | .trim() 11 | .replace(/\+.*(?=@)/, ""); 12 | -------------------------------------------------------------------------------- /apps/server/src/entrypoints/www.ts: -------------------------------------------------------------------------------- 1 | import { HOST, PORT } from "../config.ts"; 2 | import { server } from "../index.ts"; 3 | 4 | server.listen( 5 | { 6 | port: PORT, 7 | host: HOST, 8 | }, 9 | (err) => { 10 | if (err) { 11 | console.error(err); 12 | process.exit(1); 13 | } 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /build 10 | 11 | # OS 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Env 16 | .env 17 | .env.* 18 | !.env.example 19 | !.env.test 20 | 21 | # Vite 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | -------------------------------------------------------------------------------- /apps/web/src/routes/teams/[id]/+page.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@sveltejs/kit"; 2 | import type { PageLoad } from "./$types"; 3 | 4 | export const load: PageLoad = ({ params }) => { 5 | const teamId = Number(params.id); 6 | if (isNaN(teamId)) { 7 | error(404, "Not found"); 8 | } 9 | return { 10 | teamId, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/web/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /apps/web/src/routes/stats/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants/difficulties.ts: -------------------------------------------------------------------------------- 1 | export const DIFFICULTY_BG_MAP: { [k in Difficulty]: string } = { 2 | beginner: "bg-diff-beginner", 3 | easy: "bg-diff-easy", 4 | medium: "bg-diff-medium", 5 | hard: "bg-diff-hard", 6 | }; 7 | export const DIFFICULTIES = ["beginner", "easy", "medium", "hard"] as const; 8 | export type Difficulty = (typeof DIFFICULTIES)[number]; 9 | -------------------------------------------------------------------------------- /core/server-core/src/types/pagination.ts: -------------------------------------------------------------------------------- 1 | import { Primitive } from "@noctf/api/types"; 2 | 3 | export type PaginationCursor = Record; 4 | 5 | export type LimitCursorEncoded = { 6 | limit?: number; 7 | next?: string; 8 | }; 9 | 10 | export type LimitCursorDecoded = { 11 | limit?: number; 12 | next?: PaginationCursor; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/web/src/lib/api/types.ts: -------------------------------------------------------------------------------- 1 | import type { paths } from "@noctf/openapi-spec"; 2 | 3 | type HttpMethod = "get" | "post" | "put" | "delete" | "patch"; 4 | 5 | export type PathResponse< 6 | P extends keyof paths, 7 | M extends HttpMethod, 8 | > = paths[P][M] extends { 9 | responses: { 200: { content: { "application/json": infer R } } }; 10 | } 11 | ? R 12 | : never; 13 | -------------------------------------------------------------------------------- /apps/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reportsDirectory: "./dist/documentation/coverage", 7 | provider: "v8", 8 | reporter: ["text", "json", "html"], 9 | include: ["src/**"], 10 | exclude: ["**/**.test.ts"], 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /core/mod-tickets/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reportsDirectory: "./dist/documentation/coverage", 7 | provider: "v8", 8 | reporter: ["text", "json", "html"], 9 | include: ["src/**"], 10 | exclude: ["**/**.test.ts"], 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/ordinal.ts: -------------------------------------------------------------------------------- 1 | export const ordinal = (i: number): string => { 2 | if (!Number.isFinite(i)) return i.toString(); 3 | const v = Math.abs(i); 4 | const c = v % 100; 5 | if (c >= 10 && c <= 20) return `${i}th`; 6 | const d = v % 10; 7 | if (d === 1) return `${i}st`; 8 | if (d === 2) return `${i}nd`; 9 | if (d === 3) return `${i}rd`; 10 | return `${i}th`; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/src/lib/components/challenges/DifficultyChip.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
11 | {difficulty} 12 |
13 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true, 12 | "noImplicitAny": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /core/api/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = string | number | boolean | null | undefined; 2 | 3 | export type Serializable = 4 | | Primitive 5 | | Serializable[] 6 | | { 7 | [key: string]: Serializable; 8 | }; 9 | 10 | export type SerializableMap = { 11 | [key: string]: Serializable; 12 | }; 13 | 14 | export enum CaptchaHTTPMethod { 15 | POST = "POST", 16 | PUT = "PUT", 17 | DELETE = "DELETE", 18 | PATCH = "PATCH", 19 | } 20 | -------------------------------------------------------------------------------- /core/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true, 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/preprocessor.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from noctfcli.models import ChallengeConfig 6 | 7 | 8 | class PreprocessorBase(ABC): 9 | @abstractmethod 10 | def __init__(self, config_path: Optional[Path]): 11 | pass 12 | 13 | @abstractmethod 14 | def preprocess(self, challenge_config: ChallengeConfig) -> ChallengeConfig: 15 | pass 16 | -------------------------------------------------------------------------------- /core/mod-captcha/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true, 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /core/mod-tickets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true, 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /core/openapi-spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /core/api/src/enums.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from "@sinclair/typebox"; 2 | 3 | export const SubmissionStatus = Type.Enum({ 4 | Queued: "queued", 5 | Incorrect: "incorrect", 6 | Correct: "correct", 7 | Invalid: "invalid", 8 | }); 9 | export type SubmissionStatus = Static; 10 | 11 | export const ObjectUpdateType = Type.Enum({ 12 | Create: "create", 13 | Update: "update", 14 | Delete: "delete", 15 | }); 16 | export type ObjectUpdateType = Static; 17 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/__init__.py: -------------------------------------------------------------------------------- 1 | """noctfcli - CLI tool for noCTF challenge management.""" 2 | 3 | __version__ = "0.1.0" 4 | 5 | from .cli import build_cli 6 | from .client import NoCTFClient 7 | from .exceptions import APIError, NoCTFError, ValidationError 8 | from .models import Challenge, ChallengeConfig 9 | 10 | __all__ = [ 11 | "APIError", 12 | "Challenge", 13 | "ChallengeConfig", 14 | "NoCTFClient", 15 | "NoCTFError", 16 | "ValidationError", 17 | "build_cli", 18 | ] 19 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/redirect.ts: -------------------------------------------------------------------------------- 1 | const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); 2 | 3 | export const ExternalRedirect = (u: string) => { 4 | if (!u) return; 5 | if (/^\/[^/]/.test(u)) { 6 | window.location.href = u; 7 | return; 8 | } 9 | try { 10 | const url = new URL(u); 11 | if (ALLOWED_PROTOCOLS.has(url.protocol)) { 12 | window.location.href = url.toString(); 13 | return; 14 | } 15 | } catch { 16 | /* empty */ 17 | } 18 | window.location.href = "/"; 19 | }; 20 | -------------------------------------------------------------------------------- /core/server-core/src/util/promises.ts: -------------------------------------------------------------------------------- 1 | export const CreateThenable = (fn: () => Promise): PromiseLike => { 2 | let promise: Promise; 3 | return { 4 | then( 5 | onFulfilled?: (value: T) => U | PromiseLike, 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | onRejected?: (value: any) => V | PromiseLike, 8 | ): Promise { 9 | if (!promise) promise = fn(); 10 | return promise.then(onFulfilled, onRejected); 11 | }, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: valkey/valkey:8-alpine 4 | ports: 5 | - 6379:6379 6 | nats: 7 | image: nats 8 | command: -js 9 | ports: 10 | - 4222:4222 11 | - 8222:8222 12 | - 6222:6222 13 | postgres: 14 | image: postgres:17-alpine 15 | volumes: 16 | - postgres:/var/lib/postgresql/data 17 | environment: 18 | - POSTGRES_PASSWORD=noctf 19 | - POSTGRES_DB=noctf 20 | ports: 21 | - 5432:5432 22 | volumes: 23 | postgres: -------------------------------------------------------------------------------- /core/mod-auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true, 14 | }, 15 | "include": ["src/**/*", "../server-core/src/services/oauth_rp_provider.test.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /core/server-core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reportsDirectory: "./dist/documentation/coverage", 7 | provider: "v8", 8 | reporter: ["text", "json", "html"], 9 | include: ["src/**"], 10 | exclude: ["vitest.config.ts", "**/**.test.ts"], 11 | }, 12 | fakeTimers: { 13 | toFake: [...(configDefaults.fakeTimers.toFake || []), "performance"], 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /core/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/api", 3 | "version": "0.1.0", 4 | "description": "API requests/responses", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "tsc" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@sinclair/typebox": "catalog:" 14 | }, 15 | "devDependencies": { 16 | "typescript": "catalog:" 17 | }, 18 | "type": "module", 19 | "exports": { 20 | "./*": "./src/*.ts" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/mod-tickets/src/schema/api.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from "@sinclair/typebox"; 2 | import { Type } from "@sinclair/typebox"; 3 | 4 | export const OpenTicketRequest = Type.Object({ 5 | description: Type.String(), 6 | type: Type.String(), 7 | item: Type.String(), 8 | }); 9 | export type OpenTicketRequest = Static; 10 | 11 | export const OpenTicketResponse = Type.Object({ 12 | data: Type.Object({ 13 | id: Type.Number(), 14 | }), 15 | }); 16 | export type OpenTicketResponse = Static; 17 | -------------------------------------------------------------------------------- /core/server-core/src/services/key.ts: -------------------------------------------------------------------------------- 1 | import { BinaryLike, createHash, createHmac } from "node:crypto"; 2 | 3 | export class KeyService { 4 | private readonly secret; 5 | 6 | constructor(secret: string) { 7 | if (secret.length < 32) 8 | throw new Error( 9 | "Cannot start key service, key is less than 32 characters", 10 | ); 11 | this.secret = createHash("sha256").update(secret).digest(); 12 | } 13 | 14 | deriveKey(payload: BinaryLike) { 15 | return createHmac("sha256", this.secret).update(payload).digest(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /core/server-core/src/util/time.ts: -------------------------------------------------------------------------------- 1 | export const Delay = (timeoutMillis: number) => 2 | new Promise((resolve) => setTimeout(resolve, timeoutMillis)); 3 | 4 | export const IsTimeBetweenSeconds = ( 5 | time: number | Date, 6 | start_s?: number, 7 | end_s?: number, 8 | ) => { 9 | const ctime = 10 | typeof time === "number" ? time : Math.floor(time.getTime() / 1000); 11 | if (typeof start_s === "number" && ctime < start_s) { 12 | return false; 13 | } 14 | if (typeof end_s === "number" && ctime > end_s) { 15 | return false; 16 | } 17 | return true; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-static"; 2 | 3 | export default { 4 | kit: { 5 | adapter: adapter({ 6 | // default options are shown. On some platforms 7 | // these options are set automatically — see below 8 | pages: "dist", 9 | assets: "dist", 10 | runes: true, 11 | precompress: false, 12 | strict: true, 13 | fallback: "index.html", 14 | }), 15 | }, 16 | vite: { 17 | server: { 18 | proxy: { 19 | "/api": "http://localhost:8000", 20 | }, 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /apps/web/src/lib/state/config.svelte.ts: -------------------------------------------------------------------------------- 1 | import api from "$lib/api/index.svelte"; 2 | import type { PathResponse } from "$lib/api/types"; 3 | 4 | export type SiteConfig = PathResponse<"/site/config", "get">["data"]; 5 | 6 | export class ConfigState { 7 | siteConfig?: SiteConfig = $state(); 8 | 9 | constructor() { 10 | this.loadSiteConfig(); 11 | } 12 | 13 | private async loadSiteConfig() { 14 | const r = await api.GET("/site/config"); 15 | this.siteConfig = r.data?.data; 16 | } 17 | } 18 | 19 | const configState = new ConfigState(); 20 | export default configState; 21 | -------------------------------------------------------------------------------- /core/server-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "allowImportingTsExtensions": true, 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true 15 | }, 16 | "include": ["src/**/*", "../../apps/server/src/hooks/authn.ts", "../../apps/server/src/hooks/authz.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /core/server-core/src/services/email/types.ts: -------------------------------------------------------------------------------- 1 | import { EmailAddress } from "@noctf/api/datatypes"; 2 | 3 | export interface EmailProvider { 4 | readonly name: string; 5 | readonly queued: boolean; 6 | send(payload: EmailPayload): Promise; 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | validate(config: any): Promise; 9 | } 10 | 11 | export interface EmailPayload { 12 | from: EmailAddress; 13 | replyTo?: EmailAddress; 14 | to?: EmailAddress[]; 15 | cc?: EmailAddress[]; 16 | bcc?: EmailAddress[]; 17 | subject: string; 18 | text: string; 19 | } 20 | -------------------------------------------------------------------------------- /core/openapi-spec/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/openapi-spec", 3 | "version": "0.1.0", 4 | "description": "openapi-fetch API client", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "openapi-typescript ../../apps/server/dist/swagger.json -o src/schema.d.ts", 8 | "release": "pnpm run build" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "typescript": "catalog:", 16 | "openapi-typescript": "catalog:" 17 | }, 18 | "types": "./src/schema.d.ts" 19 | } 20 | -------------------------------------------------------------------------------- /core/schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/schema", 3 | "version": "0.1.0", 4 | "description": "", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "kysely-codegen", 8 | "release": "pnpm run build", 9 | "dev": "echo 'Nothing to do' && exit" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "kysely": "catalog:" 16 | }, 17 | "devDependencies": { 18 | "kysely-codegen": "^0.18.0", 19 | "typescript": "catalog:" 20 | }, 21 | "type": "module", 22 | "types": "./dist/index.d.ts" 23 | } 24 | -------------------------------------------------------------------------------- /core/mod-auth/src/api_schema.ts: -------------------------------------------------------------------------------- 1 | import { TypeDate } from "@noctf/api/datatypes"; 2 | import { RegisterTokenData } from "@noctf/api/token"; 3 | import { Static, Type } from "@sinclair/typebox"; 4 | 5 | export const RegisterAuthTokenRequest = Type.Object( 6 | { 7 | token: Type.String(), 8 | }, 9 | { additionalProperties: false }, 10 | ); 11 | export type RegisterAuthTokenRequest = Static; 12 | 13 | export const RegisterAuthTokenResponse = Type.Object({ 14 | data: Type.Omit(RegisterTokenData, ["type"]), 15 | }); 16 | export type RegisterAuthTokenResponse = Static< 17 | typeof RegisterAuthTokenResponse 18 | >; 19 | -------------------------------------------------------------------------------- /core/server-core/src/util/limit_keys.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from "fastify"; 2 | import { Address6 } from "ip-address"; 3 | import { isIPv4 } from "net"; 4 | 5 | export const GetRouteKey = (r: FastifyRequest) => 6 | `${r.routeOptions.method}${r.routeOptions.url}`; 7 | 8 | export const GetRouteUserIPKey = (r: FastifyRequest) => 9 | `${GetRouteKey(r)}:${r.user ? "u" + r.user.id + (r.user.app ? "a" + r.user.app : "") : "i" + NormalizeIPPrefix(r.ip)}`; 10 | 11 | export const NormalizeIPPrefix = (ip: string, prefix6 = 56) => { 12 | if (isIPv4(ip)) { 13 | return ip; 14 | } 15 | return new Address6(`${ip}/${prefix6}`).startAddress().correctForm(); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/server/src/hooks/authz.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from "fastify"; 2 | import { ForbiddenError, UnauthorizedError } from "@noctf/server-core/errors"; 3 | 4 | export const AuthzHook = async (request: FastifyRequest) => { 5 | const { policyService } = request.server.container.cradle; 6 | 7 | const policy = request.routeOptions.schema?.auth?.policy; 8 | if (!policy) { 9 | return; 10 | } 11 | const expanded = typeof policy === "function" ? await policy() : policy; 12 | if (!(await policyService.evaluate(request.user?.id || 0, expanded))) { 13 | if (request.user) throw new ForbiddenError("Access denied by policy"); 14 | throw new UnauthorizedError(); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /core/server-core/src/services/challenge/types.ts: -------------------------------------------------------------------------------- 1 | import { Challenge, ChallengeMetadata } from "@noctf/api/datatypes"; 2 | import { SubmissionStatus } from "@noctf/api/enums"; 3 | import { TSchema } from "@sinclair/typebox"; 4 | 5 | export type SolveData = { 6 | status: SubmissionStatus; 7 | comment?: string; 8 | }; 9 | 10 | export interface ChallengePlugin { 11 | name: () => string; 12 | privateSchema: () => TSchema; 13 | render: (m: Challenge["private_metadata"]) => Promise; 14 | validate: (m: Challenge["private_metadata"]) => Promise; 15 | preSolve: ( 16 | m: ChallengeMetadata, 17 | teamId: number, 18 | data: string, 19 | ) => Promise; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/team-flags.ts: -------------------------------------------------------------------------------- 1 | export interface TeamFlag { 2 | name: string; 3 | color: string; 4 | icon: string; 5 | } 6 | 7 | export const availableFlags: TeamFlag[] = [ 8 | { name: "blocked", color: "badge-error", icon: "material-symbols:block" }, 9 | { 10 | name: "hidden", 11 | color: "bg-gray-500", 12 | icon: "material-symbols:visibility-off", 13 | }, 14 | { name: "frozen", color: "badge-info", icon: "material-symbols:ac-unit" }, 15 | ]; 16 | 17 | export function getFlagConfig(flagName: string): TeamFlag { 18 | return ( 19 | availableFlags.find((f) => f.name === flagName) || { 20 | name: flagName, 21 | color: "badge-warning", 22 | icon: "", 23 | } 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /core/server-core/src/services/email/dummy.ts: -------------------------------------------------------------------------------- 1 | import { ServiceCradle } from "../../index.ts"; 2 | import { FilterUndefined } from "../../util/filter.ts"; 3 | import { EmailPayload, EmailProvider } from "./types.ts"; 4 | 5 | type Props = Pick; 6 | 7 | export class DummyEmailProvider implements EmailProvider { 8 | public readonly name = "dummy"; 9 | public readonly queued = false; 10 | 11 | private readonly logger; 12 | 13 | constructor({ logger }: Props) { 14 | this.logger = logger; 15 | } 16 | 17 | async validate(): Promise {} 18 | 19 | async send(payload: EmailPayload): Promise { 20 | this.logger.info(FilterUndefined(payload), "New Email Message"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/server-core/src/dao/util.ts: -------------------------------------------------------------------------------- 1 | import { SelectQueryBuilder, sql } from "kysely"; 2 | import { partition } from "../util/object.ts"; 3 | import { ReferenceExpression } from "kysely"; 4 | 5 | export const SplitYesNoQuery = ( 6 | query: SelectQueryBuilder, 7 | field: ReferenceExpression, 8 | values: string[], 9 | ) => { 10 | const [no, yes] = partition(values, (f) => f.startsWith("!")); 11 | 12 | let q = query; 13 | if (yes.length) { 14 | q = query.where(field, "&&", sql.val(yes)); 15 | } 16 | if (no.length) { 17 | q = query.where((eb) => 18 | eb.not(eb(field, "&&", eb.val(no.map((f) => f.substring(1))))), 19 | ); 20 | } 21 | return q; 22 | }; 23 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/country.ts: -------------------------------------------------------------------------------- 1 | import countries from "i18n-iso-countries"; 2 | import en from "i18n-iso-countries/langs/en.json"; 3 | countries.registerLocale(en); 4 | 5 | export const countryCodeToFlag = (countryCode: string) => { 6 | if ( 7 | !countryCode || 8 | countryCode.length !== 2 || 9 | !/^[a-zA-Z]+$/.test(countryCode) 10 | ) { 11 | return ""; 12 | } 13 | return Array.from(countryCode.toUpperCase()) 14 | .map((letter) => String.fromCodePoint(letter.charCodeAt(0) + 0x1f1a5)) 15 | .join(""); 16 | }; 17 | 18 | export const countryCodeToName = (countryCode: string) => { 19 | return countries.getName(countryCode, "en"); 20 | }; 21 | 22 | export const AllCountries = countries.getNames("en", { select: "official" }); 23 | -------------------------------------------------------------------------------- /core/api/src/params.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from "@sinclair/typebox"; 2 | import { Type } from "@sinclair/typebox"; 3 | 4 | export const IdParams = Type.Object( 5 | { 6 | id: Type.Number(), 7 | }, 8 | { additionalProperties: false }, 9 | ); 10 | export type IdParams = Static; 11 | 12 | export const IdOrSlugParams = Type.Object( 13 | { 14 | id: Type.String(), 15 | }, 16 | { additionalProperties: false }, 17 | ); 18 | export type IdOrSlugParams = Static; 19 | 20 | export const LocalFileParams = Type.Object( 21 | { 22 | ref: Type.String({ maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" }), 23 | }, 24 | { additionalProperties: false }, 25 | ); 26 | export type LocalFileParams = Static; 27 | -------------------------------------------------------------------------------- /core/mod-captcha/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/mod-captcha", 3 | "version": "0.1.0", 4 | "description": "core noCTF internal APIs", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "tsc", 8 | "dev": "echo 'Nothing to do' && exit" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@noctf/api": "workspace:*", 15 | "@noctf/server-core": "workspace:*", 16 | "@sinclair/typebox": "catalog:", 17 | "fastify": "catalog:", 18 | "ky": "catalog:" 19 | }, 20 | "devDependencies": { 21 | "typescript": "catalog:" 22 | }, 23 | "type": "module", 24 | "exports": { 25 | ".": "./src/index.ts", 26 | "./*": "./src/*.ts" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/server-core/src/clients/nats.ts: -------------------------------------------------------------------------------- 1 | import type { NatsConnection } from "nats"; 2 | import NATS from "nats"; 3 | import type { Logger } from "../types/primitives.ts"; 4 | 5 | export class NATSClientFactory { 6 | private readonly logger; 7 | private readonly url; 8 | 9 | private client: NatsConnection; 10 | 11 | constructor(logger: Logger, url: string) { 12 | this.logger = logger; 13 | this.url = url; 14 | } 15 | 16 | async getClient() { 17 | if (this.client) { 18 | return this.client; 19 | } 20 | const u = new URL(this.url); 21 | this.logger.info(`Connecting to NATS at ${u.host}:${u.port || 4222}`); 22 | this.client = await NATS.connect({ 23 | servers: this.url, 24 | }); 25 | return this.client; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /core/server-core/src/util/stopwatch.ts: -------------------------------------------------------------------------------- 1 | export class Stopwatch { 2 | private total = 0; 3 | private start: number | null = null; 4 | 5 | constructor(started = true) { 6 | this.start = started ? performance.now() : null; 7 | } 8 | 9 | elapsed() { 10 | return ( 11 | (this.start !== null ? performance.now() - this.start : 0) + this.total 12 | ); 13 | } 14 | 15 | pause() { 16 | this.total += this.start !== null ? performance.now() - this.start : 0; 17 | this.start = null; 18 | } 19 | 20 | isPaused() { 21 | return !this.start; 22 | } 23 | 24 | resume() { 25 | this.start = performance.now(); 26 | } 27 | 28 | clear() { 29 | this.total = 0; 30 | this.start = this.start === null ? null : performance.now(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /core/server-core/src/services/file/types.ts: -------------------------------------------------------------------------------- 1 | import type { Readable } from "node:stream"; 2 | import { TSchema } from "@sinclair/typebox"; 3 | 4 | export type ProviderFileMetadata = { 5 | filename: string; 6 | mime: string; 7 | size: number; 8 | }; 9 | 10 | export interface FileProvider { 11 | name: string; 12 | getInstance(config: any): T | Promise; 13 | getSchema(): TSchema | null; 14 | } 15 | 16 | export interface FileProviderInstance { 17 | upload(rs: Readable, pm: Omit): Promise; 18 | delete(ref: string): Promise; 19 | getURL(ref: string): Promise; 20 | download( 21 | ref: string, 22 | start?: number, 23 | end?: number, 24 | ): Promise<[Readable, ProviderFileMetadata]>; 25 | } 26 | -------------------------------------------------------------------------------- /core/server-core/src/types/enums.ts: -------------------------------------------------------------------------------- 1 | export enum UserFlag { 2 | VALID_EMAIL = "valid_email", 3 | BLOCKED = "blocked", 4 | HIDDEN = "hidden", 5 | } 6 | 7 | export enum UserRole { 8 | ADMIN = "admin", 9 | ACTIVE = "active", 10 | HAS_TEAM = "has_team", 11 | BLOCKED = "blocked", 12 | } 13 | 14 | export enum TeamFlag { 15 | BLOCKED = "blocked", 16 | HIDDEN = "hidden", 17 | FROZEN = "frozen", 18 | } 19 | 20 | export enum ActorType { 21 | USER = "user", 22 | SYSTEM = "sys", 23 | TEAM = "team", 24 | ANONYMOUS = "anonymous", 25 | } 26 | 27 | export enum EntityType { 28 | TEAM_TAG = "team_tag", 29 | POLICY = "policy", 30 | ANNOUNCEMENT = "announcement", 31 | DIVISION = "division", 32 | USER = "user", 33 | ROLE = "role", 34 | TEAM = "team", 35 | APP = "app", 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler", 13 | "noUncheckedIndexedAccess": true 14 | } 15 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 17 | // 18 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 19 | // from the referenced tsconfig.json - TypeScript does not merge them in 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/routes/team/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if isLoading} 16 |
17 |
18 |

Loading...

19 |
20 | {:else if authState.user?.team_id && !forceForm} 21 | 22 | {:else} 23 | 24 | {/if} 25 | -------------------------------------------------------------------------------- /apps/server/src/routes/admin_setup.ts: -------------------------------------------------------------------------------- 1 | import { SetupConfig } from "@noctf/api/config"; 2 | import type { FastifyInstance } from "fastify"; 3 | 4 | // TODO 5 | export async function routes(fastify: FastifyInstance) { 6 | const { configService } = fastify.container.cradle; 7 | await configService.register( 8 | SetupConfig, 9 | { 10 | initialized: false, 11 | active: false, 12 | name: "noCTF", 13 | root_url: "http://localhost:5173", 14 | allow_late_submissions: false, 15 | }, 16 | ({ initialized, end_time_s, freeze_time_s }) => { 17 | if (!initialized) { 18 | throw new Error("cannot set intialized to false"); 19 | } 20 | if (freeze_time_s && end_time_s && freeze_time_s > end_time_s) { 21 | throw new Error("freeze_time_s must be greater than end_time_s"); 22 | } 23 | }, 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | noCTF 14 | %sveltekit.head% 15 | 16 | 17 |
18 |
%sveltekit.body%
19 | 20 | 21 | -------------------------------------------------------------------------------- /apps/web/src/routes/settings/preferences/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

Preferences

7 | 8 |
9 |

Theme

10 |
11 |
14 |
15 |

Colour Theme

16 |

17 | Choose your preferred colour theme 18 |

19 |
20 | 23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /migrations/1741472210283_scoring.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from "kysely"; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .createTable("score_history") 7 | .addColumn("team_id", "integer", (e) => 8 | e.notNull().references("team.id").onDelete("cascade"), 9 | ) 10 | .addColumn("updated_at", "timestamptz", (e) => e.notNull()) 11 | .addPrimaryKeyConstraint("score_history_pkey", ["team_id", "updated_at"]) 12 | .addColumn("score", "integer", (e) => e.notNull()) 13 | .execute(); 14 | await db.schema 15 | .createIndex("score_history_idx_timestamp_team_id") 16 | .on("score_history") 17 | .columns(["updated_at", "team_id"]) 18 | .execute(); 19 | } 20 | 21 | export async function down(db: Kysely): Promise { 22 | await db.schema.dropTable("score_history").execute(); 23 | } 24 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/commands/delete.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from noctfcli.client import create_client 4 | 5 | from .common import CLIContextObj, console, handle_errors 6 | 7 | 8 | @click.command() 9 | @click.argument("challenge_slug") 10 | @click.pass_obj 11 | @handle_errors 12 | async def delete(ctx: CLIContextObj, challenge_slug: str) -> None: 13 | """Delete a challenge.""" 14 | 15 | async with create_client(ctx.config) as client: 16 | challenge = await client.get_challenge(challenge_slug) 17 | if not click.confirm( 18 | f"Are you sure you want to delete challenge '{challenge.title}' ({challenge.slug})?", 19 | ): 20 | console.print("Cancelled") 21 | return 22 | 23 | await client.delete_challenge(challenge_slug) 24 | console.print( 25 | f"[green]Deleted challenge: {challenge.title} ({challenge.slug})[/green]", 26 | ) 27 | -------------------------------------------------------------------------------- /core/server-core/src/util/pgerror.ts: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | 3 | export enum PostgresErrorCode { 4 | Duplicate = "23505", 5 | ForeignKeyViolation = "23503", 6 | } 7 | export type PostgresErrorConfig = Partial< 8 | Record< 9 | PostgresErrorCode, 10 | { 11 | [key: string]: (e: pg.DatabaseError) => Error; 12 | } 13 | > 14 | >; 15 | 16 | export const TryPGConstraintError = ( 17 | e: pg.DatabaseError, // We intentionally don't want to verify if error is an actual pg error 18 | config: PostgresErrorConfig, 19 | ) => { 20 | if (e.code && config[e.code as PostgresErrorCode]) { 21 | const cfg = config[e.code as PostgresErrorCode]!; // typescript is dumb 22 | if (e.constraint && cfg[e.constraint]) { 23 | return cfg[e.constraint](e); 24 | } 25 | 26 | // hopefully there's no constraint called default 27 | if (cfg["default"]) { 28 | return cfg["default"](e); 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /core/mod-auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/mod-auth", 3 | "version": "0.1.0", 4 | "description": "core auth plugin", 5 | "scripts": { 6 | "test:unit": "vitest", 7 | "build": "tsc", 8 | "dev": "echo 'Nothing to do' && exit" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@fastify/formbody": "catalog:", 15 | "@noble/ed25519": "^2.3.0", 16 | "@noctf/api": "workspace:*", 17 | "@noctf/schema": "workspace:*", 18 | "@noctf/server-core": "workspace:*", 19 | "@sinclair/typebox": "catalog:", 20 | "fastify": "catalog:", 21 | "handlebars": "catalog:", 22 | "jose": "^6.0.11", 23 | "ky": "catalog:", 24 | "kysely": "catalog:", 25 | "nanoid": "catalog:" 26 | }, 27 | "devDependencies": { 28 | "typescript": "catalog:", 29 | "vitest": "catalog:" 30 | }, 31 | "type": "module", 32 | "exports": { 33 | ".": "./src/index.ts", 34 | "./*": "./src/*.ts" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/mod-tickets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/mod-tickets", 3 | "version": "0.1.0", 4 | "description": "tickets module", 5 | "scripts": { 6 | "test:unit": "vitest --coverage", 7 | "build": "tsc", 8 | "release": "pnpm run build && pnpm run test:unit --run", 9 | "dev": "echo 'Nothing to do' && exit" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@noctf/api": "workspace:*", 16 | "@noctf/schema": "workspace:*", 17 | "@noctf/server-core": "workspace:*", 18 | "@sinclair/typebox": "catalog:", 19 | "discord-api-types": "^0.37.114", 20 | "fastify": "catalog:", 21 | "ky": "catalog:" 22 | }, 23 | "devDependencies": { 24 | "@vitest/coverage-v8": "catalog:", 25 | "typescript": "catalog:", 26 | "vitest": "catalog:", 27 | "vitest-mock-extended": "catalog:" 28 | }, 29 | "type": "module", 30 | "exports": { 31 | ".": "./src/index.ts", 32 | "./*": "./src/*.ts" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/server-core/src/util/graph.ts: -------------------------------------------------------------------------------- 1 | export const WindowDeltaedTimeSeriesPoints = ( 2 | points: [number[], number[]] | undefined, 3 | windowSize = 1, 4 | reducer = (a: number, b: number) => a + b, 5 | ): [number[], number[]] => { 6 | if (!points) return [[], []]; 7 | if (points[0].length !== points[1].length) throw new Error("Invalid graph"); 8 | if (windowSize === 1 || points[0].length === 0) return points; 9 | let t = points[0][0]; 10 | let x = Math.floor(t / windowSize) * windowSize; 11 | let l = 0; 12 | let y = points[1][0]; 13 | const out: [number[], number[]] = [[], []]; 14 | for (let p = 1; p < points[0].length; p++) { 15 | const w = Math.floor((t += points[0][p]) / windowSize) * windowSize; 16 | if (w === x) { 17 | y = reducer(y, points[1][p]); 18 | } else { 19 | out[0].push(x - l); 20 | out[1].push(y); 21 | l = x; 22 | x = w; 23 | y = points[1][p]; 24 | } 25 | } 26 | out[0].push(x - l); 27 | out[1].push(y); 28 | return out; 29 | }; 30 | -------------------------------------------------------------------------------- /kysely.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "kysely-ctl"; 2 | import type { MigrationProvider } from "kysely"; 3 | import { Pool } from "pg"; 4 | import { glob } from "node:fs/promises"; 5 | import { basename } from "node:path"; 6 | 7 | const MIGRATION_FILE_REGEX = /^[\d]+_.+\.ts$/; 8 | 9 | class DevMigrationProvider implements MigrationProvider { 10 | async getMigrations() { 11 | const migrations = {}; 12 | for await (const filename of glob([ 13 | "migrations/*.ts", 14 | "plugins/*/migrations/*.ts", 15 | ])) { 16 | if (!basename(filename).match(MIGRATION_FILE_REGEX)) { 17 | continue; 18 | } 19 | migrations[filename] = await import(`./${filename}`); 20 | } 21 | return migrations; 22 | } 23 | } 24 | 25 | export default defineConfig({ 26 | dialect: "pg", 27 | migrations: { 28 | provider: new DevMigrationProvider(), 29 | }, 30 | dialectConfig: { 31 | pool: new Pool({ 32 | connectionString: process.env.POSTGRES_URL, 33 | }), 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # sv 2 | 3 | Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npx sv create 12 | 13 | # create a new project in my-app 14 | npx sv create my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /core/server-core/src/util/coleascer.ts: -------------------------------------------------------------------------------- 1 | import type { Primitive } from "@noctf/api/types"; 2 | 3 | export class Coleascer { 4 | private readonly map = new Map>(); 5 | 6 | get(key: Primitive): Promise | null; 7 | get(key: Primitive, handler: () => Promise): Promise; 8 | get(key: Primitive, handler?: () => Promise): Promise | null { 9 | let promise = this.map.get(key); 10 | if (!promise) { 11 | if (!handler) return null; 12 | promise = handler().finally(() => this.map.delete(key)); 13 | promise.catch(() => {}); 14 | this.map.set(key, promise); 15 | } 16 | return promise as Promise; 17 | } 18 | 19 | put(key: Primitive, promise: Promise) { 20 | if (this.map.has(key)) throw new Error("Promise already exists for key"); 21 | promise.catch(() => {}); 22 | this.map.set( 23 | key, 24 | promise.finally(() => this.map.delete(key)), 25 | ); 26 | } 27 | 28 | delete(key: Primitive) { 29 | this.map.delete(key); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # core libs 3 | - "core/*" 4 | # user plugins 5 | - "plugins/*" 6 | # deployable applications 7 | - "apps/*" 8 | 9 | catalog: 10 | "@eslint/js": ^9.17.0 11 | "@types/node": ^22.7.4 12 | "@types/pg": ^8.11.10 13 | "eslint": ^9.17.0 14 | "typescript": "5.8.3" 15 | "cbor-x": ^1.6.0 16 | "typescript-eslint": ^8.38.0 17 | "@typescript-eslint/parser": ^8.38.0 18 | "tsx": ^4.19.1 19 | "ajv": ^8.17.1 20 | "ajv-formats": "^3.0.1" 21 | "handlebars": "^4.7.8" 22 | "esbuild": ^0.24.1 23 | "kysely": ^0.27.5 24 | "ky": ^1.7.2 25 | "pg": ^8.13.1 26 | "awilix": ^12.0.4 27 | "fastify": ^5.2.0 28 | "@fastify/swagger": ^9.3.0 29 | "@fastify/formbody": "^8.0.2" 30 | "pino-pretty": ^13.0.0 31 | "@sinclair/typebox": ^0.34.13 32 | "vitest": ^2.1.8 33 | "nanoid": ^5.0.9 34 | "p-limit": ^6.2.0 35 | "@vitest/coverage-v8": ^2.1.8 36 | "vitest-mock-extended": ^2.0.2 37 | "openapi-typescript": "^7.5.2" 38 | "openapi-fetch": "^0.13.4" 39 | "expr-eval": "npm:expr-eval-fork@^2.0.2" 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # TODO: make this auto-migrate 2 | services: 3 | server: 4 | image: ghcr.io/noctf-project/noctf/server 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: out_server 9 | depends_on: 10 | - redis 11 | - nats 12 | - postgres 13 | environment: 14 | NATS_URL: "nats://nats:4222" 15 | REDIS_URL: "redis://redis:6379" 16 | POSTGRES_URL: "postgres://postgres:noctf@postgres/noctf" 17 | HOST: "::" 18 | ENABLE_SWAGGER: 0 19 | ALLOWED_ORIGINS: "example.com,localhost" 20 | web: 21 | image: ghcr.io/noctf-project/noctf/web 22 | build: 23 | context: . 24 | dockerfile: Dockerfile 25 | target: out_web 26 | redis: 27 | image: valkey/valkey:8-alpine 28 | nats: 29 | image: nats 30 | command: -js 31 | postgres: 32 | image: postgres:17-alpine 33 | volumes: 34 | - postgres:/var/lib/postgresql/data 35 | environment: 36 | - POSTGRES_PASSWORD=noctf 37 | - POSTGRES_DB=noctf 38 | volumes: 39 | postgres: -------------------------------------------------------------------------------- /core/server-core/src/services/scoreboard/index.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, it, vi } from "vitest"; 2 | import { ScoreboardService } from "./index.ts"; 3 | import { mockDeep } from "vitest-mock-extended"; 4 | import type { CacheService } from "../cache.ts"; 5 | import { DivisionDAO } from "../../dao/division.ts"; 6 | import { ScoreboardDataLoader } from "./loader.ts"; 7 | 8 | vi.mock(import("../../dao/division.ts")); 9 | vi.mock(import("./loader.ts")); 10 | 11 | describe(ScoreboardService, () => { 12 | const cacheService = mockDeep(); 13 | 14 | const scoreboardDataLoader = mockDeep(); 15 | const divisionDAO = mockDeep(); 16 | 17 | beforeEach(() => { 18 | cacheService.load.mockImplementation((_a, _b, fetcher) => fetcher()); 19 | vi.mocked(ScoreboardDataLoader).mockReturnValue(scoreboardDataLoader); 20 | vi.mocked(DivisionDAO).mockReturnValue(divisionDAO); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | }); 26 | 27 | it("dummy test", () => {}); 28 | }); 29 | -------------------------------------------------------------------------------- /core/server-core/src/types/primitives.ts: -------------------------------------------------------------------------------- 1 | // raw types only, do not import any modules 2 | 3 | export type StringOrSet = string | Set; 4 | 5 | export type AtLeast = Partial & Pick; 6 | 7 | export type AllNonNullable = { 8 | [P in keyof T]: NonNullable; 9 | }; 10 | 11 | export interface LogFn { 12 | /* eslint-disable @typescript-eslint/no-explicit-any */ 13 | (obj: T, msg?: string, ...args: any[]): void; 14 | (obj: unknown, msg?: string, ...args: any[]): void; 15 | (msg: string, ...args: any[]): void; 16 | /* eslint-enable @typescript-eslint/no-explicit-any */ 17 | } 18 | 19 | export interface Logger { 20 | info: LogFn; 21 | error: LogFn; 22 | warn: LogFn; 23 | debug: LogFn; 24 | fatal: LogFn; 25 | trace: LogFn; 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | child: (bindings: { [k: string]: any }) => Logger; 28 | } 29 | 30 | export type AsMutable = Omit & { 31 | -readonly [P in K]: T[P]; 32 | }; 33 | -------------------------------------------------------------------------------- /apps/server/src/routes/admin_scoreboard.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | import { BaseResponse } from "@noctf/api/responses"; 3 | import { AdminScoreboardTriggerRequest } from "@noctf/api/requests"; 4 | import { ScoreboardTriggerEvent } from "@noctf/api/events"; 5 | 6 | export async function routes(fastify: FastifyInstance) { 7 | const { eventBusService } = fastify.container.cradle; 8 | fastify.post<{ Body: AdminScoreboardTriggerRequest }>( 9 | "/admin/scoreboard/trigger", 10 | { 11 | schema: { 12 | security: [{ bearer: [] }], 13 | tags: ["admin"], 14 | auth: { 15 | require: true, 16 | policy: ["admin.scoreboard.trigger"], 17 | }, 18 | body: AdminScoreboardTriggerRequest, 19 | response: { 20 | 200: BaseResponse, 21 | }, 22 | }, 23 | }, 24 | async (request) => { 25 | const { recompute_graph } = request.body; 26 | await eventBusService.publish(ScoreboardTriggerEvent, { 27 | recompute_graph, 28 | }); 29 | return {}; 30 | }, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @layer utilities { 6 | .pop { 7 | @apply border; 8 | @apply border-base-500; 9 | @apply shadow-solid; 10 | } 11 | } 12 | 13 | .markdown-body { 14 | @apply prose; 15 | max-width: 95%; 16 | } 17 | 18 | .markdown-body a { 19 | @apply text-primary; 20 | } 21 | 22 | .carta-font-code { 23 | font-family: "...", monospace; 24 | font-size: 1.1rem; 25 | line-height: 32px; 26 | } 27 | 28 | .markdown-body :where(code):not(:where([class~="not-prose"] *, pre *)) { 29 | border-radius: 0.3rem; 30 | color: var(--fallback-p, oklch(var(--p) / var(--tw-text-opacity, 1))); 31 | } 32 | 33 | .carta-editor { 34 | @apply text-base-content; 35 | } 36 | 37 | .carta-editor .carta-input { 38 | @apply text-base-content; 39 | } 40 | 41 | .carta-editor .carta-input textarea { 42 | @apply text-base-content; 43 | color: inherit !important; 44 | } 45 | 46 | .carta-editor .carta-renderer { 47 | @apply text-base-content; 48 | } 49 | 50 | .carta-editor .carta-renderer * { 51 | @apply text-base-content; 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants/categories.ts: -------------------------------------------------------------------------------- 1 | export const CATEGORIES = [ 2 | "pwn", 3 | "crypto", 4 | "web", 5 | "rev", 6 | "misc", 7 | "hardware", 8 | "forensics", 9 | "osint", 10 | "blockchain", 11 | "cloud", 12 | "beginner", 13 | "unsolved", 14 | "solved", 15 | "ai", 16 | "survey", 17 | ] as const; 18 | export type Category = (typeof CATEGORIES)[number]; 19 | export const ICON_MAP: { [k in Category | "all"]: string } = { 20 | all: "material-symbols:background-dot-small", 21 | unsolved: "material-symbols:mail-outline", 22 | solved: "material-symbols:send", 23 | pwn: "material-symbols:bug-report-rounded", 24 | crypto: "material-symbols:key", 25 | web: "tabler:world", 26 | rev: "material-symbols:fast-rewind", 27 | misc: "mdi:puzzle", 28 | hardware: "bxs:chip", 29 | forensics: "material-symbols:document-search-rounded", 30 | osint: "ph:detective-fill", 31 | blockchain: "tdesign:blockchain", 32 | beginner: "mdi:seedling", 33 | cloud: "material-symbols:cloud", 34 | ai: "material-symbols:wand-stars-outline-rounded", 35 | survey: "ri:survey-line", 36 | }; 37 | export const CATEGORY_UNKNOWN_ICON = "carbon:unknown-filled"; 38 | -------------------------------------------------------------------------------- /core/server-core/src/types/fastify.ts: -------------------------------------------------------------------------------- 1 | import { RateLimitBucket } from "../services/rate_limit.ts"; 2 | import type { TeamService } from "../services/team.ts"; 3 | import type { Policy } from "../util/policy.ts"; 4 | 5 | declare module "fastify" { 6 | interface FastifyInstance { 7 | readonly apiURL: string; 8 | } 9 | 10 | interface FastifySchema { 11 | tags?: string[]; 12 | description?: string; 13 | security?: [{ [key: string]: unknown }]; 14 | auth?: { 15 | require?: boolean; 16 | scopes?: Set; 17 | policy?: Policy | (() => Promise | Policy); 18 | }; 19 | rateLimit?: 20 | | RateLimitBucket 21 | | RateLimitBucket[] 22 | | (( 23 | r: FastifyRequest, 24 | ) => 25 | | Promise 26 | | RateLimitBucket 27 | | RateLimitBucket[]); 28 | } 29 | 30 | interface FastifyRequest { 31 | user?: { 32 | app?: number; 33 | id: number; 34 | token: string; 35 | membership: PromiseLike< 36 | Awaited> 37 | >; 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/monorepo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "eslint", 9 | "lint:fix": "eslint --fix", 10 | "format": "prettier '**/*.{svelte,ts,js,mjs}' --write" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "Apache-2.0", 15 | "devDependencies": { 16 | "@eslint/js": "catalog:", 17 | "@types/pg": "catalog:", 18 | "@typescript-eslint/parser": "catalog:", 19 | "eslint": "catalog:", 20 | "eslint-config-prettier": "^9.1.0", 21 | "eslint-plugin-svelte": "^2.46.1", 22 | "globals": "^15.14.0", 23 | "kysely": "catalog:", 24 | "kysely-ctl": "^0.10.1", 25 | "pg": "catalog:", 26 | "prettier": "^3.4.2", 27 | "prettier-plugin-svelte": "^3.3.3", 28 | "svelte-eslint-parser": "^0.43.0", 29 | "typescript-eslint": "catalog:", 30 | "typescript": "catalog:" 31 | }, 32 | "packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228" 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function getRelativeTime(date: Date) { 2 | const now = new Date(); 3 | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 4 | 5 | if (diffInSeconds < 60) { 6 | return "just now"; 7 | } 8 | 9 | const diffInMinutes = Math.floor(diffInSeconds / 60); 10 | if (diffInMinutes < 60) { 11 | return `${diffInMinutes}m ago`; 12 | } 13 | 14 | const diffInHours = Math.floor(diffInMinutes / 60); 15 | if (diffInHours < 24) { 16 | return `${diffInHours}h ago`; 17 | } 18 | 19 | const diffInDays = Math.floor(diffInHours / 24); 20 | if (diffInDays < 30) { 21 | return `${diffInDays}d ago`; 22 | } 23 | 24 | const diffInMonths = Math.floor(diffInDays / 30); 25 | return `${diffInMonths}mo ago`; 26 | } 27 | 28 | export function formatTimeDifference(a: Date, b: Date): string { 29 | const diffInSeconds = Math.abs( 30 | Math.floor((a.getTime() - b.getTime()) / 1000), 31 | ); 32 | const hours = Math.floor(diffInSeconds / 3600); 33 | const minutes = Math.floor((diffInSeconds % 3600) / 60); 34 | const seconds = diffInSeconds % 60; 35 | 36 | return `${hours}h ${minutes}m ${seconds}s`; 37 | } 38 | -------------------------------------------------------------------------------- /core/server-core/src/util/stopwatch.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { Stopwatch } from "./stopwatch.ts"; 4 | 5 | describe(Stopwatch, () => { 6 | beforeEach(() => { 7 | vi.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | vi.useRealTimers(); 12 | }); 13 | 14 | it("can be paused on creation", () => { 15 | const stopwatch = new Stopwatch(false); 16 | vi.advanceTimersByTime(1000); 17 | expect(stopwatch.elapsed()).to.eql(0); 18 | }); 19 | 20 | it("counts upwards", () => { 21 | const stopwatch = new Stopwatch(); 22 | vi.advanceTimersByTime(1000); 23 | expect(stopwatch.elapsed()).to.eql(1000); 24 | }); 25 | 26 | it("is pausable", () => { 27 | const stopwatch = new Stopwatch(); 28 | vi.advanceTimersByTime(1000); 29 | expect(stopwatch.elapsed()).to.eql(1000); 30 | stopwatch.pause(); 31 | expect(stopwatch.isPaused()).to.eql(true); 32 | vi.advanceTimersByTime(1000); 33 | expect(stopwatch.elapsed()).to.eql(1000); 34 | stopwatch.resume(); 35 | expect(stopwatch.isPaused()).to.eql(false); 36 | vi.advanceTimersByTime(1000); 37 | expect(stopwatch.elapsed()).to.eql(2000); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /core/server-core/src/util/object.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export const get = (obj: any, path: string, defaultValue?: any) => { 3 | const travel = (regexp: RegExp) => 4 | String.prototype.split 5 | .call(path, regexp) 6 | .filter(Boolean) 7 | .reduce( 8 | (res: any, key: string) => 9 | res !== null && res !== undefined ? res[key] : res, 10 | obj, 11 | ); 12 | const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/); 13 | return result === undefined || result === obj ? defaultValue : result; 14 | }; 15 | 16 | export const partition = (arr: T[], x: (t: T) => boolean) => { 17 | const truthy: T[] = []; 18 | const falsey: T[] = []; 19 | arr.forEach((a) => (x(a) ? truthy.push(a) : falsey.push(a))); 20 | return [truthy, falsey]; 21 | }; 22 | 23 | export const deepEqual = (a: T, b: T): boolean => { 24 | if (a === b) { 25 | return true; 26 | } 27 | 28 | const bothAreObjects = 29 | a && b && typeof a === "object" && typeof b === "object"; 30 | 31 | return Boolean( 32 | bothAreObjects && 33 | Object.keys(a).length === Object.keys(b).length && 34 | Object.entries(a).every(([k, v]) => deepEqual(v, b[k as keyof T])), 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /apps/server/src/routes/admin_audit_log.ts: -------------------------------------------------------------------------------- 1 | import { QueryAuditLogRequest } from "@noctf/api/requests"; 2 | import { QueryAuditLogResponse } from "@noctf/api/responses"; 3 | import type { FastifyInstance } from "fastify"; 4 | import "@noctf/server-core/types/fastify"; 5 | 6 | export async function routes(fastify: FastifyInstance) { 7 | const { auditLogService } = fastify.container.cradle; 8 | 9 | fastify.post<{ Body: QueryAuditLogRequest; Reply: QueryAuditLogResponse }>( 10 | "/admin/audit_log/query", 11 | { 12 | schema: { 13 | tags: ["admin"], 14 | security: [{ bearer: [] }], 15 | auth: { 16 | require: true, 17 | scopes: new Set(["admin"]), 18 | policy: ["admin.audit_log.get"], 19 | }, 20 | body: QueryAuditLogRequest, 21 | response: { 22 | 200: QueryAuditLogResponse, 23 | }, 24 | }, 25 | }, 26 | async (request) => { 27 | const { page_size, ...query } = request.body; 28 | const limit = Math.min(Math.max(0, page_size), 1000); 29 | const entries = await auditLogService.query(query, limit); 30 | return { 31 | data: { 32 | entries, 33 | page_size: limit, 34 | }, 35 | }; 36 | }, 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /tools/noctfcli/.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | share/python-wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | 25 | # Virtual environments 26 | venv/ 27 | env/ 28 | ENV/ 29 | .venv/ 30 | .env/ 31 | 32 | # IDEs 33 | .vscode/ 34 | .idea/ 35 | *.swp 36 | *.swo 37 | *~ 38 | 39 | # Testing 40 | .coverage 41 | .pytest_cache/ 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | 50 | # Type checking 51 | .mypy_cache/ 52 | .dmypy.json 53 | dmypy.json 54 | 55 | # Jupyter Notebook 56 | .ipynb_checkpoints 57 | 58 | # pyenv 59 | .python-version 60 | 61 | # pipenv 62 | Pipfile.lock 63 | 64 | # PEP 582 65 | __pypackages__/ 66 | 67 | # Celery 68 | celerybeat-schedule 69 | celerybeat.pid 70 | 71 | # SageMath parsed files 72 | *.sage.py 73 | 74 | # Environments 75 | .env 76 | .venv 77 | env/ 78 | venv/ 79 | ENV/ 80 | env.bak/ 81 | venv.bak/ 82 | 83 | # Rope project settings 84 | .ropeproject 85 | 86 | # mkdocs documentation 87 | /site 88 | 89 | # macOS 90 | .DS_Store 91 | 92 | # Windows 93 | Thumbs.db 94 | ehthumbs.db 95 | Desktop.ini -------------------------------------------------------------------------------- /core/server-core/src/worker/registry.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "../types/primitives.ts"; 2 | import { BaseWorker } from "./types.ts"; 3 | 4 | export class WorkerRegistry implements BaseWorker { 5 | private workers: BaseWorker[] = []; 6 | 7 | private timeout: PromiseWithResolvers | null; 8 | private cancellation: ReturnType | null; 9 | 10 | constructor( 11 | private readonly logger: Logger, 12 | private readonly timeoutMs = 60000, 13 | ) {} 14 | 15 | register(w: BaseWorker) { 16 | this.workers.push(w); 17 | } 18 | 19 | async run() { 20 | this.timeout = Promise.withResolvers(); 21 | const jobs = Promise.all(this.workers.map((w) => w.run())); 22 | await Promise.race([jobs, this.timeout.promise]); 23 | if (this.cancellation) clearTimeout(this.cancellation); 24 | this.cancellation = null; 25 | this.timeout = null; 26 | } 27 | 28 | dispose() { 29 | this.logger.info("Gracefully shutting down workers"); 30 | this.workers.forEach((w) => w.dispose()); 31 | this.cancellation = setTimeout(() => { 32 | this.timeout?.reject( 33 | new Error("Worker failed to shut down within allotted timeout"), 34 | ); 35 | this.timeout = null; 36 | this.cancellation = null; 37 | }, this.timeoutMs); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/mod-auth/src/const.ts: -------------------------------------------------------------------------------- 1 | import type { AuthConfig } from "@noctf/api/config"; 2 | import Handlebars from "handlebars"; 3 | 4 | export const CACHE_NAMESPACE = "core:mod:auth"; 5 | 6 | export const DEFAULT_CONFIG: AuthConfig = { 7 | enable_register_password: true, 8 | enable_login_password: true, 9 | enable_oauth: true, 10 | validate_email: false, 11 | }; 12 | 13 | export const EMAIL_VERIFICATION_TEMPLATE = Handlebars.compile(`Hello, 14 | 15 | Someone tried to register for {{ ctf_name }} using your email address. 16 | 17 | If it was you, please click the following link to continue with creating your account: 18 | {{ root_url }}/auth/register?token={{ token }} 19 | `); 20 | 21 | export const EMAIL_CHANGE_TEMPLATE = Handlebars.compile(`Hello, 22 | 23 | An email change request was made for {{ ctf_name }} using your email address. 24 | 25 | If it was you, please click the following link to confirm your email address change: 26 | {{ root_url }}/settings/account?token={{ token }} 27 | `); 28 | 29 | export const EMAIL_RESET_PASSWORD_TEMPLATE = Handlebars.compile(`Hello, 30 | 31 | Someone tried to reset your password for {{ ctf_name }} using your email address. 32 | 33 | If it was you, please click the following link to continue with resetting your password: 34 | {{ root_url }}/auth/reset?token={{ token }} 35 | `); 36 | -------------------------------------------------------------------------------- /tools/anonymiser/anonymise.sql: -------------------------------------------------------------------------------- 1 | SET session_replication_role = replica; 2 | 3 | CREATE TABLE ip_mapping ( 4 | id INTEGER GENERATED ALWAYS AS IDENTITY, 5 | original varchar unique, 6 | anonymised varchar GENERATED ALWAYS AS ('10.0.' || (id/256) || '.' || (id%256)) STORED 7 | ); 8 | INSERT INTO ip_mapping (original) SELECT ip FROM "session" ON CONFLICT (original) DO NOTHING; 9 | INSERT INTO ip_mapping (original) SELECT metadata->>'ip' FROM "submission" ON CONFLICT(original) DO NOTHING; 10 | 11 | UPDATE session SET ip=(SELECT anonymised FROM ip_mapping WHERE ip=original); 12 | UPDATE "user_identity" SET 13 | secret_data='$scrypt$N=16384,r=8,p=1$KMcU5PfZlICJrioEe8MtbQ==$FAM/KB8ZUaySkIjM+GQLM0d0BVuVkH4JLrl7UF33q/gbVOoyWZ7hKB23RnlUNRG2q5GiBftQ1IGBYkwi+kcsPQ==', 14 | provider_id=('email-'||user_id||'@2025.ductf.net'), 15 | updated_at=updated_at 16 | WHERE provider='email'; 17 | UPDATE "user" SET name=('redacted-name-'||id), updated_at=updated_at WHERE name LIKE '%@%.com'; 18 | UPDATE submission 19 | SET metadata = jsonb_set(metadata, '{ip}', to_jsonb(ip_mapping.anonymised)), 20 | updated_at=updated_at 21 | FROM ip_mapping 22 | WHERE ip_mapping.original = (submission.metadata->>'ip') 23 | AND ip_mapping.anonymised IS NOT NULL; 24 | DROP TABLE ip_mapping; 25 | DELETE FROM config WHERE namespace NOT IN ('core.setup', 'core.score'); -------------------------------------------------------------------------------- /apps/web/src/lib/components/config/JsonConfigEditor.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 |
43 | 53 |
54 | -------------------------------------------------------------------------------- /core/mod-auth/src/oauth_jwks.ts: -------------------------------------------------------------------------------- 1 | import { getPublicKeyAsync } from "@noble/ed25519"; 2 | import type { KeyService } from "@noctf/server-core/services/key"; 3 | import { CryptoKey, importJWK, JWK } from "jose"; 4 | import { createHash } from "node:crypto"; 5 | 6 | const EPOCH_SECONDS = 86400; 7 | 8 | export class JWKSStore { 9 | private key: { pub: JWK; secret: CryptoKey }; 10 | 11 | constructor(private readonly keyService: KeyService) {} 12 | 13 | async getKey() { 14 | if (!this.key) { 15 | this.key = await this.generateKey(); 16 | } 17 | return this.key; 18 | } 19 | 20 | private async generateKey() { 21 | const key = this.keyService.deriveKey("auth:jwk"); 22 | const secret = { 23 | kty: "OKP", 24 | crv: "Ed25519", 25 | d: key.toString("base64url"), 26 | x: Buffer.from(await getPublicKeyAsync(key)).toString("base64url"), 27 | use: "sig", 28 | alg: "EdDSA", 29 | }; 30 | const pub = { 31 | kid: createHash("sha256") 32 | .update(secret.x) 33 | .digest() 34 | .subarray(0, 20) 35 | .toString("base64url"), 36 | kty: secret.kty, 37 | crv: secret.crv, 38 | x: secret.x, 39 | alg: secret.alg, 40 | use: secret.use, 41 | }; 42 | return { pub, secret: (await importJWK(secret)) as CryptoKey }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/server-core/src/worker/signalled.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "../types/primitives.ts"; 2 | import { Delay } from "../util/time.ts"; 3 | import { BaseWorker } from "./types.ts"; 4 | 5 | export class SignalledWorker implements BaseWorker { 6 | private controller: AbortController; 7 | 8 | private readonly name; 9 | private readonly handler; 10 | private readonly logger; 11 | 12 | constructor({ 13 | name, 14 | handler, 15 | logger, 16 | }: { 17 | name: string; 18 | handler: (s: AbortSignal) => Promise; 19 | logger: Logger; 20 | }) { 21 | this.name = name; 22 | this.handler = handler; 23 | this.logger = logger; 24 | } 25 | dispose(): void { 26 | this.controller.abort("Disposed"); 27 | } 28 | 29 | async run() { 30 | if (this.controller) throw new Error("Worker is already running"); 31 | this.controller = new AbortController(); 32 | while (!this.controller.signal.aborted) { 33 | try { 34 | await this.handler(this.controller.signal); 35 | } catch (error) { 36 | this.logger.error( 37 | { name: this.name, error, message: error?.message }, 38 | "Worker threw error, restarting", 39 | ); 40 | } finally { 41 | // so it doesn't infinite loop 42 | await Delay(100); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /migrations/util.ts: -------------------------------------------------------------------------------- 1 | import { sql, CreateTableBuilder, SchemaModule } from "kysely"; 2 | 3 | // IMPORTANT: do not modify these in a way that will affect the schema, the previous migrations 4 | // depend on it. 5 | 6 | export const CreateTriggerUpdatedAt = ( 7 | table: string, 8 | ) => sql`CREATE TRIGGER ${sql.ref(`${table}_updated_at`)} 9 | BEFORE UPDATE ON ${sql.ref(table)} 10 | FOR EACH ROW EXECUTE PROCEDURE trigger_updated_at()`; 11 | 12 | export function CreateTableWithDefaultTimestamps( 13 | schema: SchemaModule, 14 | table: T, 15 | ): CreateTableBuilder; 16 | export function CreateTableWithDefaultTimestamps< 17 | T extends string, 18 | const S extends readonly string[], 19 | >(schema: SchemaModule, table: T, columns: S): CreateTableBuilder; 20 | export function CreateTableWithDefaultTimestamps< 21 | T extends string, 22 | S extends readonly string[], 23 | >( 24 | schema: SchemaModule, 25 | table: T, 26 | columns?: S, 27 | ): 28 | | CreateTableBuilder 29 | | CreateTableBuilder { 30 | const derived = columns ?? (["created_at", "updated_at"] as const); 31 | let tbl = schema.createTable(table); 32 | for (const column of derived) { 33 | tbl = tbl.addColumn(column, "timestamptz", (col) => 34 | col.defaultTo(sql`now()`).notNull(), 35 | ); 36 | } 37 | 38 | return tbl; 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/lib/stores/toast.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | 3 | type ToastType = "info" | "success" | "warning" | "error"; 4 | 5 | interface Toast { 6 | message: string; 7 | type: ToastType; 8 | id: string; 9 | } 10 | 11 | function createToastStore() { 12 | const { subscribe, update } = writable([]); 13 | 14 | function addToast( 15 | message: string, 16 | type: ToastType = "info", 17 | duration: number = 5000, 18 | ) { 19 | // crypto.randomUUID() is not available in the safari? 20 | const id = Math.random().toString(36).substring(2, 15); 21 | // only keep the most recent 5 messages 22 | update((toasts) => { 23 | const newToasts = [...toasts, { message, type, id }]; 24 | return newToasts.slice(-5); 25 | }); 26 | setTimeout(() => removeToast(id), duration); 27 | } 28 | 29 | function removeToast(id: string) { 30 | update((toasts) => toasts.filter((t) => t.id !== id)); 31 | } 32 | 33 | return { 34 | subscribe, 35 | remove: removeToast, 36 | info: (msg: string, duration?: number) => addToast(msg, "info", duration), 37 | success: (msg: string, duration?: number) => 38 | addToast(msg, "success", duration), 39 | warning: (msg: string, duration?: number) => 40 | addToast(msg, "warning", duration), 41 | error: (msg: string, duration?: number) => addToast(msg, "error", duration), 42 | }; 43 | } 44 | 45 | export const toasts = createToastStore(); 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=24-slim 2 | 3 | 4 | # use a staging image to cache dependencies better 5 | FROM node:$NODE_VERSION AS staging 6 | COPY . . 7 | RUN mkdir -p /staging && \ 8 | cp package.json pnpm-lock.yaml pnpm-workspace.yaml /staging/ && \ 9 | find apps core plugins -name "package.json" -exec sh -c 'mkdir -p /staging/$(dirname {}) && cp {} /staging/{}' \; 10 | 11 | FROM node:$NODE_VERSION AS base 12 | ENV CI=1 13 | ENV DOCKER_ENV=1 14 | WORKDIR /build 15 | RUN corepack enable pnpm 16 | COPY --from=staging /staging/ . 17 | RUN pnpm install -r --ignore-scripts --frozen-lockfile 18 | COPY . . 19 | 20 | # create images 21 | FROM base AS build_server 22 | RUN pnpm --filter '@noctf/server' build 23 | RUN pnpm --filter=@noctf/server --prefer-offline --prod deploy /deploy/server 24 | FROM node:$NODE_VERSION AS out_server 25 | COPY --from=build_server /deploy/server /build/apps/server 26 | ENV HOST=:: 27 | ENV ENABLE_SWAGGER=0 28 | WORKDIR "/build/apps/server" 29 | USER 1000 30 | CMD ["node", "dist/www.cjs"] 31 | 32 | FROM base AS build_web 33 | RUN VITE_API_BASE_URL="___REPLACEME_NOCTF_API_BASE_URL___" pnpm --filter '@noctf/web' build 34 | 35 | FROM joseluisq/static-web-server:2-alpine AS out_web 36 | COPY --from=build_web /build/apps/web/docker-init /init 37 | COPY --from=build_web /build/apps/web/dist /public 38 | COPY --from=build_web /build/apps/web/dist/index.html /public/index.html 39 | EXPOSE 80/tcp 40 | ENTRYPOINT [ "/init/setup.sh" ] 41 | CMD [ "static-web-server" ] -------------------------------------------------------------------------------- /apps/server/src/config.ts: -------------------------------------------------------------------------------- 1 | export const HOST = process.env.HOST || "localhost"; 2 | export const PORT = parseInt(process.env.PORT) || 8000; 3 | export const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS 4 | ? process.env.ALLOWED_ORIGINS.split(",") 5 | : ["localhost"]; 6 | export const LOG_LEVEL = process.env.LOG_LEVEL || "info"; 7 | export const API_URL = process.env.API_URL || "http://localhost:8000"; 8 | export const POSTGRES_URL = 9 | process.env.POSTGRES_URL || "postgres://localhost/noctf"; 10 | export const NATS_URL = process.env.NATS_URL || "nats://localhost:4222"; 11 | 12 | export const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; 13 | 14 | export const TOKEN_SECRET = process.env.TOKEN_SECRET || ""; 15 | 16 | export const ENABLE_COMPRESSION = ["1", "true"].includes( 17 | (process.env.ENABLE_COMPRESSION || "").toLowerCase(), 18 | ); 19 | export const ENABLE_HTTP2 = ["1", "true"].includes( 20 | (process.env.ENABLE_HTTP2 || "").toLowerCase(), 21 | ); 22 | export const ENABLE_SWAGGER = ["1", "true"].includes( 23 | (process.env.ENABLE_SWAGGER || "").toLowerCase(), 24 | ); 25 | export const DISABLE_RATE_LIMIT = ["1", "true"].includes( 26 | (process.env.DISABLE_RATE_LIMIT || "").toLowerCase(), 27 | ); 28 | 29 | export const FILE_LOCAL_PATH = process.env.FILE_LOCAL_PATH || "files/"; 30 | 31 | export const METRICS_PATH = process.env.METRICS_PATH; 32 | export const METRICS_FILE_NAME_FORMAT = 33 | process.env.METRICS_FILE_NAME_FORMAT || "metrics-%Y-%m-%d-%H.log"; 34 | -------------------------------------------------------------------------------- /core/api/src/token.ts: -------------------------------------------------------------------------------- 1 | import type { Static } from "@sinclair/typebox"; 2 | import { Type } from "@sinclair/typebox"; 3 | import { TypeDate } from "./datatypes.ts"; 4 | 5 | export const AuthTokenType = Type.Enum({ 6 | Session: "session", 7 | Scoped: "scoped", 8 | }); 9 | export type AuthTokenType = Static; 10 | 11 | export const AuthToken = Type.Object({ 12 | sid: Type.String(), 13 | sub: Type.String(), 14 | exp: Type.Integer(), 15 | app: Type.Optional(Type.String()), 16 | scopes: Type.Optional(Type.Array(Type.String())), 17 | }); 18 | export type AuthToken = Static; 19 | 20 | export const RegisterTokenData = Type.Object({ 21 | identity: Type.Array( 22 | Type.Object({ 23 | provider: Type.String(), 24 | provider_id: Type.String(), 25 | secret_data: Type.Optional(Type.String()), 26 | }), 27 | ), 28 | flags: Type.Optional(Type.Array(Type.String())), 29 | roles: Type.Optional(Type.Array(Type.String())), 30 | }); 31 | export type RegisterTokenData = Static; 32 | 33 | export const AssociateTokenData = Type.Composite([ 34 | RegisterTokenData, 35 | Type.Object({ 36 | user_id: Type.Integer(), 37 | }), 38 | ]); 39 | export type AssociateTokenData = Static; 40 | 41 | export const ResetPasswordTokenData = Type.Object({ 42 | user_id: Type.Integer(), 43 | created_at: TypeDate, 44 | }); 45 | export type ResetPasswordTokenData = Static; 46 | -------------------------------------------------------------------------------- /core/mod-auth/src/identity_routes.ts: -------------------------------------------------------------------------------- 1 | import { AssociateRequest } from "@noctf/api/requests"; 2 | import { SuccessResponse } from "@noctf/api/responses"; 3 | import { FastifyInstance } from "fastify"; 4 | import { ForbiddenError } from "@noctf/server-core/errors"; 5 | 6 | export default async function (fastify: FastifyInstance) { 7 | const { identityService, tokenService } = fastify.container.cradle; 8 | 9 | fastify.post<{ 10 | Body: AssociateRequest; 11 | Reply: SuccessResponse; 12 | }>( 13 | "/auth/associate", 14 | { 15 | schema: { 16 | security: [{ bearer: [] }], 17 | tags: ["auth"], 18 | auth: { 19 | require: true, 20 | policy: ["user.self.update"], 21 | }, 22 | body: AssociateRequest, 23 | response: { 24 | 200: SuccessResponse, 25 | }, 26 | }, 27 | }, 28 | async (request) => { 29 | const { token } = request.body; 30 | const data = await tokenService.lookup("associate", token); 31 | if (request.user.id !== data.user_id) { 32 | throw new ForbiddenError("Invalid token"); 33 | } 34 | await identityService.associateIdentities( 35 | data.identity.map((i) => ({ 36 | user_id: request.user.id, 37 | ...i, 38 | })), 39 | ); 40 | // TODO: commit flags and roles in tx 41 | await tokenService.invalidate("associate", token); 42 | return { data: true as const }; // wtf ts 43 | }, 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /core/server-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/server-core", 3 | "version": "0.1.0", 4 | "description": "core noCTF internal APIs", 5 | "scripts": { 6 | "test:unit": "vitest --coverage", 7 | "build": "tsc", 8 | "release": "pnpm run build && pnpm run test:unit --run", 9 | "dev": "echo 'Nothing to do' && exit" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@isaacs/ttlcache": "^1.4.1", 16 | "@noctf/api": "workspace:*", 17 | "@noctf/schema": "workspace:*", 18 | "@sinclair/typebox": "catalog:", 19 | "ajv": "catalog:", 20 | "ajv-formats": "catalog:", 21 | "awilix": "catalog:", 22 | "cbor-x": "catalog:", 23 | "expr-eval": "catalog:", 24 | "fastify": "catalog:", 25 | "handlebars": "catalog:", 26 | "ip-address": "^10.0.1", 27 | "jose": "^6.0.11", 28 | "ky": "catalog:", 29 | "kysely": "catalog:", 30 | "mime-types": "^2.1.35", 31 | "minio": "^8.0.5", 32 | "nanoid": "catalog:", 33 | "nats": "^2.28.2", 34 | "nodemailer": "^6.10.0", 35 | "p-limit": "catalog:", 36 | "redis": "^4.7.0" 37 | }, 38 | "devDependencies": { 39 | "@types/mime-types": "^2.1.4", 40 | "@types/nodemailer": "^6.4.17", 41 | "@vitest/coverage-v8": "catalog:", 42 | "typescript": "catalog:", 43 | "vitest": "catalog:", 44 | "vitest-mock-extended": "catalog:" 45 | }, 46 | "type": "module", 47 | "exports": { 48 | ".": "./src/index.ts", 49 | "./*": "./src/*.ts" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Images 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Log in to Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Build images with Docker Compose 33 | run: | 34 | docker compose build 35 | 36 | - name: Extract metadata for built images 37 | id: docker-metadata 38 | run: | 39 | BUILT_SERVICES=`yq -o=json -I=0 '([.services[] | select(has("build") and has("image")) | .image] | unique)' docker-compose.yml` 40 | 41 | echo "built_services=$BUILT_SERVICES" >> $GITHUB_OUTPUT 42 | echo "Built services: $BUILT_SERVICES" 43 | 44 | - name: Output pushed images 45 | env: 46 | BUILT_SERVICES: "${{steps.docker-metadata.outputs.built_services}}" 47 | run: | 48 | for image in `echo "$BUILT_SERVICES" | jq -cr '.[]'` 49 | do 50 | docker push "$image" & 51 | done 52 | wait -------------------------------------------------------------------------------- /apps/server/esbuild.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import * as path from "node:path"; 3 | import * as fsp from "node:fs/promises"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const nodeModules = new RegExp(/^(?:.*[\\/])?node_modules(?:[\\/].*)?$/); 7 | const root = path.dirname(fileURLToPath(import.meta.url)); 8 | const output = process.argv[2] || root; 9 | 10 | const dirnamePlugin = { 11 | name: "dirname", 12 | setup(build) { 13 | build.onLoad({ filter: /\.[tj]s$/ }, async ({ path: filePath }) => { 14 | if (filePath.match(nodeModules)) { 15 | let contents = await fsp.readFile(filePath, "utf8"); 16 | const loader = path.extname(filePath).substring(1); 17 | const dirname = path.dirname(filePath); 18 | const rel = path.relative("../..", path.relative(root, dirname)); 19 | contents = `var __dirname = "${path.join(output, rel)}";\n` + contents; 20 | return { 21 | contents, 22 | loader, 23 | }; 24 | } 25 | }); 26 | }, 27 | }; 28 | 29 | // TODO: this generates 5mb bundles for each app which reuses mostly the same code 30 | // Can minify although not too much of a big deal as this is serverside 31 | await esbuild.build({ 32 | entryPoints: [ 33 | "src/entrypoints/www.ts", 34 | "src/entrypoints/worker.ts", 35 | "src/entrypoints/shell.ts", 36 | ], 37 | bundle: true, 38 | outdir: "dist/", 39 | outExtension: { ".js": ".cjs" }, 40 | platform: "node", 41 | format: "cjs", 42 | plugins: [dirnamePlugin], 43 | }); 44 | -------------------------------------------------------------------------------- /core/mod-tickets/src/schema/datatypes.ts: -------------------------------------------------------------------------------- 1 | import { TypeDate } from "@noctf/api/datatypes"; 2 | import type { Static } from "@sinclair/typebox"; 3 | import { Type } from "@sinclair/typebox"; 4 | 5 | export enum TicketState { 6 | Open = "open", 7 | Closed = "closed", 8 | Created = "created", 9 | } 10 | 11 | export const Ticket = Type.Object({ 12 | id: Type.Number(), 13 | state: Type.Enum(TicketState), 14 | team_id: Type.Optional(Type.Number()), 15 | user_id: Type.Optional(Type.Number()), 16 | assignee_id: Type.Union([Type.Number(), Type.Null()]), 17 | category: Type.String(), 18 | item: Type.String(), 19 | provider: Type.String(), 20 | provider_id: Type.Union([Type.String(), Type.Null()]), 21 | provider_metadata: Type.Union([ 22 | Type.Record(Type.String(), Type.String()), 23 | Type.Null(), 24 | ]), 25 | created_at: TypeDate, 26 | }); 27 | export type Ticket = Static; 28 | 29 | export const UpdateTicket = Type.Omit(Type.Partial(Ticket), [ 30 | "id", 31 | "created_at", 32 | ]); 33 | export type UpdateTicket = Static; 34 | 35 | export const TicketStateMessage = Type.Object({ 36 | lease: Type.String(), 37 | desired_state: Type.Enum(TicketState), 38 | id: Type.Number(), 39 | }); 40 | export type TicketStateMessage = Static; 41 | 42 | export const TicketApplyMessage = Type.Object({ 43 | lease: Type.String(), 44 | properties: Type.Partial(Ticket), 45 | id: Type.Number(), 46 | }); 47 | export type TicketApplyMessage = Static; 48 | -------------------------------------------------------------------------------- /apps/server/src/routes/_util.ts: -------------------------------------------------------------------------------- 1 | import { SetupConfig } from "@noctf/api/config"; 2 | import { ServiceCradle } from "@noctf/server-core"; 3 | import { ForbiddenError } from "@noctf/server-core/errors"; 4 | import { LocalCache } from "@noctf/server-core/util/local_cache"; 5 | import { Policy } from "@noctf/server-core/util/policy"; 6 | 7 | // TODO: this is better as middleware 8 | export const GetUtils = ({ policyService, configService }: ServiceCradle) => { 9 | const adminCache = new LocalCache({ ttl: 1000, max: 5000 }); 10 | const gateStartTime = async ( 11 | policy: Policy, 12 | ctime: number, 13 | userId?: number, 14 | ) => { 15 | const admin = await adminCache.load(userId || 0, () => 16 | policyService.evaluate(userId || 0, policy), 17 | ); 18 | if (!admin) { 19 | const { 20 | value: { active, start_time_s }, 21 | } = await configService.get(SetupConfig); 22 | if (!active) { 23 | throw new ForbiddenError("The CTF is not currently active"); 24 | } 25 | if (ctime < start_time_s * 1000) { 26 | throw new ForbiddenError("The CTF has not started yet"); 27 | } 28 | } 29 | return admin; 30 | }; 31 | 32 | const getMaxPageSize = async ( 33 | policy: Policy, 34 | uid?: number, 35 | default_size = 50, 36 | max_size = Number.MAX_SAFE_INTEGER, 37 | ) => { 38 | return (await policyService.evaluate(uid, policy)) 39 | ? max_size 40 | : default_size; 41 | }; 42 | 43 | return { gateStartTime, getMaxPageSize }; 44 | }; 45 | -------------------------------------------------------------------------------- /apps/server/src/util/domain.ts: -------------------------------------------------------------------------------- 1 | export type DomainPattern = string | string[]; 2 | 3 | export type DomainMatcher = (domain: string) => boolean; 4 | 5 | /** 6 | * Converts a single glob pattern to a regex pattern string 7 | * @param pattern - The glob pattern to convert 8 | * @returns The regex pattern string 9 | */ 10 | function GlobToRegexPattern(pattern: string): string { 11 | return pattern 12 | .replace(/\./g, "\\.") // Escape dots 13 | .replace(/\*/g, "([^.]+)") // Replace * with regex for "any characters except dot" 14 | .replace(/\\\.\\\./g, "\\."); // Clean up any double dots that might appear 15 | } 16 | 17 | /** 18 | * Create a compiled regex from multiple patterns for efficient matching 19 | * @param patterns - List of glob patterns or a single pattern 20 | * @returns Combined regex that matches any of the patterns 21 | */ 22 | export function CompileDomainMatcher(patterns: string | string[]): RegExp { 23 | if (typeof patterns === "string") { 24 | const regexPattern = GlobToRegexPattern(patterns.toLowerCase()); 25 | return new RegExp(`^(http|https)://${regexPattern}(:[0-9]+)?$`); 26 | } 27 | 28 | const regexPatterns = patterns.map((pattern) => { 29 | pattern = pattern.toLowerCase(); 30 | 31 | if (!pattern.includes("*")) { 32 | return pattern.replace(/\./g, "\\."); 33 | } 34 | 35 | return GlobToRegexPattern(pattern); 36 | }); 37 | 38 | const combinedPattern = regexPatterns.map((p) => `(${p})`).join("|"); 39 | return new RegExp(`^(http|https)://(?:${combinedPattern})(:[0-9]+)?$`); 40 | } 41 | -------------------------------------------------------------------------------- /migrations/1744115051874_team_tag.ts: -------------------------------------------------------------------------------- 1 | import { sql, type Kysely } from "kysely"; 2 | import { 3 | CreateTableWithDefaultTimestamps, 4 | CreateTriggerUpdatedAt, 5 | } from "./util"; 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | export async function up(db: Kysely): Promise { 9 | const schema = db.schema; 10 | await CreateTableWithDefaultTimestamps(schema, "team_tag") 11 | .addColumn("id", "integer", (col) => 12 | col.primaryKey().generatedByDefaultAsIdentity(), 13 | ) 14 | .addColumn("name", "varchar(64)", (col) => col.notNull().unique()) 15 | .addColumn("description", "text", (col) => col.notNull().defaultTo("")) 16 | .addColumn("is_joinable", "boolean", (col) => 17 | col.notNull().defaultTo(false), 18 | ) 19 | .execute(); 20 | 21 | await CreateTriggerUpdatedAt("team_tag").execute(db); 22 | 23 | await CreateTableWithDefaultTimestamps(schema, "team_tag_member", [ 24 | "created_at", 25 | ]) 26 | .addColumn("tag_id", "integer", (e) => 27 | e.notNull().references("team_tag.id").onDelete("cascade"), 28 | ) 29 | .addColumn("team_id", "integer", (e) => 30 | e.notNull().references("team.id").onDelete("cascade"), 31 | ) 32 | .addUniqueConstraint("team_tag_member_uidx_tag_id_team_id", [ 33 | "tag_id", 34 | "team_id", 35 | ]) 36 | .execute(); 37 | } 38 | 39 | export async function down(db: Kysely): Promise { 40 | await db.schema.dropTable("team_tag_member").execute(); 41 | await db.schema.dropTable("team_tag").execute(); 42 | } 43 | -------------------------------------------------------------------------------- /core/server-core/src/dao/award.ts: -------------------------------------------------------------------------------- 1 | import { DBType } from "../clients/database.ts"; 2 | import { Award } from "@noctf/api/datatypes"; 3 | 4 | export class AwardDAO { 5 | constructor(private readonly db: DBType) {} 6 | 7 | async getTeamAwards( 8 | team_id: number, 9 | params?: { 10 | end_time?: Date; 11 | }, 12 | ): Promise { 13 | let query = this.getBaseQuery().where("team_id", "=", team_id); 14 | if (params?.end_time) { 15 | query = query.where("award.created_at", "<=", params.end_time); 16 | } 17 | return (await query.execute()) as unknown as Award[]; 18 | } 19 | 20 | async getAllAwards( 21 | division_id?: number, 22 | params?: { 23 | sort?: "asc" | "desc"; 24 | limit?: number; 25 | offset?: number; 26 | }, 27 | ): Promise { 28 | let query = this.getBaseQuery(); 29 | if (division_id) { 30 | query = query.where("team.division_id", "=", division_id); 31 | } 32 | if (params?.limit) { 33 | query = query.limit(params.limit); 34 | } 35 | if (params?.offset) { 36 | query = query.offset(params.offset); 37 | } 38 | return await query.execute(); 39 | } 40 | 41 | private getBaseQuery() { 42 | return this.db 43 | .selectFrom("award") 44 | .innerJoin("team", "team.id", "award.team_id") 45 | .select([ 46 | "award.id as id", 47 | "award.team_id as team_id", 48 | "award.value as value", 49 | "award.title as title", 50 | "award.created_at as created_at", 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/web", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 | }, 13 | "devDependencies": { 14 | "@iconify/svelte": "^4.2.0", 15 | "@noctf/openapi-spec": "workspace:", 16 | "@sveltejs/adapter-static": "^3.0.8", 17 | "@sveltejs/kit": "^2.0.0", 18 | "@sveltejs/vite-plugin-svelte": "^4.0.0", 19 | "autoprefixer": "^10.4.20", 20 | "daisyui": "^4.12.23", 21 | "eslint": "^9.7.0", 22 | "globals": "^15.0.0", 23 | "svelte": "^5.0.0", 24 | "svelte-check": "^4.0.0", 25 | "svelte-turnstile": "^0.11.0", 26 | "tailwindcss": "^3.4.9", 27 | "typescript": "^5.0.0", 28 | "vite": "^5.4.19" 29 | }, 30 | "dependencies": { 31 | "@tailwindcss/container-queries": "^0.1.1", 32 | "@tailwindcss/forms": "^0.5.9", 33 | "@tailwindcss/typography": "^0.5.15", 34 | "ajv-formats": "^3.0.1", 35 | "axios": "^1.7.9", 36 | "carta-md": "^4.6.7", 37 | "chart.js": "^4.4.8", 38 | "chartjs-adapter-date-fns": "^3.0.0", 39 | "date-fns": "^4.1.0", 40 | "expr-eval": "catalog:", 41 | "i18n-iso-countries": "^7.14.0", 42 | "isomorphic-dompurify": "^2.25.0", 43 | "lru-cache": "^11.0.2", 44 | "openapi-fetch": "^0.14.0", 45 | "svelte-jsoneditor": "^3.6.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import eslintConfigPrettier from "eslint-config-prettier"; 5 | import tsParser from "@typescript-eslint/parser"; 6 | import eslintPluginSvelte from "eslint-plugin-svelte"; 7 | import svelteParser from "svelte-eslint-parser"; 8 | 9 | export default [ 10 | { 11 | ignores: ["*/*/dist/**", "core/api/codegen/**", "**/.svelte-kit/"], 12 | }, 13 | { 14 | files: ["**/*.{js,mjs,cjs,ts}"], 15 | }, 16 | { 17 | languageOptions: { 18 | globals: { 19 | ...globals.browser, 20 | ...globals.node, 21 | }, 22 | }, 23 | }, 24 | ...eslintPluginSvelte.configs["flat/recommended"], 25 | pluginJs.configs.recommended, 26 | ...tseslint.configs.recommended, 27 | eslintConfigPrettier, 28 | { 29 | languageOptions: { 30 | parser: tsParser, 31 | }, 32 | rules: { 33 | "no-case-declarations": "off", 34 | "@typescript-eslint/no-unused-vars": [ 35 | "error", 36 | { 37 | argsIgnorePattern: "^_", 38 | varsIgnorePattern: "^_", 39 | caughtErrorsIgnorePattern: "^_", 40 | }, 41 | ], 42 | }, 43 | }, 44 | { 45 | files: ["**/*.svelte"], 46 | ignores: ["**/.svelte-kit/", "**/dist/index.d.ts"], 47 | 48 | languageOptions: { 49 | parser: svelteParser, 50 | parserOptions: { 51 | parser: { 52 | ts: tsParser, 53 | typescript: tsParser, 54 | }, 55 | }, 56 | }, 57 | }, 58 | ]; 59 | -------------------------------------------------------------------------------- /core/mod-captcha/src/provider.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "@noctf/server-core/errors"; 2 | import ky from "ky"; 3 | 4 | export interface CaptchaProvider { 5 | id(): string; 6 | validate( 7 | privateKey: string, 8 | response: string, 9 | clientIp?: string, 10 | ): Promise; 11 | } 12 | 13 | export abstract class BaseSiteVerifyCaptchaProvider implements CaptchaProvider { 14 | constructor( 15 | private readonly _id: string, 16 | private readonly verifyURL: string, 17 | ) {} 18 | 19 | async validate( 20 | privateKey: string, 21 | response: string, 22 | clientIp: string, 23 | ): Promise { 24 | const result = await ky 25 | .post(this.verifyURL, { 26 | body: new URLSearchParams({ 27 | response, 28 | remoteip: clientIp, 29 | secret: privateKey, 30 | }), 31 | }) 32 | .json<{ success: boolean; challenge_ts: string }>(); 33 | if (!result.success) { 34 | throw new ValidationError("CAPTCHA failed validation"); 35 | } 36 | return new Date(result.challenge_ts).valueOf(); 37 | } 38 | 39 | id() { 40 | return this._id; 41 | } 42 | } 43 | 44 | export class HCaptchaProvider extends BaseSiteVerifyCaptchaProvider { 45 | constructor() { 46 | super("hcaptcha", "https://api.hcaptcha.com/siteverify"); 47 | } 48 | } 49 | 50 | export class CloudflareCaptchaProvider extends BaseSiteVerifyCaptchaProvider { 51 | constructor() { 52 | super( 53 | "cloudflare", 54 | "https://challenges.cloudflare.com/turnstile/v0/siteverify", 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /core/server-core/src/services/audit_log.ts: -------------------------------------------------------------------------------- 1 | import type { AuditLogEntry } from "@noctf/api/datatypes"; 2 | import type { QueryAuditLogRequest } from "@noctf/api/requests"; 3 | import type { ServiceCradle } from "../index.ts"; 4 | import type { AuditLogActor } from "../types/audit_log.ts"; 5 | import { ActorType } from "../types/enums.ts"; 6 | import { AuditLogDAO } from "../dao/audit_log.ts"; 7 | import { LimitCursorEncoded, PaginationCursor } from "../types/pagination.ts"; 8 | 9 | type Props = Pick; 10 | 11 | export const SYSTEM_ACTOR: AuditLogActor = { 12 | type: ActorType.SYSTEM, 13 | }; 14 | 15 | export class AuditLogService { 16 | private readonly dao; 17 | private readonly databaseClient; 18 | 19 | constructor({ databaseClient }: Props) { 20 | this.databaseClient = databaseClient; 21 | this.dao = new AuditLogDAO(databaseClient.get()); 22 | } 23 | 24 | async log(v: { 25 | operation: string; 26 | actor?: AuditLogActor; 27 | entities?: string[]; 28 | data?: string; 29 | }) { 30 | const { type, id } = v.actor || SYSTEM_ACTOR; 31 | return this.dao.create({ 32 | operation: v.operation, 33 | data: v.data || null, 34 | entities: v.entities || [], 35 | actor: id ? `${type}:${id}` : type, 36 | }); 37 | } 38 | 39 | async query( 40 | q: Omit, 41 | limit?: number, 42 | ): Promise { 43 | return this.dao.query(q, limit); 44 | } 45 | 46 | async getCount(q: Omit) { 47 | return this.dao.getCount(q); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/mod-auth/src/hash_util.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Generate, Validate } from "./hash_util.ts"; 3 | 4 | test("a correct password validates successfully", async () => { 5 | const digest = await Generate("123456"); 6 | expect(await Validate("123456", digest)).toBe(true); 7 | expect(await Validate("1234567", digest)).toBe(false); 8 | }); 9 | 10 | test("a static scrypt digest validates successfully", async () => { 11 | const hash = 12 | "$scrypt$N=16384,r=8,p=1$mYpsjTuWJbNT4+KWflCG5Q==$" + 13 | "nHvbcPbf5f+j9lIAGCApEz2StBnzqQ+cb+bUarYI21gj0Fk2HEiHpZctYPBVQcfpPuzRqlmQtO0CmyFP1pFc0A=="; 14 | expect(await Validate("123456", hash)).toBe(true); 15 | expect(await Validate("1234567", hash)).toBe(false); 16 | }); 17 | 18 | test("a static scrypt digest with invalid parameters throws an error", async () => { 19 | const hash = 20 | "$scrypt$N=16384,r=8,p=0$mYpsjTuWJbNT4+KWflCG5Q==$" + 21 | "nHvbcPbf5f+j9lIAGCApEz2StBnzqQ+cb+bUarYI21gj0Fk2HEiHpZctYPBVQcfpPuzRqlmQtO0CmyFP1pFc0A=="; 22 | expect(Validate("123456", hash)).rejects.toThrowError(); 23 | }); 24 | 25 | test("a static scrypt digest with missing parameters throws an error", async () => { 26 | const hash = 27 | "$scrypt$N=16384,r=8$mYpsjTuWJbNT4+KWflCG5Q==$" + 28 | "nHvbcPbf5f+j9lIAGCApEz2StBnzqQ+cb+bUarYI21gj0Fk2HEiHpZctYPBVQcfpPuzRqlmQtO0CmyFP1pFc0A=="; 29 | expect(Validate("123456", hash)).rejects.toThrowError(); 30 | }); 31 | 32 | test("a invalid digest type throws an error", async () => { 33 | expect(Validate("123456", "$nonexistent$asdadsasd$")).rejects.toThrowError(); 34 | expect(Validate("123456", "!test!aaa!b")).rejects.toThrowError(); 35 | }); 36 | -------------------------------------------------------------------------------- /migrations/1733650351430_challenge.ts: -------------------------------------------------------------------------------- 1 | import { sql, type Kysely } from "kysely"; 2 | import { 3 | CreateTableWithDefaultTimestamps, 4 | CreateTriggerUpdatedAt, 5 | } from "./util"; 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | export async function up(db: Kysely): Promise { 9 | const schema = db.schema; 10 | 11 | await CreateTableWithDefaultTimestamps(schema, "challenge") 12 | .addColumn("id", "integer", (col) => 13 | col.primaryKey().generatedByDefaultAsIdentity(), 14 | ) 15 | .addColumn("slug", "varchar(64)", (col) => col.notNull().unique()) 16 | .addColumn("title", "varchar(128)", (col) => col.notNull()) 17 | .addColumn("description", "text", (col) => col.notNull()) 18 | .addColumn("private_metadata", "jsonb", (col) => col.notNull()) 19 | .addColumn("tags", "jsonb", (col) => col.notNull().defaultTo("{}")) 20 | .addColumn("hidden", "boolean", (col) => col.notNull().defaultTo(false)) 21 | .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) 22 | .addColumn("visible_at", "timestamptz") 23 | .execute(); 24 | await CreateTriggerUpdatedAt("challenge").execute(db); 25 | 26 | await schema 27 | .createIndex("challenge_idx_tags") 28 | .on("challenge") 29 | .using("gin") 30 | .column("tags") 31 | .execute(); 32 | 33 | await schema 34 | .createIndex("challenge_idx_hidden_visible_at") 35 | .on("challenge") 36 | .columns(["hidden", "visible_at"]) 37 | .execute(); 38 | } 39 | 40 | export async function down(db: Kysely): Promise { 41 | const schema = db.schema; 42 | 43 | await schema.dropTable("challenge").execute(); 44 | } 45 | -------------------------------------------------------------------------------- /core/server-core/src/services/email/nodemailer.ts: -------------------------------------------------------------------------------- 1 | import { createTransport, Transporter } from "nodemailer"; 2 | import { ServiceCradle } from "../../index.ts"; 3 | import { EmailPayload, EmailProvider } from "./types.ts"; 4 | import { EmailConfig } from "@noctf/api/config"; 5 | 6 | type Props = Pick; 7 | 8 | export class NodeMailerProvider implements EmailProvider { 9 | public readonly name = "nodemailer"; 10 | public readonly queued = true; 11 | 12 | private readonly configService; 13 | 14 | private configVersion: number; 15 | private transport: Transporter; 16 | 17 | constructor({ configService }: Props) { 18 | this.configService = configService; 19 | } 20 | 21 | async send(payload: EmailPayload): Promise { 22 | const transport = await this.getTransport(); 23 | await transport.sendMail({ 24 | from: payload.from, 25 | to: payload.to, 26 | replyTo: payload.replyTo, 27 | cc: payload.cc, 28 | bcc: payload.bcc, 29 | subject: payload.subject, 30 | text: payload.text, 31 | }); 32 | } 33 | 34 | async validate(config: Parameters[0]): Promise { 35 | const transport = createTransport(config); 36 | await transport.verify(); 37 | } 38 | 39 | private async getTransport() { 40 | const data = (await this.configService.get(EmailConfig)) || ({} as any); 41 | if (!this.transport || this.configVersion !== data.version) { 42 | if (this.transport) this.transport.close(); 43 | this.transport = createTransport(data.value.config); 44 | this.configVersion = data.version; 45 | } 46 | return this.transport; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/server/src/hooks/rate_limit.ts: -------------------------------------------------------------------------------- 1 | import { TooManyRequestsError } from "@noctf/server-core/errors"; 2 | import { RateLimitBucket } from "@noctf/server-core/services/rate_limit"; 3 | import { NormalizeIPPrefix } from "@noctf/server-core/util/limit_keys"; 4 | import { FastifyReply, FastifyRequest } from "fastify"; 5 | import { DISABLE_RATE_LIMIT } from "../config.ts"; 6 | 7 | const DEFAULT_CONFIG = (r: FastifyRequest) => ({ 8 | key: 9 | r.routeOptions.url && 10 | `all:${r.user ? "u" + r.user.id + (r.user.app ? "a" + r.user.app : "") : "i" + NormalizeIPPrefix(r.ip)}`, 11 | limit: r.user ? 200 : 500, 12 | windowSeconds: 60, 13 | }); 14 | 15 | export const RateLimitHook = async ( 16 | request: FastifyRequest, 17 | reply: FastifyReply, 18 | ) => { 19 | if (DISABLE_RATE_LIMIT) return; 20 | const { rateLimitService, policyService } = request.server.container.cradle; 21 | if (await policyService.evaluate(request.user?.id, ["bypass.rate_limit"])) { 22 | return; 23 | } 24 | const config = request.routeOptions.schema?.rateLimit || DEFAULT_CONFIG; 25 | let buckets: RateLimitBucket[] = []; 26 | if (typeof config === "function") { 27 | const derived = await config(request); 28 | if (Array.isArray(derived)) { 29 | buckets = derived.filter((x) => x); 30 | } else { 31 | buckets.push(derived); 32 | } 33 | } else if (Array.isArray(config)) { 34 | buckets = config; 35 | } else { 36 | buckets.push(config); 37 | } 38 | 39 | const next = await rateLimitService.evaluate(buckets); 40 | if (next) { 41 | reply.header("retry-after", Math.ceil(next / 1000)); 42 | throw new TooManyRequestsError("You're trying too hard"); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /apps/web/src/lib/api/index.svelte.ts: -------------------------------------------------------------------------------- 1 | import createClient, { type Middleware } from "openapi-fetch"; 2 | import type { paths } from "@noctf/openapi-spec"; 3 | 4 | import { IS_STATIC_EXPORT, staticHandler } from "$lib/static_export/middleware"; 5 | 6 | export const SESSION_TOKEN_KEY = "noctf-session-token"; 7 | 8 | export const API_BASE_URL = 9 | import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; 10 | const client = createClient({ 11 | baseUrl: API_BASE_URL, 12 | fetch: IS_STATIC_EXPORT ? (r) => staticHandler.handleRequest(r) : undefined, 13 | }); 14 | 15 | const authMiddleware: Middleware = { 16 | async onRequest({ request }) { 17 | // TODO: make this into a module/service 18 | const token = localStorage.getItem(SESSION_TOKEN_KEY); 19 | if (token) request.headers.set("authorization", `Bearer ${token}`); 20 | return request; 21 | }, 22 | }; 23 | 24 | if (!IS_STATIC_EXPORT) { 25 | client.use(authMiddleware); 26 | } 27 | 28 | type Loadable = 29 | | { 30 | loading: true; 31 | error: undefined; 32 | r: undefined; 33 | } 34 | | { 35 | loading: false; 36 | error: boolean; 37 | r: T; 38 | }; 39 | export function wrapLoadable(p: Promise): Loadable { 40 | const s = $state>({ 41 | loading: true, 42 | error: undefined, 43 | r: undefined, 44 | }); 45 | p.then( 46 | (r) => { 47 | s.loading = false; 48 | s.r = r; 49 | }, 50 | (e) => { 51 | s.loading = false; 52 | s.error = true; 53 | console.error(e); 54 | }, 55 | ).finally(() => { 56 | s.loading = false; 57 | }); 58 | return s; 59 | } 60 | 61 | export default client; 62 | -------------------------------------------------------------------------------- /core/server-core/src/dao/file.ts: -------------------------------------------------------------------------------- 1 | import { DBType } from "../clients/database.ts"; 2 | import { NotFoundError } from "../errors.ts"; 3 | 4 | export type DBFile = { 5 | id: number; 6 | filename: string; 7 | provider: string; 8 | hash: Buffer; 9 | size: number; // this will not exceed 2^53 bits or w/e 10 | ref: string; 11 | mime: string; 12 | created_at: Date; 13 | }; 14 | 15 | export class FileDAO { 16 | constructor(private readonly db: DBType) {} 17 | 18 | async get(id: number): Promise { 19 | const result = await this.db 20 | .selectFrom("file") 21 | .select([ 22 | "id", 23 | "created_at", 24 | "filename", 25 | "ref", 26 | "provider", 27 | "hash", 28 | "size", 29 | "mime", 30 | ]) 31 | .where("id", "=", id) 32 | .executeTakeFirst(); 33 | if (!result) throw new NotFoundError("File not found"); 34 | return { 35 | ...result, 36 | size: parseInt(result.size), 37 | }; 38 | } 39 | 40 | async delete(id: number): Promise { 41 | await this.db.deleteFrom("file").where("id", "=", id).executeTakeFirst(); 42 | } 43 | 44 | async create(f: Omit): Promise { 45 | const v: typeof f = { 46 | filename: f.filename, 47 | ref: f.ref, 48 | provider: f.provider, 49 | hash: f.hash, 50 | size: f.size, 51 | mime: f.mime, 52 | }; 53 | const result = await this.db 54 | .insertInto("file") 55 | .values(v) 56 | .returning(["id", "created_at"]) 57 | .executeTakeFirstOrThrow(); 58 | return { ...v, id: result.id, created_at: result.created_at }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/server/src/hooks/authn.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from "fastify"; 2 | import type { AuthToken } from "@noctf/api/token"; 3 | import { 4 | AuthenticationError, 5 | TokenValidationError, 6 | } from "@noctf/server-core/errors"; 7 | import { CreateThenable } from "@noctf/server-core/util/promises"; 8 | 9 | export const AuthnHook = async (request: FastifyRequest) => { 10 | const { require, scopes } = request.routeOptions.schema?.auth || {}; 11 | let token = ""; 12 | if ( 13 | request.headers["authorization"] && 14 | request.headers["authorization"].startsWith("Bearer ") 15 | ) { 16 | token = request.headers["authorization"].substring(7); 17 | } 18 | if (!token && require) { 19 | throw new AuthenticationError("no authorization header supplied"); 20 | } else if (!token && !require) { 21 | return; 22 | } 23 | 24 | const { identityService, teamService } = request.server.container.cradle; 25 | let tokenData: AuthToken; 26 | try { 27 | tokenData = await identityService.validateToken(token); 28 | 29 | const id = +tokenData.sub; 30 | const app = tokenData.app ? +tokenData.app : undefined; 31 | request.user = { 32 | app, 33 | id, 34 | token, 35 | membership: CreateThenable(() => teamService.getMembershipForUser(id)), 36 | }; 37 | } catch (e) { 38 | if (!require && e instanceof TokenValidationError) { 39 | return; 40 | } 41 | throw e; 42 | } 43 | 44 | if (!scopes || !scopes.size || !tokenData.scopes) { 45 | return; 46 | } 47 | 48 | if (!tokenData.scopes.some((s) => scopes.has(s))) { 49 | throw new AuthenticationError( 50 | `scoped token is not authorised to access this endpoint`, 51 | ); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /core/mod-auth/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ListAuthMethodsResponse } from "@noctf/api/responses"; 2 | import { DEFAULT_CONFIG } from "./const.ts"; 3 | import identity_routes from "./identity_routes.ts"; 4 | import password_routes from "./password_routes.ts"; 5 | import oauth_routes from "./oauth_routes.ts"; 6 | import register_routes from "./register_routes.ts"; 7 | import type { FastifyInstance } from "fastify/types/instance.js"; 8 | import { AuthConfig } from "@noctf/api/config"; 9 | import "@noctf/server-core/types/fastify"; 10 | 11 | export async function initServer(fastify: FastifyInstance) { 12 | const { identityService, configService, logger } = fastify.container.cradle; 13 | await configService.register(AuthConfig, DEFAULT_CONFIG); 14 | 15 | fastify.register(identity_routes); 16 | fastify.register(oauth_routes); 17 | fastify.register(password_routes); 18 | fastify.register(register_routes); 19 | 20 | fastify.get<{ 21 | Reply: ListAuthMethodsResponse; 22 | }>( 23 | "/auth/methods", 24 | { 25 | schema: { 26 | tags: ["auth"], 27 | }, 28 | }, 29 | async () => { 30 | const methods = await identityService.listMethods(); 31 | return { data: methods }; 32 | }, 33 | ); 34 | 35 | fastify.post( 36 | "/auth/logout", 37 | { 38 | schema: { 39 | tags: ["auth"], 40 | security: [{ bearer: [] }], 41 | auth: { 42 | require: true, 43 | }, 44 | }, 45 | }, 46 | async (request) => { 47 | try { 48 | await identityService.revokeToken(request.user.token); 49 | } catch (e) { 50 | logger.warn("failed to revoke session token", e); 51 | } 52 | return {}; 53 | }, 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /apps/web/src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 |
26 |
27 |
28 |
404
29 | 30 |

Page Not Found

31 | 32 |

33 | The page you're looking for doesn't exist or has been moved. 34 |

35 | 36 |
39 | {currentPath} 40 |
41 | 42 |
43 | {#if canGoBack} 44 | 50 | {/if} 51 | 54 |
55 |
56 |
57 |
58 |
59 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | PROJECT_NAME=noctf_dev 3 | 4 | get-ip() { 5 | docker inspect "$PROJECT_NAME-$1-1" \ 6 | --format '{{ (index .NetworkSettings.Networks "'$PROJECT_NAME'_default").IPAddress }}' 7 | } 8 | 9 | start() { 10 | docker compose -f docker-compose.dev.yml -p "${PROJECT_NAME}" up -d 11 | 12 | cat << EOF > .env 13 | POSTGRES_URL=postgres://postgres:noctf@`get-ip postgres`/noctf 14 | REDIS_URL=redis://`get-ip redis` 15 | NATS_URL=nats://`get-ip nats` 16 | EOF 17 | echo "Started $PROJECT_NAME" 18 | } 19 | 20 | # For non linux based dev environments 21 | start-local() { 22 | docker compose -f docker-compose.dev.yml -p "${PROJECT_NAME}" up -d 23 | 24 | cat << EOF > .env 25 | POSTGRES_URL=postgres://postgres:noctf@localhost:5432/noctf 26 | REDIS_URL=redis://localhost:6379 27 | NATS_URL=nats://localhost:4222 28 | EOF 29 | echo "Started $PROJECT_NAME" 30 | } 31 | 32 | stop() { 33 | docker compose -f docker-compose.dev.yml -p "${PROJECT_NAME}" stop 34 | # Only try to remove .env if it exists 35 | if [ -f .env ]; then 36 | rm .env 37 | fi 38 | echo "Stopped $PROJECT_NAME" 39 | } 40 | 41 | clean() { 42 | docker compose -f docker-compose.dev.yml -p "${PROJECT_NAME}" down -v 43 | docker compose -f docker-compose.dev.yml -p "${PROJECT_NAME}" rm -f -v 44 | # Only try to remove .env if it exists 45 | if [ -f .env ]; then 46 | rm .env 47 | fi 48 | echo "Cleaned $PROJECT_NAME" 49 | } 50 | 51 | case $1 in 52 | start) 53 | start 54 | ;; 55 | 56 | start-local) 57 | start-local 58 | ;; 59 | 60 | stop) 61 | stop 62 | ;; 63 | 64 | clean) 65 | clean 66 | ;; 67 | 68 | *) 69 | echo -n "Unknown command. Supported commands are start, stop, clean" 70 | ;; 71 | esac 72 | -------------------------------------------------------------------------------- /core/mod-captcha/src/service.ts: -------------------------------------------------------------------------------- 1 | import type { CaptchaProvider } from "./provider.ts"; 2 | import type { ServiceCradle } from "@noctf/server-core"; 3 | import { CaptchaConfig } from "@noctf/api/config"; 4 | 5 | type Props = Pick; 6 | 7 | export class CaptchaService { 8 | private readonly configService: Props["configService"]; 9 | private readonly providers: Map = new Map(); 10 | 11 | constructor({ configService }: Props) { 12 | this.configService = configService; 13 | void configService.register( 14 | CaptchaConfig, 15 | { routes: [] }, 16 | this.validateConfig.bind(this), 17 | ); 18 | } 19 | 20 | register(provider: CaptchaProvider) { 21 | if (this.providers.has(provider.id())) { 22 | throw new Error(`Provider ${provider.id()} has already been registered`); 23 | } 24 | this.providers.set(provider.id(), provider); 25 | } 26 | 27 | private validateConfig({ provider }: CaptchaConfig) { 28 | if (provider && !this.providers.has(provider)) { 29 | throw new Error(`Captcha provider ${provider} does not exist`); 30 | } 31 | } 32 | 33 | async getConfig(): Promise { 34 | return (await this.configService.get(CaptchaConfig)).value; 35 | } 36 | 37 | async validate(response: string, clientIp: string): Promise { 38 | const { provider, private_key } = await this.getConfig(); 39 | if (!provider) { 40 | // Pass validation if captcha is not configured 41 | return Date.now(); 42 | } 43 | if (!this.providers.has(provider) || !private_key) { 44 | throw new Error(`Captcha provider ${provider} is not configured`); 45 | } 46 | await this.providers 47 | .get(provider) 48 | .validate(private_key, response, clientIp); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/lib/static_export/config.ts: -------------------------------------------------------------------------------- 1 | import { assets } from "$app/paths"; 2 | 3 | export interface StaticExportConfig { 4 | enabled: boolean; 5 | baseUrl: string; 6 | defaultPageSize: number; 7 | maxPageSize: number; 8 | } 9 | 10 | export const STATIC_EXPORT_CONFIG: StaticExportConfig = { 11 | enabled: ["1", "true"].includes( 12 | (import.meta.env.VITE_IS_STATIC_EXPORT || "").toLowerCase(), 13 | ), 14 | baseUrl: `${assets || ""}/export`, 15 | defaultPageSize: 60, 16 | maxPageSize: 1000, 17 | }; 18 | 19 | export const STATIC_ROUTES = { 20 | USER_ME: "/user/me", 21 | MY_TEAM: "/team", 22 | 23 | // Query endpoints (POST) 24 | TEAMS_QUERY: "/teams/query", 25 | USERS_QUERY: "/users/query", 26 | 27 | // GET endpoints 28 | ANNOUNCEMENTS: "/announcements", 29 | CHALLENGES: "/challenges", 30 | DIVISIONS: "/divisions", 31 | TEAM_TAGS: "/team_tags", 32 | SITE_CONFIG: "/site/config", 33 | USER_STATS: "/stats/users", 34 | 35 | // Parameterized endpoints 36 | SCOREBOARD_DIVISION: /^\/scoreboard\/divisions\/(\d+)$/, 37 | SCOREBOARD_TEAM: /^\/scoreboard\/teams\/(\d+)$/, 38 | CHALLENGE_DETAILS: /^\/challenges\/(\d+)$/, 39 | CHALLENGE_SOLVES: /^\/challenges\/(\d+)\/solves$/, 40 | CHALLENGE_STATS: "/stats/challenges", 41 | } as const; 42 | 43 | export const STATIC_FILES = { 44 | ANNOUNCEMENTS: "announcements.json", 45 | CHALLENGES: "challenges.json", 46 | CHALLENGE_DETAILS: "challenge_details.json", 47 | DIVISIONS: "divisions.json", 48 | TEAM_TAGS: "team_tags.json", 49 | TEAMS: "teams.json", 50 | USERS: "users.json", 51 | SITE_CONFIG: "site_config.json", 52 | USER_STATS: "user_stats.json", 53 | 54 | // Division-specific files 55 | SCOREBOARD: "scoreboard.json", 56 | CHALLENGE_SOLVES: "challenge_solves.json", 57 | CHALLENGE_STATS: "challenge_stats.json", 58 | } as const; 59 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noctf/server", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test:unit": "vitest --coverage", 8 | "typecheck": "tsc -noEmit", 9 | "build": "node esbuild.mjs", 10 | "release": "pnpm run typecheck && pnpm run build /build && pnpm run test --run", 11 | "generate:swagger": "tsx --env-file=../../.env --env-file=../../.env.dev src/generate_swagger.ts dist/swagger.json", 12 | "dev:www": "tsx watch --env-file=../../.env --env-file=../../.env.dev src/entrypoints/www | pino-pretty", 13 | "dev:worker": "tsx watch --env-file=../../.env --env-file=../../.env.dev src/entrypoints/worker | pino-pretty", 14 | "dev:shell": "tsx -i --env-file=../../.env --env-file=../../.env.dev src/entrypoints/shell.ts" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@fastify/compress": "^8.1.0", 21 | "@fastify/cors": "^10.0.2", 22 | "@fastify/multipart": "^9.0.1", 23 | "@fastify/swagger": "catalog:", 24 | "@fastify/swagger-ui": "^5.2.0", 25 | "@noctf/api": "workspace:*", 26 | "@noctf/mod-auth": "workspace:*", 27 | "@noctf/mod-captcha": "workspace:*", 28 | "@noctf/mod-tickets": "workspace:*", 29 | "@noctf/server-core": "workspace:*", 30 | "@sinclair/typebox": "catalog:", 31 | "awilix": "catalog:", 32 | "cbor-x": "catalog:", 33 | "fastify": "catalog:", 34 | "kysely": "catalog:", 35 | "nanoid": "catalog:" 36 | }, 37 | "type": "module", 38 | "devDependencies": { 39 | "@types/node": "catalog:", 40 | "@types/pg": "catalog:", 41 | "@vitest/coverage-v8": "catalog:", 42 | "esbuild": "catalog:", 43 | "pino-pretty": "catalog:", 44 | "tsx": "catalog:", 45 | "typescript": "catalog:", 46 | "vitest": "catalog:" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | 4 | class NoCTFError(Exception): 5 | """Base exception for all noctfcli errors.""" 6 | 7 | def __init__(self, message: str, details: Optional[dict[str, Any]] = None) -> None: 8 | super().__init__(message) 9 | self.message = message 10 | self.details = details or {} 11 | 12 | 13 | class ValidationError(NoCTFError): 14 | """Raised when challenge configuration validation fails.""" 15 | 16 | def __init__( 17 | self, 18 | message: str, 19 | field: Optional[str] = None, 20 | value: Optional[Any] = None, 21 | details: Optional[dict[str, Any]] = None, 22 | ) -> None: 23 | super().__init__(message, details) 24 | self.field = field 25 | self.value = value 26 | 27 | 28 | class APIError(NoCTFError): 29 | """Raised when API requests fail.""" 30 | 31 | def __init__( 32 | self, 33 | message: str, 34 | status_code: Optional[int] = None, 35 | response_data: Optional[dict[str, Any]] = None, 36 | details: Optional[dict[str, Any]] = None, 37 | ) -> None: 38 | super().__init__(message, details) 39 | self.status_code = status_code 40 | self.response_data = response_data or {} 41 | 42 | 43 | class AuthenticationError(APIError): 44 | """Raised when authentication fails.""" 45 | 46 | 47 | class NotFoundError(APIError): 48 | """Raised when a resource is not found.""" 49 | 50 | 51 | class ConflictError(APIError): 52 | """Raised when there's a conflict (e.g., duplicate slug).""" 53 | 54 | 55 | class FileNotFoundError(NoCTFError): 56 | """Raised when a challenge file is not found.""" 57 | 58 | 59 | class ConfigurationError(NoCTFError): 60 | """Raised when there's an issue with configuration.""" 61 | -------------------------------------------------------------------------------- /core/server-core/src/util/local_cache.ts: -------------------------------------------------------------------------------- 1 | import type { Disposer, TTLOptions } from "@isaacs/ttlcache"; 2 | import TTLCache from "@isaacs/ttlcache"; 3 | import type { MetricsClient } from "../clients/metrics.ts"; 4 | 5 | export class LocalCache { 6 | private readonly cache; 7 | 8 | constructor(opts?: TTLCache.Options>) { 9 | this.cache = new TTLCache>(opts); 10 | } 11 | 12 | load( 13 | key: K, 14 | loader: () => V | Promise, 15 | setTTL?: ((v: V) => TTLOptions | undefined) | TTLOptions | undefined, 16 | ): V | Promise { 17 | let p = this.cache.get(key); 18 | if (typeof p === "undefined") { 19 | p = loader(); 20 | this.cache.set(key, p); 21 | } else { 22 | return p; 23 | } 24 | if (!(p instanceof Promise)) { 25 | this.cache.set( 26 | key, 27 | p, 28 | setTTL && (typeof setTTL === "function" ? setTTL(p) : setTTL), 29 | ); 30 | return p; 31 | } 32 | return p 33 | .then((v) => { 34 | this.cache.set( 35 | key, 36 | v, 37 | setTTL && (typeof setTTL === "function" ? setTTL(v) : setTTL), 38 | ); 39 | return v; 40 | }) 41 | .catch((e) => { 42 | this.cache.delete(key); 43 | throw e; 44 | }); 45 | } 46 | 47 | delete(key: K) { 48 | this.cache.delete(key); 49 | } 50 | 51 | clear() { 52 | this.cache.clear(); 53 | } 54 | 55 | static disposeMetricsHook( 56 | metrics: MetricsClient, 57 | name: string, 58 | ): Disposer { 59 | const labels = { 60 | local_cache: name, 61 | }; 62 | return (_value, _key, reason) => { 63 | if (reason === "evict") { 64 | metrics.recordAggregate([["EvictedCount", 1]], labels); 65 | } 66 | }; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /migrations/1752615351508_announcement.ts: -------------------------------------------------------------------------------- 1 | import { sql, type Kysely } from "kysely"; 2 | import { 3 | CreateTableWithDefaultTimestamps, 4 | CreateTriggerUpdatedAt, 5 | } from "./util"; 6 | 7 | export async function up(db: Kysely): Promise { 8 | const schema = db.schema; 9 | 10 | await CreateTableWithDefaultTimestamps(schema, "announcement") 11 | .addColumn("id", "integer", (col) => 12 | col.primaryKey().generatedByDefaultAsIdentity(), 13 | ) 14 | .addColumn("title", "varchar(128)", (col) => col.notNull()) 15 | .addColumn("message", "text", (col) => col.notNull()) 16 | .addColumn("created_by", "integer", (col) => 17 | col.references("user.id").onDelete("set null"), 18 | ) 19 | .addColumn("updated_by", "integer", (col) => 20 | col.references("user.id").onDelete("set null"), 21 | ) 22 | .addColumn("visible_to", sql`varchar[]`, (col) => 23 | col.notNull().defaultTo("{}"), 24 | ) 25 | .addColumn("delivery_channels", sql`varchar[]`, (col) => 26 | col.notNull().defaultTo("{}"), 27 | ) 28 | .addColumn("important", "boolean", (col) => col.notNull().defaultTo(false)) 29 | .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) 30 | .execute(); 31 | 32 | await CreateTriggerUpdatedAt("announcement").execute(db); 33 | await schema 34 | .createIndex("announcement_idx_updated_at_desc_visible_to") 35 | .on("announcement") 36 | .columns(["updated_at desc", "visible_to"]) 37 | .execute(); 38 | await schema 39 | .createIndex("announcement_idx_created_at_desc_visible_to") 40 | .on("announcement") 41 | .columns(["created_at desc", "visible_to"]) 42 | .execute(); 43 | } 44 | 45 | export async function down(db: Kysely): Promise { 46 | const schema = db.schema; 47 | 48 | await schema.dropTable("announcement").execute(); 49 | } 50 | -------------------------------------------------------------------------------- /core/server-core/src/dao/config.ts: -------------------------------------------------------------------------------- 1 | import type { JsonObject } from "@noctf/schema"; 2 | import type { DBType } from "../clients/database.ts"; 3 | import { BadRequestError } from "../errors.ts"; 4 | import { SerializableMap } from "@noctf/api/types"; 5 | 6 | export class ConfigDAO { 7 | constructor(private readonly db: DBType) {} 8 | async get(namespace: string) { 9 | const config = await this.db 10 | .selectFrom("config") 11 | .select(["version", "value"]) 12 | .where("namespace", "=", namespace) 13 | .executeTakeFirst(); 14 | if (config) { 15 | return { 16 | version: config.version, 17 | value: config.value as T, 18 | }; 19 | } 20 | return { 21 | version: 0, 22 | value: {} as T, 23 | }; 24 | } 25 | 26 | async update( 27 | namespace: string, 28 | value: T, 29 | version?: number, 30 | ) { 31 | let query = this.db 32 | .updateTable("config") 33 | .set((eb) => ({ 34 | value: value as JsonObject, 35 | version: eb("version", "+", 1), 36 | })) 37 | .where("namespace", "=", namespace) 38 | .returning(["version", "updated_at"]); 39 | if (version || version === 0) { 40 | query = query.where("version", "=", version); 41 | } 42 | const result = await query.executeTakeFirst(); 43 | if (!result) { 44 | throw new BadRequestError("config version mismatch"); 45 | } 46 | return result; 47 | } 48 | 49 | async register(namespace: string, value: T) { 50 | const result = await this.db 51 | .insertInto("config") 52 | .values({ 53 | namespace, 54 | value: value as JsonObject, 55 | }) 56 | .onConflict((c) => c.doNothing()) 57 | .executeTakeFirst(); 58 | return !!result.numInsertedOrUpdatedRows; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/commands/list_cmd.py: -------------------------------------------------------------------------------- 1 | import click 2 | from rich.table import Table 3 | 4 | from noctfcli.client import create_client 5 | 6 | from .common import CLIContextObj, console, handle_errors 7 | 8 | 9 | @click.command(name="list") 10 | @click.pass_obj 11 | @handle_errors 12 | async def list_challenges(ctx: CLIContextObj) -> None: 13 | """List all challenges.""" 14 | 15 | async with create_client(ctx.config) as client: 16 | challenges = await client.list_challenges() 17 | 18 | if not challenges: 19 | console.print("[yellow]No challenges found[/yellow]") 20 | return 21 | 22 | table = Table(title="Challenges") 23 | table.add_column("ID", style="cyan") 24 | table.add_column("Slug", style="green") 25 | table.add_column("Title", style="bold") 26 | table.add_column("Categories", style="blue") 27 | table.add_column("Hidden", style="red") 28 | table.add_column("Visible At", style="magenta") 29 | table.add_column("Updated", style="dim") 30 | 31 | # TODO: would be nice to use local timezone displays 32 | for challenge in challenges: 33 | categories = challenge.tags.get("categories", "unknown") 34 | hidden_text = "Yes" if challenge.hidden else "No" 35 | updated = challenge.updated_at.strftime("%Y-%m-%d %H:%M") 36 | visible_at = ( 37 | "-" 38 | if challenge.visible_at is None 39 | else challenge.visible_at.strftime("%Y-%m-%d %H:%M") 40 | ) 41 | 42 | table.add_row( 43 | str(challenge.id), 44 | challenge.slug, 45 | challenge.title, 46 | categories, 47 | hidden_text, 48 | visible_at, 49 | updated, 50 | ) 51 | 52 | console.print(table) 53 | -------------------------------------------------------------------------------- /core/server-core/src/util/pgerror.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { PostgresErrorCode, TryPGConstraintError } from "./pgerror.ts"; 3 | import pg from "pg"; 4 | import { mockDeep } from "vitest-mock-extended"; 5 | 6 | class FakeError extends Error {} 7 | 8 | describe(TryPGConstraintError, () => { 9 | it("Test non-existent error code", () => { 10 | const e = mockDeep(); 11 | e.code = "1"; 12 | e.constraint = "hello"; 13 | const derived = TryPGConstraintError(e, {}); 14 | expect(derived).toBeFalsy(); 15 | }); 16 | 17 | it("Test defined error code and constraint", () => { 18 | const e = mockDeep(); 19 | e.message = "lol"; 20 | e.code = PostgresErrorCode.Duplicate; 21 | e.constraint = "hello_key"; 22 | const derived = TryPGConstraintError(e, { 23 | [PostgresErrorCode.Duplicate]: { 24 | hello_key: (e) => new FakeError(e.message), 25 | }, 26 | }); 27 | expect(derived).to.eql(new FakeError("lol")); 28 | }); 29 | 30 | it("Test not-exists constraint", () => { 31 | const e = mockDeep(); 32 | e.message = "lol"; 33 | e.code = PostgresErrorCode.Duplicate; 34 | e.constraint = "hello_key"; 35 | const derived = TryPGConstraintError(e, { 36 | [PostgresErrorCode.Duplicate]: { 37 | lol: (e) => new FakeError(e.message), 38 | }, 39 | }); 40 | expect(derived).toBeFalsy(); 41 | }); 42 | 43 | it("Test default constraint", () => { 44 | const e = mockDeep(); 45 | e.message = "lol"; 46 | e.code = PostgresErrorCode.Duplicate; 47 | e.constraint = "hello_key"; 48 | const derived = TryPGConstraintError(e, { 49 | [PostgresErrorCode.Duplicate]: { 50 | default: (e) => new FakeError(e.message), 51 | }, 52 | }); 53 | expect(derived).to.eql(new FakeError("lol")); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /apps/server/src/entrypoints/worker.ts: -------------------------------------------------------------------------------- 1 | import { initWorker as initTickets } from "@noctf/mod-tickets"; 2 | import { server } from "../index.ts"; 3 | import { WorkerRegistry } from "@noctf/server-core/worker/registry"; 4 | import { SignalledWorker } from "@noctf/server-core/worker/signalled"; 5 | import { 6 | RunLockedScoreboardCalculator, 7 | ScoreboardCalculatorWorker, 8 | } from "@noctf/server-core/services/scoreboard/worker"; 9 | import { SingletonWorker } from "@noctf/server-core/worker/singleton"; 10 | 11 | server.ready(async () => { 12 | const { logger, emailService, lockService, notificationService } = 13 | server.container.cradle; 14 | const registry = new WorkerRegistry(server.container.cradle.logger); 15 | 16 | registry.register( 17 | new SignalledWorker({ 18 | name: "queue.tickets", 19 | handler: (signal) => initTickets(signal, server.container.cradle), 20 | logger, 21 | }), 22 | ); 23 | registry.register( 24 | new SignalledWorker({ 25 | name: "email_sender", 26 | handler: (signal) => emailService.worker(signal), 27 | logger, 28 | }), 29 | ); 30 | 31 | registry.register( 32 | new SingletonWorker({ 33 | lockService: lockService, 34 | logger: logger, 35 | intervalSeconds: 120, 36 | name: "scoreboard_periodic", 37 | handler: () => RunLockedScoreboardCalculator(server.container.cradle), 38 | }), 39 | ); 40 | registry.register( 41 | new SignalledWorker({ 42 | name: "scoreboard_event", 43 | handler: (signal) => 44 | ScoreboardCalculatorWorker(signal, server.container.cradle), 45 | logger, 46 | }), 47 | ); 48 | 49 | registry.register( 50 | new SignalledWorker({ 51 | name: "notification", 52 | handler: (signal) => notificationService.worker(signal), 53 | logger, 54 | }), 55 | ); 56 | await registry.run(); 57 | 58 | // TODO: graceful shutdown 59 | }); 60 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/commands/show.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from noctfcli.client import create_client 4 | 5 | from .common import CLIContextObj, console, handle_errors 6 | 7 | 8 | @click.command() 9 | @click.argument("challenge_slug") 10 | @click.pass_obj 11 | @handle_errors 12 | async def show(ctx: CLIContextObj, challenge_slug: str) -> None: 13 | """Show detailed information about a challenge.""" 14 | 15 | async with create_client(ctx.config) as client: 16 | challenge, files = await client.get_challenge(challenge_slug, with_files=True) 17 | 18 | console.print(f"[bold]Challenge: {challenge.title}[/bold]") 19 | console.print(f"ID: {challenge.id}") 20 | console.print(f"Slug: {challenge.slug}") 21 | console.print(f"Categories: {challenge.tags.get('categories', 'unknown')}") 22 | console.print(f"Difficulty: {challenge.tags.get('difficulty', 'unknown')}") 23 | console.print(f"Hidden: {'Yes' if challenge.hidden else 'No'}") 24 | console.print(f"Version: {challenge.version}") 25 | console.print(f"Created: {challenge.created_at}") 26 | console.print(f"Updated: {challenge.updated_at}") 27 | 28 | if challenge.visible_at: 29 | console.print(f"Visible at: {challenge.visible_at}") 30 | 31 | console.print("\n[bold]Description:[/bold]") 32 | console.print(challenge.description) 33 | 34 | flags = challenge.flags 35 | if flags: 36 | console.print(f"\n[bold]Flags ({len(flags)}):[/bold]") 37 | for i, flag in enumerate(flags, 1): 38 | console.print(f" {i}. {flag.data} ({flag.strategy})") 39 | 40 | if files: 41 | console.print(f"\n[bold]Files ({len(files)}):[/bold]") 42 | for file in files: 43 | size_mb = file.size / (1024 * 1024) 44 | console.print( 45 | f" • {file.filename} ({size_mb:.2f} MB) ({file.hash})", 46 | ) 47 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Optional, Type 4 | 5 | import click 6 | from rich.console import Console 7 | 8 | from noctfcli import __version__ 9 | from noctfcli.commands.common import CLIContextObj 10 | from noctfcli.commands.delete import delete 11 | from noctfcli.commands.list_cmd import list_challenges 12 | from noctfcli.commands.show import show 13 | from noctfcli.commands.update import update 14 | from noctfcli.commands.upload import upload 15 | from noctfcli.commands.validate import validate 16 | from noctfcli.config import Config 17 | from noctfcli.exceptions import ConfigurationError 18 | from noctfcli.preprocessor import PreprocessorBase 19 | 20 | 21 | def build_cli(Preprocessor: Optional[Type[PreprocessorBase]] = None): 22 | console = Console() 23 | 24 | @click.group() 25 | @click.version_option(version=__version__) 26 | @click.option( 27 | "--config", 28 | type=click.Path(exists=True, path_type=Path), 29 | help="Configuration file path", 30 | ) 31 | @click.pass_context 32 | def cli( 33 | ctx: click.Context, 34 | config: Path, 35 | ) -> None: 36 | """noctfcli - CLI tool for noCTF challenge management.""" 37 | 38 | try: 39 | app_config = Config.init(config) 40 | preprocessor = Preprocessor(config) if Preprocessor else None 41 | ctx.obj = CLIContextObj(config=app_config, preprocessor=preprocessor) 42 | except ConfigurationError as e: 43 | console.print(f"[red]Configuration error:[/red] {e.message}") 44 | sys.exit(1) 45 | 46 | cli.add_command(list_challenges) 47 | cli.add_command(show) 48 | cli.add_command(upload) 49 | cli.add_command(update) 50 | cli.add_command(validate) 51 | cli.add_command(delete) 52 | 53 | return cli 54 | 55 | 56 | def main() -> None: 57 | cli = build_cli() 58 | cli() 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /apps/web/src/lib/static_export/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PaginationParams, 3 | TeamFilterParams, 4 | UserFilterParams, 5 | Team, 6 | User, 7 | } from "./types"; 8 | 9 | export function paginateEntries( 10 | entries: T[], 11 | { page = 1, page_size = 60 }: PaginationParams, 12 | ): { entries: T[]; total: number; page: number; page_size: number } { 13 | const total = entries.length; 14 | const startIndex = (page - 1) * page_size; 15 | const paginatedEntries = entries.slice(startIndex, startIndex + page_size); 16 | 17 | return { 18 | entries: paginatedEntries, 19 | total, 20 | page, 21 | page_size, 22 | }; 23 | } 24 | 25 | export function filterTeams( 26 | entries: Team[], 27 | { name, division_id, ids }: Omit, 28 | ): Team[] { 29 | let filtered = entries; 30 | 31 | if (ids && Array.isArray(ids)) { 32 | filtered = filtered.filter((entry) => ids.includes(entry.id)); 33 | } 34 | 35 | if (division_id !== undefined) { 36 | filtered = filtered.filter((entry) => entry.division_id === division_id); 37 | } 38 | 39 | if (name) { 40 | const searchTerm = name.toLowerCase(); 41 | filtered = filtered.filter((entry) => 42 | entry.name.toLowerCase().includes(searchTerm), 43 | ); 44 | } 45 | 46 | return filtered; 47 | } 48 | 49 | export function filterUsers( 50 | entries: User[], 51 | { name, ids }: Omit, 52 | ): User[] { 53 | let filtered = entries; 54 | if (ids && Array.isArray(ids)) { 55 | filtered = filtered.filter((entry) => ids.includes(entry.id)); 56 | } 57 | 58 | if (name) { 59 | const searchTerm = name.toLowerCase(); 60 | filtered = filtered.filter((entry) => 61 | entry.name.toLowerCase().includes(searchTerm), 62 | ); 63 | } 64 | 65 | return filtered; 66 | } 67 | 68 | export function getDivisionFilePath( 69 | divisionId: string, 70 | filename: string, 71 | ): string { 72 | return `division:${divisionId}/${filename}`; 73 | } 74 | -------------------------------------------------------------------------------- /apps/web/src/lib/components/Toast.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | {#if $toasts.length > 0} 30 |
31 | {#each $toasts as toast (toast.id)} 32 | {@const style = toastStyles[toast.type] || defaultStyle} 33 | 57 | {/each} 58 |
59 | {/if} 60 | 61 | 66 | -------------------------------------------------------------------------------- /core/server-core/src/services/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { CacheService } from "./cache.ts"; 3 | import { Coleascer } from "../util/coleascer.ts"; 4 | import { mockDeep } from "vitest-mock-extended"; 5 | import { MetricsClient } from "../clients/metrics.ts"; 6 | import { RedisClientFactory } from "../clients/redis.ts"; 7 | import { encode } from "cbor-x"; 8 | import { Logger } from "../types/primitives.ts"; 9 | 10 | vi.mock(import("../util/coleascer.ts"), () => ({ 11 | Coleascer: vi.fn(), 12 | })); 13 | 14 | vi.mock(import("../util/message_compression.ts"), () => ({ 15 | Compress: async (x) => x as unknown as Uint8Array, 16 | Decompress: async (x) => x as unknown as Uint8Array, 17 | })); 18 | 19 | describe(CacheService, () => { 20 | const logger = mockDeep(); 21 | const metricsClient = mockDeep(); 22 | const redisClientFactory = mockDeep(); 23 | const redisClient = 24 | mockDeep>>(); 25 | 26 | const coleascer = mockDeep>(); 27 | beforeEach(() => { 28 | vi.mocked(Coleascer).mockReturnValue(coleascer); 29 | redisClientFactory.getClient.mockResolvedValue(redisClient); 30 | }); 31 | 32 | it("loads stuff using the fetcher", async () => { 33 | const svc = new CacheService({ logger, metricsClient, redisClientFactory }); 34 | coleascer.get.mockImplementation((_k, f) => f()); 35 | const fetcher = async () => "loaded"; 36 | expect(await svc.load("ns", "key", fetcher)).toEqual("loaded"); 37 | }); 38 | 39 | it("loads stuff using the cache if cached", async () => { 40 | const svc = new CacheService({ logger, metricsClient, redisClientFactory }); 41 | coleascer.get.mockImplementation((_k, f) => f()); 42 | const fetcher = async () => "loaded"; 43 | // TODO: typescript is being annoying 44 | redisClient.get.mockResolvedValue(encode("cached") as unknown as string); 45 | expect(await svc.load("ns", "key", fetcher)).toEqual("cached"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /core/server-core/src/util/paginator.ts: -------------------------------------------------------------------------------- 1 | export type PaginateOptions = { 2 | max_page_size: number; 3 | default_page_size: number; 4 | }; 5 | 6 | const DEFAULT_PAGINATE_OPTIONS: PaginateOptions = { 7 | max_page_size: 100, 8 | default_page_size: 50, 9 | }; 10 | 11 | export const OffsetPaginate = async ( 12 | query: Q, 13 | { page_size, page }: { page_size?: number; page?: number }, 14 | queryFn: ( 15 | q: Q, 16 | limit: { limit: number; offset: number }, 17 | ) => T[] | Promise, 18 | opts: Partial = {}, 19 | ): Promise<{ 20 | entries: T[]; 21 | page_size: number; 22 | }> => { 23 | const o = { ...DEFAULT_PAGINATE_OPTIONS, ...opts }; 24 | page = page || 1; 25 | page_size = page_size = 26 | o.max_page_size === 0 27 | ? page_size || o.default_page_size 28 | : Math.min(o.max_page_size, page_size || o.default_page_size); 29 | if (page < 0) { 30 | page = 1; 31 | } 32 | if (page_size < 0) { 33 | page_size = o.default_page_size; 34 | } 35 | const entries = await queryFn(query, { 36 | limit: page_size, 37 | offset: (page - 1) * page_size, 38 | }); 39 | 40 | return { 41 | entries, 42 | page_size, 43 | }; 44 | }; 45 | 46 | export const CursorPaginate = async ( 47 | query: Q, 48 | { page_size, next }: { page_size?: number; next?: string }, 49 | queryFn: ( 50 | q: Q, 51 | limit: { limit: number; next?: string }, 52 | ) => [T[], string] | Promise<[T[], string]>, 53 | opts: Partial = {}, 54 | ): Promise<{ 55 | entries: T[]; 56 | page_size: number; 57 | next: string; 58 | }> => { 59 | const o = { ...DEFAULT_PAGINATE_OPTIONS, ...opts }; 60 | page_size = page_size = 61 | o.max_page_size === 0 62 | ? page_size || o.default_page_size 63 | : Math.min(o.max_page_size, page_size || o.default_page_size); 64 | if (page_size < 0) { 65 | page_size = o.default_page_size; 66 | } 67 | const [entries, cursor] = await queryFn(query, { 68 | limit: page_size, 69 | next, 70 | }); 71 | 72 | return { 73 | entries, 74 | page_size, 75 | next: cursor, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /core/server-core/src/services/scoreboard/worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChallengeUpdateEvent, 3 | SubmissionUpdateEvent, 4 | ScoreboardTriggerEvent, 5 | } from "@noctf/api/events"; 6 | import { ServiceCradle } from "../../index.ts"; 7 | 8 | type Props = Pick< 9 | ServiceCradle, 10 | "scoreboardService" | "eventBusService" | "lockService" 11 | >; 12 | 13 | export const RunLockedScoreboardCalculator = async ( 14 | { 15 | lockService, 16 | scoreboardService, 17 | }: Pick, 18 | { 19 | updated_at, 20 | recompute_graph, 21 | }: { updated_at?: Date; recompute_graph?: boolean } = {}, 22 | ) => { 23 | await lockService.withLease(`singleton:scoreboard`, () => { 24 | if (recompute_graph) { 25 | return scoreboardService.recomputeFullGraph(); 26 | } 27 | return scoreboardService.computeAndSaveScoreboards(updated_at); 28 | }); 29 | }; 30 | 31 | export const ScoreboardCalculatorWorker = async ( 32 | signal: AbortSignal, 33 | c: Props, 34 | ) => { 35 | await c.eventBusService.subscribe< 36 | SubmissionUpdateEvent | ChallengeUpdateEvent 37 | >( 38 | signal, 39 | "ScoreboardWorker", 40 | [ 41 | SubmissionUpdateEvent.$id!, 42 | ChallengeUpdateEvent.$id!, 43 | ScoreboardTriggerEvent.$id!, 44 | ], 45 | { 46 | concurrency: 1, 47 | handler: async (data) => { 48 | if (data.subject === ScoreboardTriggerEvent.$id!) { 49 | return await RunLockedScoreboardCalculator(c, { 50 | recompute_graph: (data.data as ScoreboardTriggerEvent) 51 | .recompute_graph, 52 | }); 53 | } 54 | let updated_at = new Date(data.timestamp); 55 | if (data.data.updated_at) updated_at = data.data.updated_at; 56 | const sub = data.data as SubmissionUpdateEvent; 57 | if ( 58 | data.subject === SubmissionUpdateEvent.$id! && 59 | !sub.is_update && 60 | sub.status !== "correct" 61 | ) 62 | return; 63 | 64 | await RunLockedScoreboardCalculator(c, { updated_at }); 65 | }, 66 | }, 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /core/server-core/src/clients/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient, ErrorReply } from "redis"; 2 | import type { Logger } from "../types/primitives.ts"; 3 | import { LocalCache } from "../util/local_cache.ts"; 4 | 5 | export class RedisClientFactory { 6 | private readonly url; 7 | private readonly logger; 8 | private client: ReturnType; 9 | 10 | private readonly scriptCache = new LocalCache({ 11 | max: 1000, 12 | ttl: Infinity, 13 | }); 14 | 15 | constructor(url: string, logger?: Logger) { 16 | this.url = url; 17 | this.logger = logger; 18 | } 19 | 20 | async getClient(): Promise> { 21 | if (this.client) { 22 | return this.client; 23 | } 24 | if (this.logger) { 25 | const url = new URL(this.url); 26 | this.logger.info( 27 | `Connecting to redis at ${url.host}:${url.port || 6379}`, 28 | ); 29 | } 30 | this.client = await createClient({ 31 | url: this.url, 32 | }).connect(); 33 | return this.client; 34 | } 35 | 36 | async executeScript( 37 | script: string, 38 | keys: string[], 39 | args: string[], 40 | returnBuffers = false, 41 | ): Promise { 42 | const client = await this.getClient(); 43 | let sha = await this.scriptCache.load(script, () => 44 | client.scriptLoad(script), 45 | ); 46 | const exec = (sha: string): Promise => { 47 | if (returnBuffers) { 48 | return client.evalSha(client.commandOptions({ returnBuffers }), sha, { 49 | keys, 50 | arguments: args, 51 | }) as Promise; 52 | } 53 | return client.evalSha(sha, { 54 | keys, 55 | arguments: args, 56 | }) as Promise; 57 | }; 58 | 59 | try { 60 | return await exec(sha); 61 | } catch (e) { 62 | if (e instanceof ErrorReply && e.message.startsWith("NOSCRIPT")) { 63 | sha = await this.scriptCache.load(script, () => 64 | client.scriptLoad(script), 65 | ); 66 | return await exec(sha); 67 | } else { 68 | throw e; 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /apps/web/src/lib/state/captcha.svelte.ts: -------------------------------------------------------------------------------- 1 | import api from "$lib/api/index.svelte"; 2 | import type { PathResponse } from "$lib/api/types"; 3 | import { STATIC_EXPORT_CONFIG } from "$lib/static_export/config"; 4 | import type { Middleware } from "openapi-fetch"; 5 | 6 | export type CaptchaConfig = PathResponse<"/captcha", "get">["data"]; 7 | 8 | export class CaptchaState { 9 | config?: CaptchaConfig = $state(); 10 | show: boolean = $state(false); 11 | 12 | resolve?: (r: string) => void; 13 | reject?: (r: unknown) => void; 14 | 15 | constructor() { 16 | if (!STATIC_EXPORT_CONFIG.enabled) { 17 | this.load(); 18 | } 19 | } 20 | 21 | async request() { 22 | return new Promise((resolve, reject) => { 23 | this.resolve = resolve; 24 | this.reject = reject; 25 | this.show = true; 26 | }); 27 | } 28 | 29 | onSuccess(response: string) { 30 | const f = this.resolve; 31 | this.resolve = undefined; 32 | this.reject = undefined; 33 | this.show = false; 34 | f?.(response); 35 | } 36 | 37 | onError(e: unknown) { 38 | const f = this.reject; 39 | this.resolve = undefined; 40 | this.reject = undefined; 41 | this.show = false; 42 | f?.(e); 43 | } 44 | 45 | private async load() { 46 | const { data, error } = await api.GET("/captcha"); 47 | if (error) { 48 | throw new Error("Error loading captcha widget"); 49 | } 50 | this.config = data.data; 51 | } 52 | } 53 | 54 | const captchaState = new CaptchaState(); 55 | export default captchaState; 56 | const captchaMiddleware: Middleware = { 57 | async onRequest({ request, schemaPath }) { 58 | const path = schemaPath.replace(/{([a-zA-Z0-9_-]+)}/g, ":$1"); 59 | if ( 60 | captchaState.config?.routes && 61 | captchaState.config?.routes.some( 62 | (x) => x.method === request.method && x.path === path, 63 | ) 64 | ) { 65 | const response = await captchaState.request(); 66 | request.headers.append("x-noctf-captcha", response); 67 | } 68 | return request; 69 | }, 70 | }; 71 | if (!STATIC_EXPORT_CONFIG.enabled) { 72 | api.use(captchaMiddleware); 73 | } 74 | -------------------------------------------------------------------------------- /apps/web/src/routes/auth/+layout.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | {#snippet header(heading: string, subHeading: string)} 20 |
21 |

{heading}

22 |

{subHeading}

23 |
24 | {/snippet} 25 | 26 | {#snippet emailLocked(options: EmailLockedOptions = defaultEmailLockedOptions)} 27 |
28 | 31 |
32 | 39 | {#if options.showEditButton} 40 | 48 | {/if} 49 |
50 |
51 | {/snippet} 52 | 53 |
54 |
57 |
58 | {@render children()} 59 |
60 |
61 | 62 | 68 |
69 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import yaml 6 | from pydantic import BaseModel, Field 7 | 8 | from .exceptions import ConfigurationError 9 | 10 | 11 | class Config(BaseModel): 12 | """noctfcli configuration.""" 13 | 14 | api_url: str = Field(..., description="noCTF API base URL") 15 | token: Optional[str] = Field(default=None, description="Authentication token") 16 | verify_ssl: bool = Field(default=True, description="Verify SSL certificates") 17 | timeout: float = Field(default=30.0, description="Request timeout in seconds") 18 | 19 | @classmethod 20 | def init(cls, config_path: Path) -> "Config": 21 | """Load configuration from a file. 22 | 23 | Args: 24 | config_path: Path to configuration file 25 | 26 | Returns: 27 | Configuration instance 28 | 29 | Raises: 30 | ConfigurationError: If configuration file is invalid 31 | """ 32 | 33 | if not config_path.exists(): 34 | msg = f"Configuration file not found: {config_path}" 35 | raise ConfigurationError(msg) 36 | 37 | token = os.getenv("NOCTF_TOKEN") 38 | if not token: 39 | raise ConfigurationError( 40 | "NOCTF_TOKEN environment variable is required", 41 | ) 42 | 43 | try: 44 | with open(config_path) as f: 45 | data = yaml.safe_load(f) 46 | return cls(**data, token=token) 47 | except Exception as e: 48 | msg = f"Invalid configuration file: {e}" 49 | raise ConfigurationError(msg) from e 50 | 51 | def get_token(self) -> str: 52 | """Get authentication token. 53 | 54 | Returns: 55 | Authentication token 56 | 57 | Raises: 58 | ConfigurationError: If token is not configured 59 | """ 60 | 61 | if not self.token: 62 | msg = ( 63 | "Authentication token must be configured via environment variable " 64 | "NOCTF_TOKEN" 65 | ) 66 | raise ConfigurationError(msg) 67 | return self.token 68 | -------------------------------------------------------------------------------- /tools/noctfcli/src/noctfcli/commands/validate.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | import click 5 | 6 | from noctfcli.models import UploadUpdateResult, UploadUpdateResultEnum 7 | from noctfcli.utils import ( 8 | find_challenge_files, 9 | print_results_summary, 10 | ) 11 | from noctfcli.validator import ChallengeValidator 12 | 13 | from .common import console 14 | 15 | 16 | @click.command() 17 | @click.argument( 18 | "challenges_directory", 19 | type=click.Path(exists=True, path_type=Path, file_okay=False, dir_okay=True), 20 | ) 21 | def validate(challenges_directory: Path) -> None: 22 | """Validate all noctf.yaml files in a directory.""" 23 | 24 | results: List[UploadUpdateResult] = [] 25 | validator = ChallengeValidator() 26 | 27 | yaml_files = find_challenge_files(challenges_directory) 28 | for yaml_path in yaml_files: 29 | try: 30 | challenge_config = validator.validate_challenge_complete(yaml_path) 31 | 32 | console.print( 33 | f"[blue]Validating challenge {challenge_config.slug}...[/blue]", 34 | ) 35 | console.print("\t[green]✓[/green] Challenge configuration is valid") 36 | console.print(f"\tTitle: {challenge_config.title}") 37 | console.print(f"\tSlug: {challenge_config.slug}") 38 | console.print(f"\tCategories: {challenge_config.categories}") 39 | console.print(f"\tFlags: {challenge_config.flags}") 40 | console.print(f"\tFiles: {challenge_config.files}") 41 | 42 | results.append( 43 | UploadUpdateResult( 44 | challenge=challenge_config.slug, 45 | status=UploadUpdateResultEnum.VALIDATED, 46 | ), 47 | ) 48 | 49 | except Exception as e: 50 | results.append( 51 | UploadUpdateResult( 52 | challenge=yaml_path.parent.name, 53 | status=UploadUpdateResultEnum.FAILED, 54 | error=str(e), 55 | ), 56 | ) 57 | console.print(f"[red]Error validating challenge {yaml_path}: {e}[/red]") 58 | 59 | print_results_summary(console, results) 60 | -------------------------------------------------------------------------------- /core/server-core/src/util/arrays.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic bisect_left implementation that supports element transformation 3 | * @param arr - The sorted array to search in 4 | * @param target - The target value to find insertion point for 5 | * @param getter - Optional function to transform array elements before comparison 6 | * @returns The index where target should be inserted (leftmost position) 7 | */ 8 | export function bisectLeft( 9 | arr: T[], 10 | target: V, 11 | getter: (item: T) => V = (item: any) => item, 12 | ): number { 13 | let left = 0; 14 | let right = arr.length; 15 | 16 | while (left < right) { 17 | const mid = Math.floor((left + right) / 2); 18 | if (getter(arr[mid]) < target) { 19 | left = mid + 1; 20 | } else { 21 | right = mid; 22 | } 23 | } 24 | 25 | return left; 26 | } 27 | 28 | /** 29 | * Generic bisect_right implementation that supports element transformation 30 | * @param arr - The sorted array to search in 31 | * @param target - The target value to find insertion point for 32 | * @param getter - Optional function to transform array elements before comparison 33 | * @returns The index where target should be inserted (rightmost position) 34 | */ 35 | export function bisectRight( 36 | arr: T[], 37 | target: V, 38 | getter: (item: T) => V = (item: any) => item, 39 | ): number { 40 | let left = 0; 41 | let right = arr.length; 42 | 43 | while (left < right) { 44 | const mid = Math.floor((left + right) / 2); 45 | if (getter(arr[mid]) <= target) { 46 | left = mid + 1; 47 | } else { 48 | right = mid; 49 | } 50 | } 51 | 52 | return left; 53 | } 54 | 55 | /** 56 | * Inserts a value into a sorted array at the appropriate position 57 | * @param arr - The sorted array to insert into 58 | * @param item - The item to insert 59 | * @param getter - Optional function to transform array elements before comparison 60 | * @returns The new array with the inserted item 61 | */ 62 | export function insort( 63 | arr: T[], 64 | item: T, 65 | getter: (item: T) => V = (item: any) => item, 66 | ): T[] { 67 | const index = bisectRight(arr, getter(item), getter); 68 | return [...arr.slice(0, index), item, ...arr.slice(index)]; 69 | } 70 | -------------------------------------------------------------------------------- /tools/noctfcli/README.md: -------------------------------------------------------------------------------- 1 | # noctfcli 2 | 3 | CLI tool and Python library for noCTF challenge management. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | cd tools/noctfcli 9 | python3 -m venv .venv 10 | source .venv/bin/activate 11 | pip install -e . 12 | ``` 13 | 14 | ## Configuration 15 | 16 | Using a configuration file: 17 | 18 | ```yaml 19 | # config.yaml 20 | api_url: "http://localhost:8000" 21 | ``` 22 | 23 | The `NOCTF_TOKEN` environment variable must be set: 24 | 25 | ```bash 26 | export NOCTF_TOKEN="" 27 | ``` 28 | 29 | ## Challenge Format (`noctf.yaml`) 30 | 31 | See [`noctf.yaml.schema.json`](./src/noctfcli/schema/noctf.yaml.schema.json) for the full JSON schema. 32 | 33 | ```yaml 34 | version: "1.0" 35 | slug: yet-another-login 36 | title: yet another login 37 | categories: ["crypto"] 38 | description: | 39 | Yet another login task... Authenticate as admin to get the flag! 40 | 41 | Author: joseph 42 | difficulty: easy 43 | flags: 44 | - DUCTF{now_that_youve_logged_in_its_time_to_lock_in} 45 | files: 46 | - ./publish/chall.py 47 | connection_info: nc ${host} ${port} 48 | ``` 49 | 50 | ## CLI Usage 51 | 52 | The `update` and `upload` commands take a directory which will be recursively searched for `noctf.yaml` files to process. 53 | 54 | ``` 55 | Usage: noctfcli [OPTIONS] COMMAND [ARGS]... 56 | 57 | noctfcli - CLI tool for noCTF challenge management. 58 | 59 | Options: 60 | --version Show the version and exit. 61 | --config PATH Configuration file path 62 | --help Show this message and exit. 63 | 64 | Commands: 65 | delete Delete a challenge. 66 | list List all challenges. 67 | show Show detailed information about a challenge. 68 | update Update existing challenges from a directory. 69 | upload Upload all challenge from a directory. 70 | validate Validate all noctf.yaml files in a directory. 71 | ``` 72 | 73 | ## Preprocessor 74 | 75 | noctfcli can be built on top of to support CTF-specific challenge management configurations (such as scoring, connection info details, release wave configs). The CLI tool bundled in noctfcli can be passed a preprocessor class which to pre-process the challenge config before it is uploaded to the noCTF instance. 76 | -------------------------------------------------------------------------------- /core/server-core/src/services/token.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssociateTokenData, 3 | RegisterTokenData, 4 | ResetPasswordTokenData, 5 | } from "@noctf/api/token"; 6 | import { ServiceCradle } from "../index.ts"; 7 | import { ForbiddenError } from "../errors.ts"; 8 | import { createHmac, randomUUID } from "node:crypto"; 9 | 10 | const CACHE_NAMESPACE = "core:svc:token"; 11 | type Props = Pick; 12 | 13 | export type StateTokenData = { 14 | name: string; 15 | }; 16 | 17 | type TokenDataMap = { 18 | register: RegisterTokenData; 19 | reset_password: ResetPasswordTokenData; 20 | associate: AssociateTokenData; 21 | state: StateTokenData; 22 | }; 23 | 24 | const TOKEN_EXPIRY_SECONDS: Record = { 25 | register: 3600, 26 | reset_password: 3600, 27 | associate: 3600, 28 | state: 600, 29 | }; 30 | 31 | export class TokenService { 32 | private readonly cacheService; 33 | 34 | constructor({ cacheService }: Props) { 35 | this.cacheService = cacheService; 36 | } 37 | 38 | async lookup( 39 | type: T, 40 | token: string, 41 | ): Promise { 42 | const hash = TokenService.hash(type, token); 43 | const result = await this.cacheService.get( 44 | CACHE_NAMESPACE, 45 | hash, 46 | ); 47 | if (!result) throw new ForbiddenError("Invalid token"); 48 | return result; 49 | } 50 | 51 | async create( 52 | type: T, 53 | data: TokenDataMap[T], 54 | ): Promise { 55 | const token = randomUUID(); 56 | const hash = TokenService.hash(type, token); 57 | await this.cacheService.put( 58 | CACHE_NAMESPACE, 59 | hash, 60 | data, 61 | TOKEN_EXPIRY_SECONDS[type], 62 | ); 63 | return token; 64 | } 65 | 66 | async invalidate(type: keyof TokenDataMap, token: string) { 67 | const hash = TokenService.hash(type, token); 68 | const result = await this.cacheService.del(CACHE_NAMESPACE, hash); 69 | if (!result) throw new ForbiddenError("Token already revoked"); 70 | } 71 | 72 | static hash(type: string, token: string) { 73 | return createHmac("sha256", token).update(type).digest("base64url"); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils/challenges.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CATEGORY_UNKNOWN_ICON, 3 | ICON_MAP, 4 | type Category, 5 | } from "$lib/constants/categories"; 6 | import { 7 | DIFFICULTY_BG_MAP, 8 | type Difficulty, 9 | } from "$lib/constants/difficulties"; 10 | 11 | const CATEGORY_ORDERING = [ 12 | "survey", 13 | "beginner", 14 | "pwn", 15 | "crypto", 16 | "web", 17 | "rev", 18 | "ai", 19 | "osint", 20 | "misc", 21 | ]; 22 | export const categoryOrdering = (a?: string, b?: string) => { 23 | return ( 24 | CATEGORY_ORDERING.indexOf(a || "misc") - 25 | CATEGORY_ORDERING.indexOf(b || "misc") 26 | ); 27 | }; 28 | 29 | export const categoryToIcon = (category: string) => { 30 | const c = category.toLowerCase(); 31 | if (!(c in ICON_MAP)) { 32 | return CATEGORY_UNKNOWN_ICON; 33 | } 34 | return ICON_MAP[c as Category] as string; 35 | }; 36 | 37 | export const difficultyToBgColour = (difficulty: Difficulty) => { 38 | return DIFFICULTY_BG_MAP[difficulty]; 39 | }; 40 | 41 | export const getDifficultyFromTags = (tags: { [k in string]: string }) => { 42 | return tags["difficulty"] ?? ""; 43 | }; 44 | 45 | export const getCategoriesFromTags = (tags: { [k in string]: string }) => { 46 | const categories = tags?.["categories"]; 47 | return categories ? categories?.split(",") : ["uncategorized"]; 48 | }; 49 | 50 | export const getCustomTagsFromTags = (tags: { [k in string]: string }) => { 51 | const customTags: { [k in string]: string } = {}; 52 | for (const [key, value] of Object.entries(tags)) { 53 | if (key !== "difficulty" && key !== "categories") { 54 | customTags[key] = value; 55 | } 56 | } 57 | return customTags; 58 | }; 59 | 60 | export const slugify = (title: string) => { 61 | const s = title 62 | .toLowerCase() 63 | .replace(/[^a-z0-9]+/g, "-") 64 | .replace(/^-|-$/g, "") 65 | .slice(0, 64) 66 | .replace(/-$/g, ""); 67 | return /^\d/.test(s) ? `n-${s}`.slice(0, 64) : s; 68 | }; 69 | 70 | export const formatFileSize = (bytes: number, decimals: number = 2): string => { 71 | if (bytes === 0) return "0 B"; 72 | 73 | const units: string[] = ["B", "KB", "MB", "GB"]; 74 | const k: number = 1024; 75 | 76 | const i: number = Math.floor(Math.log(bytes) / Math.log(k)); 77 | const size: number = bytes / Math.pow(k, i); 78 | return `${parseFloat(size.toFixed(decimals))} ${units[i]}`; 79 | }; 80 | -------------------------------------------------------------------------------- /core/api/src/query.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from "@sinclair/typebox"; 2 | import { Challenge, TypeDate } from "./datatypes.ts"; 3 | 4 | export const FilterChallengesQuery = Type.Partial( 5 | Type.Pick(Challenge, ["tags", "hidden", "visible_at"]), 6 | ); 7 | export type FilterChallengesQuery = Static; 8 | 9 | export const PaginatedQuery = Type.Object( 10 | { 11 | page: Type.Optional(Type.Integer({ minimum: 1 })), 12 | page_size: Type.Optional(Type.Integer()), 13 | }, 14 | { additionalProperties: false }, 15 | ); 16 | export type PaginatedQuery = Static; 17 | 18 | export const ScoreboardQuery = Type.Composite( 19 | [ 20 | PaginatedQuery, 21 | Type.Object({ 22 | tags: Type.Optional(Type.Array(Type.Number(), { maxItems: 10 })), 23 | graph_interval: Type.Optional(Type.Integer({ minimum: 1 })), 24 | }), 25 | ], 26 | { additionalProperties: false }, 27 | ); 28 | export type ScoreboardQuery = Static; 29 | 30 | export const ScoreboardTagsQuery = Type.Composite( 31 | [ 32 | Type.Pick(ScoreboardQuery, ["tags"]), 33 | Type.Object({ 34 | graph_interval: Type.Optional(Type.Integer({ minimum: 1 })), 35 | }), 36 | ], 37 | { additionalProperties: false }, 38 | ); 39 | export type ScoreboardTagsQuery = Static; 40 | 41 | export const GetFileQuery = Type.Object( 42 | { 43 | sig: Type.String({ maxLength: 255 }), 44 | iat: Type.Number(), 45 | }, 46 | { additionalProperties: false }, 47 | ); 48 | export type GetFileQuery = Static; 49 | 50 | export const DivisionQuery = Type.Object( 51 | { 52 | division_id: Type.Optional(Type.Integer()), 53 | }, 54 | { additionalProperties: false }, 55 | ); 56 | export type DivisionQuery = Static; 57 | 58 | export const SessionQuery = Type.Composite( 59 | [ 60 | PaginatedQuery, 61 | Type.Object({ 62 | active: Type.Optional(Type.Boolean()), 63 | }), 64 | ], 65 | { additionalProperties: false }, 66 | ); 67 | export type SessionQuery = Static; 68 | 69 | export const GetAnnouncementsQuery = Type.Object( 70 | { 71 | updated_at: Type.Optional(TypeDate), 72 | }, 73 | { additionalProperties: false }, 74 | ); 75 | export type GetAnnouncementsQuery = Static; 76 | -------------------------------------------------------------------------------- /apps/server/src/routes/admin_config.ts: -------------------------------------------------------------------------------- 1 | import { UpdateConfigValueRequest } from "@noctf/api/requests"; 2 | import { 3 | AdminGetConfigValueResponse, 4 | AdminGetConfigSchemaResponse, 5 | } from "@noctf/api/responses"; 6 | import { ActorType } from "@noctf/server-core/types/enums"; 7 | import type { FastifyInstance } from "fastify"; 8 | import "@noctf/server-core/types/fastify"; 9 | 10 | export async function routes(fastify: FastifyInstance) { 11 | const { configService } = fastify.container.cradle; 12 | 13 | fastify.get( 14 | "/admin/config", 15 | { 16 | schema: { 17 | tags: ["admin"], 18 | security: [{ bearer: [] }], 19 | auth: { require: true, policy: ["admin.config.get"] }, 20 | response: { 21 | 200: AdminGetConfigSchemaResponse, 22 | }, 23 | }, 24 | }, 25 | () => ({ data: configService.getSchemas() }), 26 | ); 27 | 28 | fastify.get<{ 29 | Params: { 30 | namespace: string; 31 | }; 32 | Reply: AdminGetConfigValueResponse; 33 | }>( 34 | "/admin/config/:namespace", 35 | { 36 | schema: { 37 | tags: ["admin"], 38 | security: [{ bearer: [] }], 39 | auth: { require: true, policy: ["admin.config.get"] }, 40 | response: { 41 | 200: AdminGetConfigValueResponse, 42 | }, 43 | }, 44 | }, 45 | async (request) => { 46 | return { 47 | data: await configService.get(request.params.namespace, true), 48 | }; 49 | }, 50 | ); 51 | fastify.put<{ 52 | Params: { 53 | namespace: string; 54 | }; 55 | Body: UpdateConfigValueRequest; 56 | }>( 57 | "/admin/config/:namespace", 58 | { 59 | schema: { 60 | tags: ["admin"], 61 | security: [{ bearer: [] }], 62 | body: UpdateConfigValueRequest, 63 | auth: { 64 | require: true, 65 | policy: ["admin.config.update"], 66 | }, 67 | }, 68 | }, 69 | async (request) => { 70 | const { value, version } = request.body; 71 | const data = await configService.update({ 72 | namespace: request.params.namespace, 73 | value, 74 | version, 75 | actor: { 76 | type: ActorType.USER, 77 | id: request.user?.id, 78 | }, 79 | }); 80 | return { 81 | data, 82 | }; 83 | }, 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /core/server-core/src/util/semaphore.ts: -------------------------------------------------------------------------------- 1 | export class Semaphore { 2 | private count = 0; 3 | private readonly waiting: Array<() => void> = []; 4 | private signals: Array<() => void> = []; 5 | 6 | constructor(private readonly limit: number) {} 7 | 8 | /** 9 | * Releases a token 10 | */ 11 | async release() { 12 | if (this.count === 0) { 13 | throw new Error("Invalid state, count cannot be less than 0"); 14 | } 15 | if (this.waiting.length > 0) { 16 | const Resolve = this.waiting.shift(); 17 | if (Resolve) { 18 | Resolve(); 19 | } 20 | } else { 21 | this.count--; 22 | if (this.count === 0) { 23 | for (const Signal of this.signals) { 24 | Signal(); 25 | } 26 | this.signals = []; 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Returns a promise that awaits when semaphore is empty 33 | * @returns Promise 34 | */ 35 | async signal() { 36 | if (this.count === 0) { 37 | return; 38 | } 39 | return new Promise((resolve) => { 40 | this.signals.push(resolve); 41 | }); 42 | } 43 | 44 | /** 45 | * Acquires a token. 46 | * @returns Returns a promise that awaits whn there is a free slot 47 | */ 48 | async acquire() { 49 | if (this.count < this.limit) { 50 | this.count++; 51 | return; 52 | } 53 | return new Promise((resolve) => { 54 | this.waiting.push(resolve); 55 | }); 56 | } 57 | } 58 | 59 | /** 60 | * Run jobs in parallel with a limit. We do not guarantee order in the returned results. 61 | * @param limit job limit 62 | * @param supplier supplier 63 | * @param items items 64 | * @param voided whether to return void 65 | * @returns 66 | */ 67 | export async function RunInParallelWithLimit( 68 | items: Iterable, 69 | limit: number, 70 | supplier: (input: I, index: number) => Promise, 71 | ): Promise[]> { 72 | const sem = new Semaphore(limit); 73 | const outputs: PromiseSettledResult[] = []; 74 | let count = 0; 75 | for (const item of items) { 76 | await sem.acquire(); 77 | const i = count++; 78 | void supplier(item, i) 79 | .then((value) => (outputs[i] = { status: "fulfilled", value })) 80 | .catch((e) => (outputs[i] = { status: "rejected", reason: e })) 81 | .finally(() => sem.release()); 82 | } 83 | await sem.signal(); 84 | 85 | return outputs; 86 | } 87 | -------------------------------------------------------------------------------- /core/server-core/src/dao/audit_log.ts: -------------------------------------------------------------------------------- 1 | import type { QueryAuditLogRequest } from "@noctf/api/requests"; 2 | import type { DBType } from "../clients/database.ts"; 3 | import { sql } from "kysely"; 4 | import type { AuditLogEntry, LimitOffset } from "@noctf/api/datatypes"; 5 | import { LimitCursorDecoded } from "../types/pagination.ts"; 6 | 7 | export class AuditLogDAO { 8 | constructor(private readonly db: DBType) {} 9 | async create({ 10 | operation, 11 | actor, 12 | entities, 13 | data, 14 | }: Pick) { 15 | return this.db 16 | .insertInto("audit_log") 17 | .values({ 18 | actor, 19 | operation, 20 | entities, 21 | data, 22 | }) 23 | .execute(); 24 | } 25 | 26 | async query( 27 | params?: Parameters[0], 28 | limit?: number, 29 | ): Promise { 30 | let query = this.listQuery(params).select([ 31 | "actor", 32 | "operation", 33 | "entities", 34 | "data", 35 | "created_at", 36 | ]); 37 | 38 | if (limit) { 39 | query = query.limit(limit); 40 | } 41 | return query.orderBy("created_at desc").execute(); 42 | } 43 | 44 | async getCount( 45 | params?: Parameters[0], 46 | ): Promise { 47 | return Number( 48 | ( 49 | await this.listQuery(params) 50 | .select(this.db.fn.countAll().as("count")) 51 | .executeTakeFirstOrThrow() 52 | ).count, 53 | ); 54 | } 55 | 56 | private listQuery({ 57 | created_at, 58 | actor, 59 | entities, 60 | operation, 61 | }: Omit = {}) { 62 | let query = this.db.selectFrom("audit_log"); 63 | 64 | if (created_at) { 65 | if (created_at[0]) query = query.where("created_at", ">=", created_at[0]); 66 | if (created_at[1]) query = query.where("created_at", "<=", created_at[1]); 67 | } 68 | 69 | if (actor && actor.length) { 70 | query = query.where("actor", "in", actor); 71 | } 72 | if (entities && entities.length) { 73 | query = query.where( 74 | "entities", 75 | "&&", 76 | sql`ARRAY[${sql.join(entities)}]`, 77 | ); 78 | } 79 | if (operation && operation.length) { 80 | query = query.where("operation", "like", operation); 81 | } 82 | return query; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apps/web/src/lib/components/challenges/ChallengeModal.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 63 | 64 | {#if visible} 65 |
70 |
75 | {/* @ts-expect-error use directive incorrect typing */ null} 76 |
77 | 78 |
79 |
80 |
81 | {/if} 82 | -------------------------------------------------------------------------------- /migrations/1728179364677_config.ts: -------------------------------------------------------------------------------- 1 | import { sql, type Kysely } from "kysely"; 2 | import { 3 | CreateTableWithDefaultTimestamps, 4 | CreateTriggerUpdatedAt, 5 | } from "./util"; 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | export async function up(db: Kysely): Promise { 9 | const schema = db.schema; 10 | 11 | await sql`CREATE EXTENSION IF NOT EXISTS unaccent`.execute(db); 12 | await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.execute(db); 13 | await sql`CREATE OR REPLACE FUNCTION immutable_unaccent(text) RETURNS text 14 | AS $$ 15 | SELECT public.unaccent('public.unaccent', $1); 16 | $$ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT`.execute(db); 17 | 18 | await sql`CREATE OR REPLACE FUNCTION trigger_updated_at() 19 | RETURNS TRIGGER AS $$ 20 | BEGIN 21 | IF OLD.updated_at = NEW.updated_at THEN 22 | NEW.updated_at = NOW(); 23 | END IF; 24 | RETURN NEW; 25 | END; 26 | $$ language 'plpgsql'`.execute(db); 27 | 28 | await CreateTableWithDefaultTimestamps(schema, "config") 29 | .addColumn("namespace", "varchar", (col) => col.primaryKey()) 30 | .addColumn("value", "jsonb", (col) => col.notNull().defaultTo("{}")) 31 | .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) 32 | .execute(); 33 | await CreateTriggerUpdatedAt("config").execute(db); 34 | 35 | await schema 36 | .createTable("audit_log") 37 | .addColumn("actor", "varchar(64)", (col) => col.notNull()) 38 | .addColumn("operation", "varchar(64)", (col) => col.notNull()) 39 | .addColumn("entities", sql`text[]`, (col) => col.notNull()) 40 | .addColumn("data", "text") 41 | .addColumn("created_at", "timestamptz", (col) => 42 | col.defaultTo(sql`now()`).notNull(), 43 | ) 44 | .execute(); 45 | await schema 46 | .createIndex("audit_log_idx_created_at_actor") 47 | .on("audit_log") 48 | .expression(sql`created_at DESC, actor`) 49 | .execute(); 50 | await schema 51 | .createIndex("audit_log_idx_created_at_operation") 52 | .on("audit_log") 53 | .expression(sql`created_at DESC, operation`) 54 | .execute(); 55 | } 56 | 57 | export async function down(db: Kysely): Promise { 58 | const schema = db.schema; 59 | 60 | await schema.dropTable("audit_log").execute(); 61 | await schema.dropTable("config").execute(); 62 | await sql`DROP FUNCTION trigger_updated_at`.execute(db); 63 | await sql`DROP FUNCTION immutable_unaccent`.execute(db); 64 | await sql`DROP EXTENSION unaccent`.execute(db); 65 | } 66 | -------------------------------------------------------------------------------- /core/mod-auth/src/hash_util.ts: -------------------------------------------------------------------------------- 1 | import type { BinaryLike, ScryptOptions } from "node:crypto"; 2 | import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; 3 | import { promisify } from "node:util"; 4 | 5 | const scryptPromise = promisify< 6 | BinaryLike, 7 | BinaryLike, 8 | number, 9 | ScryptOptions, 10 | Buffer 11 | >(scrypt); 12 | 13 | const SCRYPT_KEYLEN = 64; 14 | const SCRYPT_OPTIONS: ScryptOptions = { 15 | N: 16384, 16 | r: 8, 17 | p: 1, 18 | }; 19 | 20 | const SCryptValidator = async (password: string, parts: string[]) => { 21 | const [optionsStr, saltStr, keyStr] = parts; 22 | let N: number, r: number, p: number; 23 | 24 | for (const [k, v] of optionsStr.split(",").map((opt) => opt.split("="))) { 25 | const numVal = parseInt(v); 26 | if (isNaN(numVal) || !Number.isInteger(numVal) || numVal <= 0) { 27 | throw new Error( 28 | `invalid value for parameter ${k} passed to scrypt validator`, 29 | ); 30 | } 31 | 32 | if (k === "N") { 33 | N = numVal; 34 | } else if (k === "r") { 35 | r = numVal; 36 | } else if (k === "p") { 37 | p = numVal; 38 | } else { 39 | throw new Error(`unrecognized parameter ${k} passed to scrypt validator`); 40 | } 41 | } 42 | if (!N || !r || !p) { 43 | throw new Error("Not all required scrypt parameters were present"); 44 | } 45 | const salt = Buffer.from(saltStr, "base64"); 46 | const key = Buffer.from(keyStr, "base64"); 47 | 48 | const derived = await scryptPromise(password, salt, key.length, { 49 | N, 50 | r, 51 | p, 52 | maxmem: SCRYPT_OPTIONS.maxmem, 53 | }); 54 | return timingSafeEqual(key, derived); 55 | }; 56 | 57 | const VALIDATORS: Record< 58 | string, 59 | (password: string, parts: string[]) => Promise 60 | > = { 61 | scrypt: SCryptValidator, 62 | }; 63 | 64 | export const Generate = async (password: string) => { 65 | const { N, r, p } = SCRYPT_OPTIONS; 66 | const salt = randomBytes(16); 67 | const key = await scryptPromise( 68 | password, 69 | salt, 70 | SCRYPT_KEYLEN, 71 | SCRYPT_OPTIONS, 72 | ); 73 | return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString("base64")}$${key.toString("base64")}`; 74 | }; 75 | 76 | export const Validate = async (password: string, digest: string) => { 77 | const parts = digest.split("$"); 78 | if (parts.length < 3 || !Object.hasOwn(VALIDATORS, parts[1])) { 79 | throw new Error("Invalid digest format"); 80 | } 81 | return VALIDATORS[parts[1]](password, parts.slice(2)); 82 | }; 83 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import containerQueries from "@tailwindcss/container-queries"; 2 | import forms from "@tailwindcss/forms"; 3 | import typography from "@tailwindcss/typography"; 4 | import type { Config } from "tailwindcss"; 5 | import daisyui from "daisyui"; 6 | import daisyuiThemes from "daisyui/src/theming/themes"; 7 | 8 | export default { 9 | content: ["./src/**/*.{html,js,svelte,ts}"], 10 | 11 | theme: { 12 | fontFamily: { 13 | sans: ['"Instrument Sans"', "sans-serif"], 14 | "instrument-sans": ['"Instrument Sans"', "sans-serif"], 15 | }, 16 | extend: { 17 | boxShadow: { 18 | solid: "0px 3px 0 0 var(--base-500)", 19 | }, 20 | colors: { 21 | "base-400": "var(--base-400)", 22 | "base-500": "var(--base-500)", 23 | "diff-beginner": "var(--diff-beginner)", 24 | "diff-easy": "var(--diff-easy)", 25 | "diff-medium": "var(--diff-medium)", 26 | "diff-hard": "var(--diff-hard)", 27 | }, 28 | }, 29 | }, 30 | 31 | plugins: [typography, forms, containerQueries, daisyui], 32 | 33 | daisyui: { 34 | themes: [ 35 | { 36 | light: { 37 | ...daisyuiThemes["light"], 38 | "--base-400": "#8a8b90", 39 | "--base-500": "#3a3b40", 40 | primary: "#706ce4", 41 | secondary: "#6c9ee4", 42 | info: "#5cb4ef", 43 | success: "#72ca72", 44 | warning: "#f2bb54", 45 | error: "#c75658", 46 | "primary-content": "#e6e6f7", 47 | "--diff-beginner": "#8cd5fa", 48 | "--diff-easy": "#a1d593", 49 | "--diff-medium": "#deb475", 50 | "--diff-hard": "#dd534e", 51 | "--rounded-box": "0.5rem", 52 | }, 53 | }, 54 | { 55 | dark: { 56 | primary: "#6ea84f", 57 | secondary: "#65ac69", 58 | accent: "#49d9b1", 59 | neutral: "#020405", 60 | "base-100": "#1a1b1e", 61 | "base-200": "#14151a", 62 | "base-300": "#090b15", 63 | "--base-400": "#040716", 64 | "--base-500": "#010108", 65 | info: "#5fc4e9", 66 | success: "#b3f550", 67 | warning: "#ea9545", 68 | error: "#dc5551", 69 | "--diff-beginner": "#64a7ca", 70 | "--diff-easy": "#4f9840", 71 | "--diff-medium": "#ae6e24", 72 | "--diff-hard": "#ad3b3d", 73 | "--rounded-box": "0.5rem", 74 | }, 75 | }, 76 | ], 77 | }, 78 | } satisfies Config; 79 | -------------------------------------------------------------------------------- /apps/web/src/routes/auth/+page.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | {@render header("Welcome", "Sign in or create an account")} 43 | 44 |
45 |
46 | 49 | 58 |
59 | 60 | 71 |
72 | 73 | {#if oAuthProviders.length} 74 |
OR
75 | 76 |
77 | {#each oAuthProviders as provider} 78 | 85 | {/each} 86 |
87 | {/if} 88 | -------------------------------------------------------------------------------- /apps/web/src/routes/settings/+layout.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | {#snippet tab(id: TabType, title: string, icon: string)} 41 | (activeTab = id)} 49 | > 50 | 51 | 52 | 53 | {/snippet} 54 | 55 |
56 |

Settings

57 | 58 |
59 |
62 | {@render tab("profile", "Profile", "material-symbols:account-circle")} 63 | {@render tab("account", "Account", "material-symbols:person-book")} 64 | {@render tab("security", "Security", "material-symbols:lock")} 65 | {@render tab( 66 | "preferences", 67 | "Preferences", 68 | "material-symbols:palette-outline", 69 | )} 70 |
71 | 72 |
73 |
74 | {@render children()} 75 |
76 |
77 |
78 |
79 | -------------------------------------------------------------------------------- /apps/server/src/util/domain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { CompileDomainMatcher } from "./domain.ts"; 3 | 4 | describe(CompileDomainMatcher, () => { 5 | test.each([ 6 | { pattern: "*.example.com", host: "http://a.example.com", expected: true }, 7 | { pattern: "*.example.com", host: "http://b.example.com", expected: true }, 8 | { pattern: "*.example.com", host: "http://example.com", expected: false }, 9 | { 10 | pattern: "*.example.com", 11 | host: "http://a.b.example.com", 12 | expected: false, 13 | }, 14 | { 15 | pattern: "*.*.example.com", 16 | host: "http://a.b.example.com", 17 | expected: true, 18 | }, 19 | { 20 | pattern: "*.*.example.com", 21 | host: "http://a.example.com", 22 | expected: false, 23 | }, 24 | { pattern: "example.com", host: "http://example.com", expected: true }, 25 | { pattern: "example.com", host: "http://example.com:", expected: false }, 26 | { pattern: "example.com", host: "http://example.com:8080", expected: true }, 27 | { pattern: "example.com", host: "http://a.example.com", expected: false }, 28 | { 29 | pattern: "*.a.example.com", 30 | host: "http://b.a.example.com", 31 | expected: true, 32 | }, 33 | { 34 | pattern: "*.a.example.com", 35 | host: "http://a.example.com", 36 | expected: false, 37 | }, 38 | ])("Single Pattern Tests %s", ({ pattern, host, expected }) => { 39 | expect(!!host.match(CompileDomainMatcher(pattern))).toBe(expected); 40 | }); 41 | 42 | test.each([ 43 | { 44 | patterns: ["*.example.com", "example.org"], 45 | host: "https://a.example.com", 46 | expected: true, 47 | }, 48 | { 49 | patterns: ["*.example.com", "example.org"], 50 | host: "https://example.com", 51 | expected: false, 52 | }, 53 | { 54 | patterns: ["*.example.com", "example.org"], 55 | host: "https://example.org", 56 | expected: true, 57 | }, 58 | { 59 | patterns: ["*.example.com", "example.org"], 60 | host: "https://a.example.org", 61 | expected: false, 62 | }, 63 | { 64 | patterns: ["site.com", "blog.site.com"], 65 | host: "https://blog.site.com", 66 | expected: true, 67 | }, 68 | { 69 | patterns: ["site.com", "blog.site.com"], 70 | host: "https://dev.site.com", 71 | expected: false, 72 | }, 73 | ])("Multi Pattern Tests", ({ patterns, host, expected }) => { 74 | expect(!!host.match(CompileDomainMatcher(patterns))).toBe(expected); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /apps/web/src/lib/components/config/SchemaForm.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | {#if schema?.properties} 39 |
40 | {#if schema.title} 41 |
42 |

{schema.title}

43 | {#if schema.description} 44 |

{schema.description}

45 | {/if} 46 |
47 | {/if} 48 | 49 |
50 | {#each Object.entries(schema.properties) as [fieldName, property]} 51 | {#if property.type === "object"} 52 |
53 | 60 |
61 | {/if} 62 | {/each} 63 | 64 |
65 | {#each Object.entries(schema.properties) as [fieldName, property]} 66 | {#if property.type !== "object"} 67 |
68 | 75 |
76 | {/if} 77 | {/each} 78 |
79 |
80 |
81 | {:else} 82 |
83 | No schema properties found 84 |
85 | {/if} 86 | -------------------------------------------------------------------------------- /core/server-core/src/util/message_compression.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from "node:zlib"; 2 | import { Readable, Writable } from "node:stream"; 3 | 4 | type SetupFn = (i: Readable, o: Writable) => void; 5 | type Method = { 6 | encode: SetupFn; 7 | decode: SetupFn; 8 | }; 9 | 10 | /** 11 | * Compression methods. Always add new methods at the end. Existing methods can 12 | * be changed as long as they are backwards compatible (i.e. same decompressor) 13 | * 14 | * 0: none 15 | * 1: brotli 16 | */ 17 | const COMPRESSION_THRESHOLD_BYTES = 2048; 18 | const METHODS: Method[] = [ 19 | { 20 | encode: (i, o) => { 21 | i.pipe(o); 22 | }, 23 | decode: (i, o) => { 24 | i.pipe(o); 25 | }, 26 | }, 27 | { 28 | encode: (i, o) => { 29 | const c = zlib.createBrotliCompress({ 30 | params: { 31 | [zlib.constants.BROTLI_PARAM_QUALITY]: 32 | zlib.constants.BROTLI_MIN_QUALITY, 33 | }, 34 | }); 35 | i.pipe(c).pipe(o); 36 | }, 37 | decode: (i, o) => { 38 | const c = zlib.createBrotliDecompress(); 39 | i.pipe(c).pipe(o); 40 | }, 41 | }, 42 | ]; 43 | 44 | const DEFAULT_METHOD = 1; 45 | 46 | const DoStream = ( 47 | chunks: Uint8Array[], 48 | i: Uint8Array, 49 | setup: SetupFn, 50 | ): Promise => { 51 | const w = new Writable({ 52 | write(chunk: Buffer, _encoding, cb) { 53 | try { 54 | chunks.push(chunk); 55 | cb(); 56 | } catch (e) { 57 | cb(e); 58 | } 59 | }, 60 | }); 61 | return new Promise((resolve, reject) => { 62 | try { 63 | setup( 64 | Readable.from(Buffer.from(i.buffer, i.byteOffset, i.byteLength)), 65 | w, 66 | ); 67 | w.on("error", (e) => reject(e)); 68 | w.on("finish", () => resolve(Buffer.concat(chunks))); 69 | } catch (e) { 70 | return reject(e); 71 | } 72 | }); 73 | }; 74 | 75 | export const Compress = ( 76 | arr: Uint8Array, 77 | id = DEFAULT_METHOD, 78 | ): Promise => { 79 | let m = id; 80 | if (arr.length < COMPRESSION_THRESHOLD_BYTES) { 81 | m = 0; 82 | } 83 | const method = METHODS[m]; 84 | if (!method) { 85 | throw new Error(`Compression method ${m} does not exist`); 86 | } 87 | return DoStream([Uint8Array.from([m])], arr, method.encode); 88 | }; 89 | 90 | export const Decompress = (arr: Uint8Array): Promise => { 91 | const id = arr[0]; 92 | const method = METHODS[id]; 93 | if (!method) { 94 | throw new Error(`Compression method does not exist`); 95 | } 96 | 97 | return DoStream([], arr.slice(1), method.decode); 98 | }; 99 | -------------------------------------------------------------------------------- /core/server-core/src/util/single_value_cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A cache for a single value that expires after a specified time. 3 | * @template T The type of the cached value 4 | */ 5 | export class SingleValueCache { 6 | private value: T | null = null; 7 | private lastUpdated: number | null = null; 8 | private fetchPromise: Promise | null = null; 9 | 10 | /** 11 | * Creates a new single value cache. 12 | * @param expiryTimeMs The time in milliseconds after which the cached value expires 13 | * @param resolver A function that returns a Promise resolving to the value to cache 14 | * @param options Configuration options for the cache 15 | */ 16 | constructor( 17 | private readonly resolver: () => Promise, 18 | private readonly expiryTimeMs: number, 19 | private readonly allowStale = false, 20 | ) {} 21 | 22 | /** 23 | * Gets the cached value, fetching a new one if needed. 24 | * @returns A Promise resolving to the cached value 25 | */ 26 | async get(): Promise { 27 | if (this.value !== null && this.lastUpdated !== null) { 28 | const elapsed = performance.now() - this.lastUpdated; 29 | if (elapsed < this.expiryTimeMs) { 30 | return this.value; 31 | } 32 | } 33 | 34 | if (this.fetchPromise) { 35 | if (this.value === null || !this.allowStale) return this.fetchPromise; 36 | if (this.allowStale) return this.value; 37 | } 38 | 39 | this.fetchPromise = this.resolver(); 40 | this.fetchPromise 41 | .then((newValue) => { 42 | this.value = newValue; 43 | this.lastUpdated = performance.now(); 44 | this.fetchPromise = null; 45 | return newValue; 46 | }) 47 | .catch(() => { 48 | this.fetchPromise = null; 49 | }); 50 | if (this.value !== null && this.allowStale) { 51 | return this.value; 52 | } 53 | 54 | return this.fetchPromise; 55 | } 56 | 57 | /** 58 | * Returns the time-to-live in milliseconds for the current cached value. 59 | * @returns The TTL in milliseconds, or 0 if expired/no value 60 | */ 61 | ttl(): number { 62 | if (this.value === null || this.lastUpdated === null) { 63 | return 0; 64 | } 65 | 66 | const elapsed = performance.now() - this.lastUpdated; 67 | return Math.max(0, this.expiryTimeMs - elapsed); 68 | } 69 | 70 | /** 71 | * Clears the cached value, forcing the next get() to fetch a new value. 72 | */ 73 | clear(): void { 74 | this.value = null; 75 | this.lastUpdated = null; 76 | this.fetchPromise = null; 77 | } 78 | } 79 | 80 | export default SingleValueCache; 81 | -------------------------------------------------------------------------------- /core/server-core/src/services/lock.ts: -------------------------------------------------------------------------------- 1 | import type { ServiceCradle } from "../index.ts"; 2 | import { nanoid } from "nanoid"; 3 | 4 | type Props = Pick; 5 | 6 | const LEASE_PREFIX = "lease"; 7 | 8 | const SCRIPTS = { 9 | renew: 10 | 'local val = redis.call("GET", KEYS[1]);' + 11 | 'if val == ARGV[1] then redis.call("EXPIRE", KEYS[1], ARGV[2]);return 1;' + 12 | "else return 0 end", 13 | }; 14 | 15 | export class LockServiceError extends Error {} 16 | 17 | export class LockService { 18 | private readonly redisClientFactory; 19 | private readonly logger; 20 | 21 | constructor({ redisClientFactory, logger }: Props) { 22 | this.redisClientFactory = redisClientFactory; 23 | this.logger = logger; 24 | } 25 | 26 | async withLease( 27 | name: string, 28 | handler: () => Promise, 29 | durationSeconds = 10, 30 | ): Promise { 31 | const token = await this.acquireLease(name, durationSeconds); 32 | const timeout = setInterval( 33 | async () => { 34 | try { 35 | await this.renewLease(name, token, durationSeconds); 36 | } catch (e) { 37 | this.logger.warn(e, "Could not renew lease on lock"); 38 | } 39 | }, 40 | (durationSeconds * 1000) / 3, 41 | ); 42 | try { 43 | return await handler(); 44 | } finally { 45 | clearTimeout(timeout); 46 | await this.dropLease(name, token); 47 | } 48 | } 49 | 50 | async acquireLease(name: string, durationSeconds = 10) { 51 | const token = nanoid(); 52 | const client = await this.redisClientFactory.getClient(); 53 | if ( 54 | !(await client.set(`${LEASE_PREFIX}:${name}`, token, { 55 | EX: durationSeconds, 56 | NX: true, 57 | })) 58 | ) { 59 | throw new LockServiceError("lease already exists"); 60 | } 61 | return token; 62 | } 63 | 64 | async renewLease(name: string, token: string, durationSeconds = 10) { 65 | if ( 66 | !(await this.redisClientFactory.executeScript( 67 | SCRIPTS.renew, 68 | [`${LEASE_PREFIX}:${name}`], 69 | [token, durationSeconds.toString()], 70 | )) 71 | ) { 72 | throw new LockServiceError("lease token mismatch"); 73 | } 74 | return durationSeconds; 75 | } 76 | 77 | async dropLease(name: string, token?: string) { 78 | // force 79 | if (!token) { 80 | const client = await this.redisClientFactory.getClient(); 81 | await client.del(`${LEASE_PREFIX}:${name}`); 82 | return; 83 | } 84 | return this.renewLease(name, token, 0); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /core/mod-auth/src/password_provider.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigService } from "@noctf/server-core/services/config"; 2 | import type { AuthMethod } from "@noctf/api/datatypes"; 3 | import type { 4 | IdentityProvider, 5 | IdentityService, 6 | } from "@noctf/server-core/services/identity"; 7 | import { AuthenticationError } from "@noctf/server-core/errors"; 8 | import { Validate } from "./hash_util.ts"; 9 | import type { ServiceCradle } from "@noctf/server-core"; 10 | import { AuthConfig } from "@noctf/api/config"; 11 | import { UserNotFoundError } from "./error.ts"; 12 | 13 | type Props = Pick; 14 | 15 | export class PasswordProvider implements IdentityProvider { 16 | private configService: ConfigService; 17 | private identityService: IdentityService; 18 | 19 | constructor({ configService, identityService }: Props) { 20 | this.configService = configService; 21 | this.identityService = identityService; 22 | 23 | this.identityService.register(this); 24 | } 25 | 26 | id(): string { 27 | return "email"; 28 | } 29 | 30 | async listMethods(): Promise { 31 | if ((await this.getConfig()).enable_login_password) { 32 | return [ 33 | { 34 | provider: "email", 35 | }, 36 | ]; 37 | } 38 | return []; 39 | } 40 | 41 | async authPreCheck(email: string): Promise { 42 | const identity = await this.identityService.getIdentityForProvider( 43 | this.id(), 44 | email, 45 | ); 46 | if (!identity) { 47 | throw new UserNotFoundError(); 48 | } 49 | if (!identity.secret_data) { 50 | throw new AuthenticationError( 51 | "Password sign-in has not been configured for this user.", 52 | ); 53 | } 54 | } 55 | 56 | async authenticate(email: string, password: string): Promise { 57 | const identity = await this.identityService.getIdentityForProvider( 58 | this.id(), 59 | email, 60 | ); 61 | 62 | if ( 63 | !identity || 64 | !identity.secret_data || 65 | !(await Validate(password, identity.secret_data)) 66 | ) { 67 | throw new AuthenticationError("Incorrect email or password"); 68 | } 69 | 70 | return identity.user_id; 71 | } 72 | 73 | async getConfig() { 74 | const { 75 | enable_login_password, 76 | enable_register_password, 77 | validate_email, 78 | allowed_email_domains, 79 | } = (await this.configService.get(AuthConfig)).value; 80 | return { 81 | enable_login_password, 82 | enable_register_password, 83 | validate_email, 84 | allowed_email_domains, 85 | }; 86 | } 87 | } 88 | --------------------------------------------------------------------------------