AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)

This commit was merged in pull request #40.
This commit is contained in:
2026-07-22 20:26:30 +00:00
parent 1a40e5708a
commit 8dcbd48045
41 changed files with 4548 additions and 145 deletions
@@ -0,0 +1,150 @@
import { z } from 'zod';
export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const;
export const PROTOCOLS = ['NC', 'WEB'] as const;
export const CHALLENGE_FORMAT_VERSION = 1;
export const CHALLENGE_FORMAT = 'hipctf-challenges';
export const ChallengeNameSchema = z
.string()
.trim()
.min(1, 'Name is required')
.max(128, 'Name must be 128 characters or fewer');
export const ChallengeDifficultySchema = z.enum(DIFFICULTIES);
export const ChallengeProtocolSchema = z.enum(PROTOCOLS);
export const ChallengeIdParamSchema = z.object({
id: z.string().min(1).max(64),
});
export const ListChallengesQuerySchema = z.object({
q: z.string().trim().min(1).max(128).optional(),
categoryId: z.string().min(1).max(64).optional(),
});
export type ListChallengesQuery = z.infer<typeof ListChallengesQuerySchema>;
export const ChallengeFileManifestSchema = z.object({
id: z.string().optional(),
stagedToken: z.string().min(1).max(128).optional(),
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
remove: z.boolean().optional(),
});
export type ChallengeFileManifest = z.infer<typeof ChallengeFileManifestSchema>;
const portField = z
.number()
.int()
.min(1)
.max(65535)
.nullable()
.optional();
const baseShape = {
descriptionMd: z.string().max(64_000),
categoryId: z.string().min(1).max(64),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1, 'Flag is required').max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
};
export const CreateChallengeSchema = z
.object({
name: ChallengeNameSchema,
...baseShape,
})
.superRefine((val, ctx) => {
if (val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type CreateChallengePayload = z.infer<typeof CreateChallengeSchema>;
export const UpdateChallengeSchema = z
.object({
name: ChallengeNameSchema.optional(),
...baseShape,
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
})
.superRefine((val, ctx) => {
if (val.initialPoints !== undefined && val.minimumPoints !== undefined && val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type UpdateChallengePayload = z.infer<typeof UpdateChallengeSchema>;
export const ImportQuerySchema = z.object({
confirm: z.string().optional(),
});
export const ImportChallengeFileSchema = z.object({
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
dataBase64: z.string().min(1).max(8 * 1024 * 1024),
});
export type ImportChallengeFilePayload = z.infer<typeof ImportChallengeFileSchema>;
export const ImportChallengeSchema = z.object({
name: ChallengeNameSchema,
descriptionMd: z.string().max(64_000),
categorySystemKey: z.string().min(1).max(64),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1).max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ImportChallengeFileSchema).max(64).optional(),
});
export type ImportChallengePayload = z.infer<typeof ImportChallengeSchema>;
export const ImportDocumentSchema = z.object({
format: z.literal(CHALLENGE_FORMAT),
version: z.number().int().min(1).max(CHALLENGE_FORMAT_VERSION),
exportedAt: z.string().min(1),
challenges: z.array(ImportChallengeSchema),
});
export type ImportDocumentPayload = z.infer<typeof ImportDocumentSchema>;
export const StageChallengeFileResponseSchema = z.object({
token: z.string(),
originalFilename: z.string(),
mimeType: z.string(),
sizeBytes: z.number().int(),
});
export type StageChallengeFileResponse = z.infer<typeof StageChallengeFileResponseSchema>;