diff --git a/.gitignore b/.gitignore index 90b3dbef0..33a81db15 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,5 @@ temp/ !.yarn/versions # End of https://www.toptal.com/developers/gitignore/api/yarn,node + +/gitlab \ No newline at end of file diff --git a/README.md b/README.md index f0c895663..ea873c8eb 100755 --- a/README.md +++ b/README.md @@ -142,6 +142,67 @@ The `/create`, `/archive` and `/delete` commands are only accessible when you ha The bot will automatically create more categories when you hit the 50 Discord channel limit, so you can have an almost infinite amount of tasks per CTF. It is your own responsibility to stay below the Discord server channel limit, which is 500 at the moment of writing (categories count as channels). +### Add GitLab integration + +CTFNote can integrate with GitLab to automatically create repositories for CTF tasks, providing version control and collaboration features for your team's work. + +#### What it does + +When enabled, CTFNote will: +- Automatically create a GitLab group for your CTF team (if it doesn't exist) +- Create subgroups for each CTF +- Create repositories for individual tasks within CTF subgroups +- Initialize repositories with README files containing task information + +#### Creating a GitLab Personal Access Token + +To enable GitLab integration, you'll need to create a Personal Access Token: + +1. **Log in to your GitLab instance** (or gitlab.com) + +2. **Navigate to Access Tokens**: + - Click on your avatar in the top-right corner + - Select "Edit profile" or "Preferences" + - In the left sidebar, click "Access Tokens" + +3. **Create a new token**: + - Click "Add new token" + - Enter a descriptive name (e.g., "CTFNote Integration") + - Set an expiration date (optional, but recommended for security) + - Select the following scope + - `api` - Full API access (required for creating groups and repositories) + +4. **Generate and save the token**: + - Click "Create token" + - **Important**: Copy the token immediately - you won't be able to see it again! + +#### Configuration + +Add the following values to your `.env` file: + +``` +USE_GITLAB=true +GITLAB_URL=https://gitlab-instance-url.com +GITLAB_PERSONAL_ACCESS_TOKEN=your-token-here +GITLAB_GROUP_PATH=path-to-all-ctf-subgroups +``` + +Configuration options explained: +- `USE_GITLAB`: Set to `true` to enable GitLab integration +- `GITLAB_URL`: Your GitLab instance URL +- `GITLAB_PERSONAL_ACCESS_TOKEN`: The token you created above +- `GITLAB_GROUP_PATH`: The path for your team's group for ctfs (e.g., `ctfs` or `parent-group/ctfs` for subgroups) + +#### Features + +- **Automatic group creation**: If the specified group doesn't exist, CTFNote will create it automatically +- **Automatic Subgroup creation for a CTF**: CTFNote will create a subgroup for each CTF under the specified group +- **Repository structure**: Each task gets its own repository with: + - README.md containing task description and metadata + - Clean repository name based on task title + - Private visibility by default + + ### Migration If you already have an instance of CTFNote in a previous version and wish to diff --git a/api/.env.dev b/api/.env.dev index 842e34454..99afeea2b 100644 --- a/api/.env.dev +++ b/api/.env.dev @@ -17,4 +17,9 @@ DISCORD_BOT_TOKEN=secret_token DISCORD_SERVER_ID=server_id DISCORD_VOICE_CHANNELS=3 +USE_GITLAB=true +GITLAB_URL=http://localhost:80 # or https://gitlab.com +GITLAB_PERSONAL_ACCESS_TOKEN= +GITLAB_GROUP_PATH=ctfs # optional + WEB_PORT=3000 \ No newline at end of file diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..f10d0728b 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -51,6 +51,14 @@ export type CTFNoteConfig = DeepReadOnly<{ registrationRoleId: string; channelHandleStyle: DiscordChannelHandleStyle; }; + gitlab: { + enabled: boolean; + url: string; + personalAccessToken: string; + groupPath: string; + defaultBranch: string; + visibility: "private" | "internal" | "public"; + }; }>; function getEnv( @@ -112,6 +120,17 @@ const config: CTFNoteConfig = { "agile" ) as DiscordChannelHandleStyle, }, + gitlab: { + enabled: getEnv("USE_GITLAB", "false") === "true", + url: getEnv("GITLAB_URL", "https://gitlab.com"), + personalAccessToken: getEnv("GITLAB_PERSONAL_ACCESS_TOKEN", ""), + groupPath: getEnv("GITLAB_GROUP_PATH", ""), + defaultBranch: getEnv("GITLAB_DEFAULT_BRANCH", "main"), + visibility: getEnv("GITLAB_VISIBILITY", "private") as + | "private" + | "internal" + | "public", + }, }; export default config; diff --git a/api/src/gitlab/client.ts b/api/src/gitlab/client.ts new file mode 100644 index 000000000..bd2092918 --- /dev/null +++ b/api/src/gitlab/client.ts @@ -0,0 +1,202 @@ +import axios, { AxiosInstance } from "axios"; +import config from "../config"; + +export interface GitLabGroup { + id: number; + name: string; + path: string; + full_path: string; + parent_id?: number; +} + +export class GitLabClient { + private client: AxiosInstance; + private groupId: number | null = null; + private connected: boolean = false; + + constructor() { + this.client = axios.create({ + baseURL: `${config.gitlab.url}/api/v4`, + headers: { + "PRIVATE-TOKEN": config.gitlab.personalAccessToken, + "Content-Type": "application/json", + }, + }); + } + + async connect(): Promise { + if (!config.gitlab.enabled) { + console.log("GitLab integration is disabled"); + return; + } + + if (!config.gitlab.url || !config.gitlab.personalAccessToken) { + console.error("GitLab configuration is incomplete"); + return; + } + + try { + console.log("Connecting to GitLab..."); + + // Test authentication by getting current user + const userResponse = await this.client.get("/user"); + const user = userResponse.data; + + console.log(`Authenticated as GitLab user: ${user.username}`); + + // Find or create the group + if (config.gitlab.groupPath) { + try { + const groupResponse = await this.client.get( + `/groups/${encodeURIComponent(config.gitlab.groupPath)}` + ); + const group = groupResponse.data; + this.groupId = group.id; + console.log(`Using GitLab group: ${group.full_path} (ID: ${group.id})`); + } catch (error) { + const err = error as { response?: { status?: number } }; + if (err.response?.status === 404) { + // Group doesn't exist, try to create it + console.log(`Group '${config.gitlab.groupPath}' not found, attempting to create it...`); + + // Parse the group path to determine if it's a subgroup + const pathParts = config.gitlab.groupPath.split('/'); + + if (pathParts.length === 1) { + // Top-level group + try { + const createResponse = await this.client.post('/groups', { + path: pathParts[0], + name: pathParts[0], + visibility: 'private' + }); + const newGroup = createResponse.data; + this.groupId = newGroup.id; + console.log(`✓ Created GitLab group: ${newGroup.full_path} (ID: ${newGroup.id})`); + } catch (createError) { + console.error("Failed to create GitLab group:", createError); + throw createError; + } + } else { + // Subgroup - need to find parent and create subgroup + const parentPath = pathParts.slice(0, -1).join('/'); + const subgroupName = pathParts[pathParts.length - 1]; + + try { + // Get parent group + const parentResponse = await this.client.get( + `/groups/${encodeURIComponent(parentPath)}` + ); + const parentGroup = parentResponse.data; + + // Create subgroup + const createResponse = await this.client.post('/groups', { + path: subgroupName, + name: subgroupName, + parent_id: parentGroup.id, + visibility: 'private' + }); + const newGroup = createResponse.data; + this.groupId = newGroup.id; + console.log(`✓ Created GitLab subgroup: ${newGroup.full_path} (ID: ${newGroup.id})`); + } catch (createError) { + console.error("Failed to create GitLab subgroup:", createError); + throw createError; + } + } + } else { + throw error; + } + } + } else { + console.log( + "No GitLab group specified, repositories will be created in user namespace" + ); + } + + this.connected = true; + } catch (error) { + console.error("Failed to connect to GitLab:", error); + const err = error as { response?: { status?: number } }; + if (err.response?.status === 401) { + console.error( + "Authentication failed - check your personal access token" + ); + } else if (err.response?.status === 404) { + console.error("Group not found - check GITLAB_GROUP_PATH"); + } + this.connected = false; + } + } + + isConnected(): boolean { + return this.connected; + } + + getClient(): AxiosInstance { + return this.client; + } + + getGroupId(): number | null { + return this.groupId; + } + + async createGroup(name: string, path: string, parentId?: number): Promise { + if (!this.connected) { + console.error("GitLab client is not connected"); + return null; + } + + try { + const groupData: Record = { + name: name, + path: path, + visibility: config.gitlab.visibility, + }; + + // If parentId is provided, create as subgroup + if (parentId) { + groupData.parent_id = parentId; + } + + const response = await this.client.post("/groups", groupData); + + console.log(`Created GitLab group: ${response.data.full_path}`); + return response.data; + } catch (error) { + console.error("Failed to create GitLab group:", error); + const err = error as { response?: { data?: unknown; status?: number } }; + if (err.response?.status === 400) { + console.error("Bad request - group may already exist or invalid parameters"); + if (err.response?.data) { + console.error("Error details:", err.response.data); + } + } + return null; + } + } + + async getGroup(path: string, parentId?: number): Promise { + if (!this.connected) { + return null; + } + + try { + let fullPath = path; + + // If parentId is provided, get the full path including parent + if (parentId) { + const parentGroup = await this.client.get(`/groups/${parentId}`); + fullPath = `${parentGroup.data.full_path}/${path}`; + } + + const response = await this.client.get(`/groups/${encodeURIComponent(fullPath)}`); + return response.data; + } catch (error) { + // Group doesn't exist, which is fine + return null; + } + } +} + +export const gitlabClient = new GitLabClient(); diff --git a/api/src/gitlab/postgraphileHooks.ts b/api/src/gitlab/postgraphileHooks.ts new file mode 100644 index 000000000..a65dc2333 --- /dev/null +++ b/api/src/gitlab/postgraphileHooks.ts @@ -0,0 +1,216 @@ +import { Build, Context, SchemaBuilder } from "postgraphile"; +import { GraphQLResolveInfoWithMessages } from "@graphile/operation-hooks"; +import { gitlabClient } from "./client"; +import { gitlabRepositoryManager } from "./repositories"; +import config from "../config"; +import { PoolClient } from "pg"; + +interface TaskData { + id: bigint; + ctf_id: bigint; + title: string; + description: string; + flag: string; +} + +interface CTFData { + id: bigint; + title: string; + description?: string; + start_time?: Date; + end_time?: Date; +} + +async function getCtfFromDatabase( + ctfId: bigint, + pgClient: PoolClient +): Promise { + const result = await pgClient.query( + "SELECT * FROM ctfnote.ctf WHERE id = $1", + [ctfId] + ); + return result.rows[0] || null; +} + +async function getTaskFromDatabase( + ctfId: bigint, + title: string, + pgClient: PoolClient +): Promise { + try { + const result = await pgClient.query( + "SELECT id, ctf_id, title, description, flag FROM ctfnote.task WHERE ctf_id = $1 AND title = $2 ORDER BY id DESC LIMIT 1", + [ctfId, title] + ); + + if (result.rows.length === 0) return null; + + return result.rows[0]; + } catch (error) { + console.error("Failed to get task from database:", error); + return null; + } +} + +const gitlabMutationHook = + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_build: Build) => (fieldContext: Context) => { + const { + scope: { isRootMutation }, + } = fieldContext; + + if (!isRootMutation) return null; + + if (!config.gitlab.enabled) return null; + + const relevantMutations = ["createCtf", "createTask"]; + + if (!relevantMutations.includes(fieldContext.scope.fieldName)) { + return null; + } + + const gitlabMutationHandler = async ( + input: unknown, + args: Record, + context: { pgClient: PoolClient }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _resolveInfo: GraphQLResolveInfoWithMessages + ) => { + console.log( + `GitLab hook triggered for mutation: ${fieldContext.scope.fieldName}` + ); + try { + switch (fieldContext.scope.fieldName) { + case "createCtf": { + console.log("Processing createCtf mutation for GitLab"); + + const ctfArgs = args as { + input: { ctf: { title: string; description?: string } }; + }; + const title = ctfArgs.input.ctf.title; + + console.log(`Creating GitLab group for new CTF: ${title}`); + + // Get CTF from database (it should exist after the mutation) + const result = await context.pgClient.query( + "SELECT * FROM ctfnote.ctf WHERE title = $1 ORDER BY id DESC LIMIT 1", + [title] + ); + + if (result.rows.length > 0) { + const ctf = result.rows[0]; + + // Create group asynchronously to not block the mutation + gitlabRepositoryManager + .createOrGetCtfGroup({ + id: ctf.id, + title: ctf.title, + description: ctf.description, + startTime: ctf.start_time, + endTime: ctf.end_time, + }) + .catch((error) => { + console.error("Failed to create GitLab group for CTF:", error); + }); + } + + break; + } + case "createTask": { + console.log("Processing createTask mutation for GitLab"); + + const taskArgs = args as { + input: { ctfId: number; title: string }; + }; + const ctfId = taskArgs.input.ctfId; + const title = taskArgs.input.title; + + console.log( + `Looking for task with title: ${title} in CTF ${ctfId}` + ); + + // Get task from database (it should exist after the mutation) + const task = await getTaskFromDatabase( + BigInt(ctfId), + title, + context.pgClient + ); + if (!task) { + console.error(`Task not found in database: ${title}`); + break; + } + + const ctf = await getCtfFromDatabase(task.ctf_id, context.pgClient); + if (!ctf) { + console.error(`CTF not found for task: ${task.title}`); + break; + } + + console.log( + `Creating GitLab repository for task: ${task.title} in CTF: ${ctf.title}` + ); + + // Create repository asynchronously to not block the mutation + gitlabRepositoryManager + .createRepositoryForTask( + { + id: task.id, + ctf_id: task.ctf_id, + title: task.title, + description: task.description || "", + flag: task.flag || "", + }, + { + id: ctf.id, + title: ctf.title, + description: ctf.description, + startTime: ctf.start_time, + endTime: ctf.end_time, + } + ) + .catch((error) => { + console.error("Failed to create GitLab repository:", error); + }); + + break; + } + } + } catch (error) { + console.error("GitLab hook error:", error); + } + + return input; + }; + + return { + after: [ + { + priority: 500, + callback: gitlabMutationHandler, + }, + ], + }; + }; + +export default async function gitlabPostgraphileHooks( + builder: SchemaBuilder +): Promise { + if (!config.gitlab.enabled) { + console.log("GitLab integration is disabled"); + return; + } + + console.log("Initializing GitLab hooks..."); + await gitlabClient.connect(); + + if (!gitlabClient.isConnected()) { + console.error("Failed to initialize GitLab hooks - client not connected"); + return; + } + + builder.hook("init", (_, build) => { + console.log("Adding GitLab operation hooks to GraphQL"); + build.addOperationHook(gitlabMutationHook(build)); + return _; + }); +} diff --git a/api/src/gitlab/repositories.ts b/api/src/gitlab/repositories.ts new file mode 100644 index 000000000..707219e60 --- /dev/null +++ b/api/src/gitlab/repositories.ts @@ -0,0 +1,332 @@ +import { gitlabClient } from "./client"; +import config from "../config"; +import { safeSlugify } from "../utils/utils"; + +export interface CTF { + id: bigint; + title: string; + description?: string; + startTime?: Date; + endTime?: Date; +} + +export interface Task { + id: bigint; + ctf_id: bigint; + title: string; + description: string; + flag: string; +} + +export interface GitLabRepository { + id: number; + name: string; + path: string; + web_url: string; + ssh_url_to_repo: string; + http_url_to_repo: string; +} + +export class GitLabRepositoryManager { + private ctfGroupCache = new Map(); + private normalizeRepoName(name: string): string { + return safeSlugify(name) + .toLowerCase() + .replace(/[^a-z0-9-_]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 100); + } + + private normalizeGroupName(name: string): string { + return safeSlugify(name) + .toLowerCase() + .replace(/[^a-z0-9-_]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 100); + } + + private getRepoName(task: Task): string { + // Just use the task name since it will be in the CTF's group + return this.normalizeRepoName(task.title).substring(0, 100); + } + + private generateReadmeContent(ctf: CTF, task: Task): string { + const ctfnoteUrl = this.getTaskUrl(ctf, task); + + return `# ${task.title} + +## CTF: ${ctf.title} + +${task.description || "No description provided"} + +## Task Information + +- **CTF**: ${ctf.title} +- **Task**: ${task.title} +- **Created**: ${new Date().toISOString()} +${ctf.startTime ? `- **CTF Start**: ${ctf.startTime.toISOString()}` : ""} +${ctf.endTime ? `- **CTF End**: ${ctf.endTime.toISOString()}` : ""} + +## CTFNote Link + +[View in CTFNote](${ctfnoteUrl}) + +## Getting Started + +1. Clone this repository +2. Work on the challenge +3. Document your findings +4. Share solutions with your team + +## Directory Structure + +\`\`\` +. +├── README.md # This file +├── challenge/ # Challenge files +├── solution/ # Your solution +├── notes/ # Working notes +└── scripts/ # Helper scripts +\`\`\` + +--- +*This repository was automatically created by CTFNote* +`; + } + + async createOrGetCtfGroup(ctf: CTF): Promise { + if (!gitlabClient.isConnected()) { + console.error("GitLab client is not connected"); + return null; + } + + // Check cache first + if (this.ctfGroupCache.has(ctf.id)) { + return this.ctfGroupCache.get(ctf.id) || null; + } + + try { + const ctfGroupPath = this.normalizeGroupName(ctf.title); + const parentGroupId = gitlabClient.getGroupId(); + + // Check if CTF group already exists + let ctfGroup = await gitlabClient.getGroup(ctfGroupPath, parentGroupId || undefined); + + if (!ctfGroup) { + // Create the CTF group + console.log(`Creating GitLab group for CTF: ${ctf.title}`); + ctfGroup = await gitlabClient.createGroup( + ctf.title, + ctfGroupPath, + parentGroupId || undefined // Will be undefined if no parent group is configured + ); + + if (!ctfGroup) { + console.error(`Failed to create group for CTF: ${ctf.title}`); + return null; + } + + console.log(`Created GitLab group: ${ctfGroup.full_path}`); + } else { + console.log(`Using existing GitLab group: ${ctfGroup.full_path}`); + } + + // Cache the group ID + this.ctfGroupCache.set(ctf.id, ctfGroup.id); + return ctfGroup.id; + } catch (error) { + console.error(`Failed to create/get CTF group for ${ctf.title}:`, error); + return null; + } + } + + async createRepositoryForTask( + task: Task, + ctf: CTF + ): Promise { + if (!gitlabClient.isConnected()) { + console.error("GitLab client is not connected"); + return null; + } + + try { + console.log(`Creating GitLab repository for task: ${task.title}`); + + // First, ensure the CTF group exists + const ctfGroupId = await this.createOrGetCtfGroup(ctf); + if (!ctfGroupId) { + console.error(`Failed to get/create group for CTF: ${ctf.title}`); + return null; + } + + const repoName = this.getRepoName(task); + const client = gitlabClient.getClient(); + + // Check if repository already exists in the CTF group + const existingRepo = await this.findRepository(repoName, ctfGroupId); + if (existingRepo) { + console.log(`Repository ${repoName} already exists in CTF group`); + return existingRepo; + } + + // Prepare repository data + const repoData: Record = { + name: repoName, + description: `Task: ${task.title} | CTF: ${ctf.title}`, + visibility: config.gitlab.visibility, + initialize_with_readme: true, + default_branch: config.gitlab.defaultBranch, + }; + + // Use the CTF group as the namespace + repoData.namespace_id = ctfGroupId; + + // Create the repository + const response = await client.post("/projects", repoData); + const repository = response.data; + + console.log(`Created repository: ${repository.path_with_namespace}`); + + // Update README with detailed content + try { + const readmeContent = this.generateReadmeContent(ctf, task); + await this.updateFile( + repository.id, + "README.md", + readmeContent, + "Update README with task details" + ); + + // Create directory structure + await this.createDirectoryStructure(repository.id); + } catch (error) { + console.error("Failed to update repository content:", error); + // Don't fail the whole operation if we can't update the README + } + + console.log(`Successfully created repository for task: ${task.title}`); + console.log(`Repository URL: ${repository.web_url}`); + + return repository; + } catch (error) { + console.error( + `Failed to create repository for task ${task.title}:`, + error + ); + + const err = error as { response?: { status?: number } }; + if (err.response?.status === 400) { + console.error("Bad request - check repository name and parameters"); + } else if (err.response?.status === 401) { + console.error( + "Authentication failed - check GitLab personal access token" + ); + } else if (err.response?.status === 403) { + console.error("Permission denied - check token has 'api' scope"); + } + + return null; + } + } + + private async findRepository( + name: string, + groupId: number | null + ): Promise { + try { + const client = gitlabClient.getClient(); + + // Search within the group if specified + const searchParams = { + search: name, + simple: true, + }; + + if (groupId) { + const response = await client.get(`/groups/${groupId}/projects`, { + params: searchParams, + }); + + const repos = response.data as GitLabRepository[]; + const repo = repos.find((r) => r.path === name); + return repo || null; + } else { + // Search in user's projects + const response = await client.get("/projects", { + params: { ...searchParams, owned: true }, + }); + + const repos = response.data as GitLabRepository[]; + const repo = repos.find((r) => r.path === name); + return repo || null; + } + } catch (error) { + // Repository doesn't exist, which is fine + return null; + } + } + + private async updateFile( + projectId: number, + filePath: string, + content: string, + commitMessage: string + ): Promise { + try { + const client = gitlabClient.getClient(); + const encodedContent = Buffer.from(content).toString("base64"); + + await client.put( + `/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}`, + { + branch: config.gitlab.defaultBranch, + content: encodedContent, + commit_message: commitMessage, + encoding: "base64", + } + ); + } catch (error) { + console.error(`Failed to update file ${filePath}:`, error); + throw error; + } + } + + private async createDirectoryStructure(projectId: number): Promise { + const directories = ["challenge", "solution", "notes", "scripts"]; + const client = gitlabClient.getClient(); + + for (const dir of directories) { + try { + // Create a .gitkeep file in each directory + const filePath = `${dir}/.gitkeep`; + const encodedContent = Buffer.from("").toString("base64"); + + await client.post( + `/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}`, + { + branch: config.gitlab.defaultBranch, + content: encodedContent, + commit_message: `Create ${dir} directory`, + encoding: "base64", + } + ); + } catch (error) { + console.error(`Failed to create directory ${dir}:`, error); + // Continue with other directories even if one fails + } + } + } + + private getTaskUrl(ctf: CTF, task: Task): string { + if (!config.pad.domain) return ""; + + const ssl = config.pad.useSSL === "false" ? "" : "s"; + return `http${ssl}://${config.pad.domain}/#/ctf/${ctf.id}-${safeSlugify( + ctf.title + )}/task/${task.id}`; + } +} + +export const gitlabRepositoryManager = new GitLabRepositoryManager(); diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..a87b94738 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -20,6 +20,7 @@ import ConnectionFilterPlugin from "postgraphile-plugin-connection-filter"; import OperationHook from "@graphile/operation-hooks"; import discordHooks from "./discord/hooks"; import { initDiscordBot } from "./discord"; +import gitlabHooks from "./gitlab/postgraphileHooks"; import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin"; @@ -61,6 +62,7 @@ function createOptions() { createTasKPlugin, ConnectionFilterPlugin, discordHooks, + gitlabHooks, PgManyToManyPlugin, ProfileSubscriptionPlugin, ], diff --git a/api/test-gitlab-connection.ts b/api/test-gitlab-connection.ts new file mode 100644 index 000000000..40f4b963c --- /dev/null +++ b/api/test-gitlab-connection.ts @@ -0,0 +1,155 @@ +import axios from "axios"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testGitLabConnection() { + console.log("=== Testing GitLab Connection ===\n"); + + const GITLAB_URL = process.env.GITLAB_URL || "https://gitlab.com"; + const GITLAB_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; + const GITLAB_GROUP = process.env.GITLAB_GROUP_PATH; + + console.log(`GitLab URL: ${GITLAB_URL}`); + console.log(`GitLab Token: ${GITLAB_TOKEN ? "***" + GITLAB_TOKEN.slice(-4) : "NOT SET"}`); + console.log(`GitLab Group: ${GITLAB_GROUP || "NOT SET"}`); + console.log(""); + + if (!GITLAB_TOKEN) { + console.error("❌ GITLAB_PERSONAL_ACCESS_TOKEN is not set in environment"); + console.log("\nTo create a GitLab personal access token:"); + console.log("1. Go to GitLab -> Settings -> Access Tokens"); + console.log("2. Create a token with 'api' scope"); + console.log("3. Set it as GITLAB_PERSONAL_ACCESS_TOKEN in your .env file"); + process.exit(1); + } + + try { + // Test authentication + console.log("1. Testing authentication..."); + const userResponse = await axios.get(`${GITLAB_URL}/api/v4/user`, { + headers: { + "PRIVATE-TOKEN": GITLAB_TOKEN, + }, + }); + + const user = userResponse.data; + console.log(`✓ Authenticated as: ${user.username} (${user.name})`); + console.log(` Email: ${user.email}`); + console.log(` ID: ${user.id}`); + + // Test group access if specified + if (GITLAB_GROUP) { + console.log(`\n2. Testing group access: ${GITLAB_GROUP}...`); + + try { + const groupResponse = await axios.get( + `${GITLAB_URL}/api/v4/groups/${encodeURIComponent(GITLAB_GROUP)}`, + { + headers: { + "PRIVATE-TOKEN": GITLAB_TOKEN, + }, + } + ); + + const group = groupResponse.data; + console.log(`✓ Group found: ${group.full_path}`); + console.log(` Name: ${group.name}`); + console.log(` ID: ${group.id}`); + console.log(` Visibility: ${group.visibility}`); + + } catch (error: unknown) { + const axiosError = error as { response?: { status?: number } }; + if (axiosError.response?.status === 404) { + console.error(`❌ Group not found: ${GITLAB_GROUP}`); + console.log("\nMake sure:"); + console.log("1. The group path is correct (use full path like 'myorg/mygroup')"); + console.log("2. You have access to the group"); + } else { + throw error; + } + } + } else { + console.log("\n2. No group specified - repositories will be created in your personal namespace"); + } + + // Test repository creation permissions + console.log("\n3. Testing repository creation permissions..."); + const testRepoName = `test-permissions-${Date.now()}`; + + try { + const createResponse = await axios.post( + `${GITLAB_URL}/api/v4/projects`, + { + name: testRepoName, + visibility: "private", + }, + { + headers: { + "PRIVATE-TOKEN": GITLAB_TOKEN, + "Content-Type": "application/json", + }, + } + ); + + const repo = createResponse.data; + console.log(`✓ Can create repositories`); + console.log(` Created test repo: ${repo.path_with_namespace}`); + + // Delete the test repository + await axios.delete(`${GITLAB_URL}/api/v4/projects/${repo.id}`, { + headers: { + "PRIVATE-TOKEN": GITLAB_TOKEN, + }, + }); + console.log(" ✓ Deleted test repository"); + + } catch (error: unknown) { + console.error("❌ Cannot create repositories"); + const axiosError = error as { response?: { data?: unknown } }; + if (axiosError.response?.data) { + console.error("Error:", axiosError.response.data); + } + } + + console.log("\n✅ GitLab connection test successful!"); + console.log("\nYou can now set these environment variables:"); + console.log(`USE_GITLAB=true`); + console.log(`GITLAB_URL=${GITLAB_URL}`); + console.log(`GITLAB_PERSONAL_ACCESS_TOKEN=your-token`); + if (GITLAB_GROUP) { + console.log(`GITLAB_GROUP_PATH=${GITLAB_GROUP}`); + } + + } catch (error: unknown) { + console.error("\n❌ Connection test failed:"); + + const axiosError = error as { + response?: { + status?: number; + data?: unknown + }; + message?: string; + }; + + if (axiosError.response?.status === 401) { + console.error("Authentication failed - check your personal access token"); + } else if (axiosError.response?.data) { + console.error("GitLab API error:", axiosError.response.data); + } else if (axiosError.message) { + console.error(axiosError.message); + } else { + console.error("Unknown error:", error); + } + + process.exit(1); + } +} + +// Run the test +testGitLabConnection() + .then(() => process.exit(0)) + .catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/api/test-gitlab-ctf-groups.ts b/api/test-gitlab-ctf-groups.ts new file mode 100644 index 000000000..e6fb4f035 --- /dev/null +++ b/api/test-gitlab-ctf-groups.ts @@ -0,0 +1,111 @@ +import { gitlabClient } from "./src/gitlab/client"; +import { gitlabRepositoryManager } from "./src/gitlab/repositories"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testGitLabCTFGroups() { + console.log("=== Testing GitLab CTF Group Creation ===\n"); + + try { + // Connect to GitLab + console.log("1. Connecting to GitLab..."); + await gitlabClient.connect(); + + if (!gitlabClient.isConnected()) { + throw new Error("Failed to connect to GitLab"); + } + + console.log("✓ Connected successfully"); + const parentGroupId = gitlabClient.getGroupId(); + console.log(` Parent Group ID: ${parentGroupId || "None (will create at root level)"}`); + + // Create a test CTF + const testCtf = { + id: BigInt(999), + title: `Test CTF ${Date.now()}`, + description: "This is a test CTF for GitLab group integration", + startTime: new Date(), + endTime: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now + }; + + console.log(`\n2. Creating group for CTF: ${testCtf.title}`); + const ctfGroupId = await gitlabRepositoryManager.createOrGetCtfGroup(testCtf); + + if (ctfGroupId) { + console.log(`✓ CTF group created/found with ID: ${ctfGroupId}`); + + // Now create a task in this CTF + const testTask = { + id: BigInt(999), + ctf_id: testCtf.id, + title: `Test Task ${Date.now()}`, + description: "This is a test task for repository creation in CTF group", + flag: "", + }; + + console.log(`\n3. Creating repository for task: ${testTask.title}`); + const repository = await gitlabRepositoryManager.createRepositoryForTask(testTask, testCtf); + + if (repository) { + console.log("\n✓ Repository created successfully in CTF group!"); + console.log(` Name: ${repository.name}`); + console.log(` Path: ${repository.path}`); + console.log(` Web URL: ${repository.web_url}`); + console.log(` SSH URL: ${repository.ssh_url_to_repo}`); + console.log(` HTTP URL: ${repository.http_url_to_repo}`); + + // Optionally delete the test repository and group + console.log("\n4. Cleaning up..."); + const readline = await import("readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question( + "Do you want to delete the test repository and group? (y/n): ", + async (answer: string) => { + if (answer.toLowerCase() === "y") { + try { + const client = gitlabClient.getClient(); + // Delete the repository + await client.delete(`/projects/${repository.id}`); + console.log("✓ Test repository deleted"); + + // Delete the CTF group + await client.delete(`/groups/${ctfGroupId}`); + console.log("✓ Test CTF group deleted"); + } catch (error) { + console.error("Failed to delete test resources:", error); + } + } else { + console.log("Test resources kept. You can delete them manually later."); + } + rl.close(); + process.exit(0); + } + ); + } else { + console.log("❌ Failed to create repository"); + process.exit(1); + } + } else { + console.log("❌ Failed to create CTF group"); + process.exit(1); + } + + } catch (error) { + console.error("\n❌ Test failed:"); + console.error(error); + + const err = error as { response?: { data?: unknown } }; + if (err.response?.data) { + console.error("GitLab API response:", err.response.data); + } + process.exit(1); + } +} + +// Run the test +testGitLabCTFGroups(); \ No newline at end of file diff --git a/api/test-gitlab-no-parent.ts b/api/test-gitlab-no-parent.ts new file mode 100644 index 000000000..28261afee --- /dev/null +++ b/api/test-gitlab-no-parent.ts @@ -0,0 +1,91 @@ +import { gitlabClient } from "./src/gitlab/client"; +import { gitlabRepositoryManager } from "./src/gitlab/repositories"; +import dotenv from "dotenv"; + +dotenv.config(); + +// Temporarily disable the parent group +process.env.GITLAB_GROUP_PATH = ""; + +async function testGitLabNoParent() { + console.log("=== Testing GitLab CTF Group Creation (No Parent) ===\n"); + + try { + // Connect to GitLab + console.log("1. Connecting to GitLab..."); + await gitlabClient.connect(); + + if (!gitlabClient.isConnected()) { + throw new Error("Failed to connect to GitLab"); + } + + console.log("✓ Connected successfully"); + const parentGroupId = gitlabClient.getGroupId(); + console.log(` Parent Group ID: ${parentGroupId || "None (will create at root level)"}`); + + // Create a test CTF + const testCtf = { + id: BigInt(888), + title: `Root CTF ${Date.now()}`, + description: "This is a test CTF at root level", + startTime: new Date(), + endTime: new Date(Date.now() + 24 * 60 * 60 * 1000), + }; + + console.log(`\n2. Creating group for CTF: ${testCtf.title}`); + const ctfGroupId = await gitlabRepositoryManager.createOrGetCtfGroup(testCtf); + + if (ctfGroupId) { + console.log(`✓ CTF group created at root level with ID: ${ctfGroupId}`); + + // Create a task + const testTask = { + id: BigInt(888), + ctf_id: testCtf.id, + title: `Root Task ${Date.now()}`, + description: "Test task in root CTF group", + flag: "", + }; + + console.log(`\n3. Creating repository for task: ${testTask.title}`); + const repository = await gitlabRepositoryManager.createRepositoryForTask(testTask, testCtf); + + if (repository) { + console.log("\n✓ Repository created in root CTF group!"); + console.log(` Web URL: ${repository.web_url}`); + + // Clean up + const readline = await import("readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question( + "Delete test resources? (y/n): ", + async (answer: string) => { + if (answer.toLowerCase() === "y") { + try { + const client = gitlabClient.getClient(); + await client.delete(`/projects/${repository.id}`); + await client.delete(`/groups/${ctfGroupId}`); + console.log("✓ Cleaned up"); + } catch (error) { + console.error("Failed to delete:", error); + } + } + rl.close(); + process.exit(0); + } + ); + } + } + + } catch (error) { + console.error("\n❌ Test failed:"); + console.error(error); + process.exit(1); + } +} + +testGitLabNoParent(); \ No newline at end of file diff --git a/api/test-gitlab-repository.ts b/api/test-gitlab-repository.ts new file mode 100644 index 000000000..64f391747 --- /dev/null +++ b/api/test-gitlab-repository.ts @@ -0,0 +1,125 @@ +import { gitlabClient } from "./src/gitlab/client"; +import { gitlabRepositoryManager } from "./src/gitlab/repositories"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testGitLabRepository() { + console.log("=== Testing GitLab Repository Creation ===\n"); + + try { + // Connect to GitLab + console.log("1. Connecting to GitLab..."); + await gitlabClient.connect(); + + if (!gitlabClient.isConnected()) { + throw new Error("Failed to connect to GitLab"); + } + + console.log("✓ Connected successfully"); + console.log(` Group ID: ${gitlabClient.getGroupId() || "Using user namespace"}`); + + // Create a test CTF and task + const testCtf = { + id: BigInt(999), + title: `Test CTF ${Date.now()}`, + description: "This is a test CTF for GitLab integration", + startTime: new Date(), + endTime: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now + }; + + const testTask = { + id: BigInt(999), + ctf_id: testCtf.id, + title: `Test Task ${Date.now()}`, + description: "This is a test task for repository creation\n\nIt has multiple lines\nand some **markdown**", + flag: "", + }; + + console.log(`\n2. Creating repository for task: ${testTask.title}`); + const repository = await gitlabRepositoryManager.createRepositoryForTask(testTask, testCtf); + + if (repository) { + console.log("\n✓ Repository created successfully!"); + console.log(` Name: ${repository.name}`); + console.log(` Path: ${repository.path}`); + console.log(` Web URL: ${repository.web_url}`); + console.log(` SSH URL: ${repository.ssh_url_to_repo}`); + console.log(` HTTP URL: ${repository.http_url_to_repo}`); + + // List repository files + console.log("\n3. Checking repository contents..."); + const client = gitlabClient.getClient(); + + try { + const treeResponse = await client.get( + `/projects/${repository.id}/repository/tree` + ); + + console.log("\nRepository files:"); + const items = treeResponse.data as Array<{ path: string; type: string }>; + items.forEach((item) => { + console.log(` - ${item.path} (${item.type})`); + }); + + // Read README content + const readmeResponse = await client.get( + `/projects/${repository.id}/repository/files/README.md/raw`, + { + params: { ref: "main" }, + } + ); + + console.log("\nREADME.md content preview:"); + console.log("---"); + console.log(readmeResponse.data.substring(0, 500) + "..."); + console.log("---"); + + } catch (error) { + console.error("Failed to list repository contents:", error); + } + + // Optionally delete the test repository + console.log("\n4. Cleaning up..."); + const readline = await import("readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question( + "Do you want to delete the test repository? (y/n): ", + async (answer: string) => { + if (answer.toLowerCase() === "y") { + try { + await client.delete(`/projects/${repository.id}`); + console.log("✓ Test repository deleted"); + } catch (error) { + console.error("Failed to delete repository:", error); + } + } else { + console.log("Repository kept. You can delete it manually later."); + } + rl.close(); + process.exit(0); + } + ); + } else { + console.log("❌ Failed to create repository"); + process.exit(1); + } + + } catch (error) { + console.error("\n❌ Test failed:"); + console.error(error); + + const err = error as { response?: { data?: unknown } }; + if (err.response?.data) { + console.error("GitLab API response:", err.response.data); + } + process.exit(1); + } +} + +// Run the test +testGitLabRepository(); \ No newline at end of file diff --git a/front/.yarn/cache/fsevents-patch-6b67494872-10.zip b/front/.yarn/cache/fsevents-patch-6b67494872-10.zip new file mode 100644 index 000000000..9887ada72 Binary files /dev/null and b/front/.yarn/cache/fsevents-patch-6b67494872-10.zip differ diff --git a/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip b/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip new file mode 100644 index 000000000..8206af945 Binary files /dev/null and b/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip differ diff --git a/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip b/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip deleted file mode 100644 index 55b7f59f1..000000000 Binary files a/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip and /dev/null differ diff --git a/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip b/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip deleted file mode 100644 index 5c5749f7d..000000000 Binary files a/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip and /dev/null differ