Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,5 @@ temp/
!.yarn/versions

# End of https://www.toptal.com/developers/gitignore/api/yarn,node

/gitlab
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions api/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
202 changes: 202 additions & 0 deletions api/src/gitlab/client.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<GitLabGroup | null> {
if (!this.connected) {
console.error("GitLab client is not connected");
return null;
}

try {
const groupData: Record<string, unknown> = {
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<GitLabGroup | null> {
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();
Loading