diff --git a/.env.example b/.env.example index 59d90f078..551a60384 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,16 @@ # the Discord command, set the ID of the role below, else leave this field empty. #DISCORD_REGISTRATION_ROLE_ID=discord_id +# Mattermost Integration +# USE_MATTERMOST=false +# MATTERMOST_URL=https://your-mattermost-server.com +# MATTERMOST_USERNAME=ctfnote-bot +# MATTERMOST_PASSWORD=your-bot-password +# MATTERMOST_TEAM_NAME=your-team-name +# MATTERMOST_CREATE_VOICE_CHANNELS=false +# MATTERMOST_VOICE_CHANNELS_COUNT=2 +# MATTERMOST_TEAM_ALLOW_OPEN_INVITE=true + # Configure timezone and locale # TZ=Europe/Paris # LC_ALL=en_US.UTF-8 diff --git a/README.md b/README.md index f0c895663..077531bd9 100755 --- a/README.md +++ b/README.md @@ -142,6 +142,55 @@ 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 Mattermost support + +CTFNote can integrate with Mattermost to automatically create a team for a created CTF and channels for tasks. + +#### What it does + +When enabled, CTFNote will: +- Automatically create a team and channels for each CTF +- Create task-specific channels within the CTF team + +#### Setup + +To enable Mattermost integration, configure the following values in your `.env` file: + +``` +USE_MATTERMOST=true +MATTERMOST_URL=http://your-mattermost-server:8065 +MATTERMOST_USERNAME=bot-username +MATTERMOST_PASSWORD=bot-password +MATTERMOST_TEAM_ALLOW_OPEN_INVITE=true +``` + +#### Testing with Mattermost Preview + +You can quickly test the Mattermost integration using the official preview Docker image: + +```shell +docker run --name mattermost-preview -d --publish 8065:8065 mattermost/mattermost-preview:10.9.5 +``` + +This will start a Mattermost server on `http://localhost:8065`. +The first user created will be an admin user. + +After logging in: +1. Create a team or use the default one +2. Create a bot account for CTFNote (or use the admin account for testing) +3. Update your `.env` file with the appropriate values +4. Restart CTFNote to enable the integration + +#### Configuration Options + +- `MATTERMOST_TEAM_ALLOW_OPEN_INVITE`: When set to `true` (default), created teams will allow any user with an account on the server to join. Set to `false` if you want teams to be invite-only. + +#### Requirements + +- The Mattermost bot account needs permissions to create and manage teams and channels +- The bot user must belong to at least one team in Mattermost +- The Mattermost server must be accessible from the CTFNote API container + ### 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..3b97c8b57 100644 --- a/api/.env.dev +++ b/api/.env.dev @@ -17,4 +17,12 @@ DISCORD_BOT_TOKEN=secret_token DISCORD_SERVER_ID=server_id DISCORD_VOICE_CHANNELS=3 +USE_MATTERMOST=false +MATTERMOST_URL=http://localhost:8065 +MATTERMOST_USERNAME=username +MATTERMOST_PASSWORD=password +MATTERMOST_CREATE_VOICE_CHANNELS=true +MATTERMOST_VOICE_CHANNELS_COUNT=2 +MATTERMOST_TEAM_ALLOW_OPEN_INVITE=true + WEB_PORT=3000 \ No newline at end of file diff --git a/api/.yarn/cache/@mattermost-client-npm-10.9.0-21cf752029-22acc3445a.zip b/api/.yarn/cache/@mattermost-client-npm-10.9.0-21cf752029-22acc3445a.zip new file mode 100644 index 000000000..bc09ef8c2 Binary files /dev/null and b/api/.yarn/cache/@mattermost-client-npm-10.9.0-21cf752029-22acc3445a.zip differ diff --git a/api/.yarn/cache/@mattermost-types-npm-10.9.0-5d1e161537-8bfcf13332.zip b/api/.yarn/cache/@mattermost-types-npm-10.9.0-5d1e161537-8bfcf13332.zip new file mode 100644 index 000000000..2725b6666 Binary files /dev/null and b/api/.yarn/cache/@mattermost-types-npm-10.9.0-5d1e161537-8bfcf13332.zip differ diff --git a/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip b/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip new file mode 100644 index 000000000..aff1ab12c Binary files /dev/null and b/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip differ diff --git a/api/package.json b/api/package.json index 1a9d56390..24a698dca 100644 --- a/api/package.json +++ b/api/package.json @@ -22,6 +22,8 @@ "@graphile-contrib/pg-simplify-inflector": "^6.1.0", "@graphile/operation-hooks": "^1.0.0", "@graphile/pg-pubsub": "4.13.0", + "@mattermost/client": "^10.9.0", + "@mattermost/types": "^10.9.0", "axios": "^1.7.7", "discord.js": "^14.21.0", "dotenv": "^16.4.5", diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..42967906a 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -51,6 +51,16 @@ export type CTFNoteConfig = DeepReadOnly<{ registrationRoleId: string; channelHandleStyle: DiscordChannelHandleStyle; }; + mattermost: { + enabled: boolean; + url: string; + username: string; + password: string; + teamName: string; + createVoiceChannels: boolean; + voiceChannelsCount: number; + teamAllowOpenInvite: boolean; + }; }>; function getEnv( @@ -112,6 +122,20 @@ const config: CTFNoteConfig = { "agile" ) as DiscordChannelHandleStyle, }, + mattermost: { + enabled: getEnv("USE_MATTERMOST", "false") === "true", + url: getEnv("MATTERMOST_URL", ""), + username: getEnv("MATTERMOST_USERNAME", ""), + password: getEnv("MATTERMOST_PASSWORD", ""), + teamName: getEnv("MATTERMOST_TEAM_NAME", ""), + createVoiceChannels: + getEnv("MATTERMOST_CREATE_VOICE_CHANNELS", "false") === "true", + voiceChannelsCount: parseInt( + getEnv("MATTERMOST_VOICE_CHANNELS_COUNT", "2") + ), + teamAllowOpenInvite: + getEnv("MATTERMOST_TEAM_ALLOW_OPEN_INVITE", "true") === "true", + }, }; export default config; diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..c831e7b53 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 mattermostHooks from "./mattermost/postgraphileHooks"; import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin"; @@ -61,6 +62,7 @@ function createOptions() { createTasKPlugin, ConnectionFilterPlugin, discordHooks, + mattermostHooks, PgManyToManyPlugin, ProfileSubscriptionPlugin, ], diff --git a/api/src/mattermost/channels.ts b/api/src/mattermost/channels.ts new file mode 100644 index 000000000..20d8e9afc --- /dev/null +++ b/api/src/mattermost/channels.ts @@ -0,0 +1,365 @@ +import { mattermostClient } from "./client"; +import type { Channel } from "@mattermost/types/channels"; +import type { Team } from "@mattermost/types/teams"; +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 class MattermostChannelManager { + private channelPrefixes = { + new: "new-", + started: "started-", + solved: "solved-", + }; + + private normalizeChannelName(name: string): string { + return safeSlugify(name) + .toLowerCase() + .replace(/[^a-z0-9-_]/g, "") + .substring(0, 64); + } + + private getChannelName( + ctf: CTF, + type: "new" | "started" | "solved", + task?: Task + ): string { + const prefix = this.channelPrefixes[type]; + const ctfName = this.normalizeChannelName(ctf.title); + if (task) { + const taskName = this.normalizeChannelName(task.title); + return `${prefix}${ctfName}-${taskName}`.substring(0, 64); + } + return `${prefix}${ctfName}`.substring(0, 64); + } + + private getTaskChannelName( + ctf: CTF, + task: Task, + type: "new" | "started" | "solved" + ): string { + return this.getChannelName(ctf, type, task); + } + + async createChannelsForCtf(ctf: CTF): Promise { + if (!mattermostClient.isConnected()) { + console.error("Mattermost client is not connected"); + return; + } + + try { + console.log(`Creating Mattermost team and channels for CTF: ${ctf.title}`); + + // Create a new team for this CTF + const team = await this.createTeamForCtf(ctf); + + if (!team) { + console.error(`Failed to create team for CTF: ${ctf.title}`); + return; + } + + const teamId = team.id; + const createdChannels: string[] = []; + const failedChannels: string[] = []; + + // Create main discussion channel + const challengesTalkName = this.normalizeChannelName(`talk`); + const talkChannel = await this.createChannel( + teamId, + challengesTalkName, + `Talk`, + "O", + `General discussion for ${ctf.title}` + ); + + if (talkChannel) { + createdChannels.push(challengesTalkName); + } else { + failedChannels.push(challengesTalkName); + } + + // Create voice channels if enabled + if (config.mattermost.createVoiceChannels) { + for (let i = 0; i < config.mattermost.voiceChannelsCount; i++) { + const voiceChannelName = this.normalizeChannelName( + `voice-${i}` + ); + const voiceChannel = await this.createChannel( + teamId, + voiceChannelName, + `Voice ${i}`, + "O", + `Voice channel ${i} for ${ctf.title}` + ); + + if (voiceChannel) { + createdChannels.push(voiceChannelName); + } else { + failedChannels.push(voiceChannelName); + } + } + } + + if (failedChannels.length > 0) { + console.error( + `Failed to create channels for CTF ${ctf.title}: ${failedChannels.join(", ")}` + ); + } + + if (createdChannels.length > 0) { + console.log( + `Successfully created channels for CTF ${ctf.title}: ${createdChannels.join(", ")}` + ); + } + } catch (error) { + console.error(`Failed to create channels for CTF ${ctf.title}:`, error); + } + } + + async createChannelForTask(task: Task, ctf: CTF): Promise { + if (!mattermostClient.isConnected()) { + console.error("Mattermost client is not connected"); + return; + } + + // Get the team for this CTF + const team = await this.getTeamForCtf(ctf); + + if (!team) { + console.error(`Team not found for CTF: ${ctf.title}`); + return; + } + + const teamId = team.id; + + try { + console.log(`Creating Mattermost channel for task: ${task.title}`); + + let channelType: "new" | "started" | "solved" = "new"; + if (task.flag && task.flag !== "") { + channelType = "solved"; + } + + const channelName = this.getTaskChannelName(ctf, task, channelType); + const displayName = `${task.title}`; + const description = `Task: ${task.title}\n${task.description}\n\nCTFNote: ${this.getTaskUrl(ctf, task)}`; + + await this.createChannel( + teamId, + channelName, + displayName, + "O", + description + ); + + console.log(`Successfully created channel for task: ${task.title}`); + } catch (error) { + console.error(`Failed to create channel for task ${task.title}:`, error); + } + } + + async moveTaskChannel( + task: Task, + ctf: CTF, + targetType: "started" | "solved" + ): Promise { + if (!mattermostClient.isConnected()) { + console.error("Mattermost client is not connected"); + return; + } + + const client = mattermostClient.getClient(); + + // Get the team for this CTF + const team = await this.getTeamForCtf(ctf); + + if (!team) { + console.error(`Team not found for CTF: ${ctf.title}`); + return; + } + + const teamId = team.id; + + try { + // Find existing channel with any prefix + const existingChannels = await Promise.all([ + this.findChannel(teamId, this.getTaskChannelName(ctf, task, "new")), + this.findChannel(teamId, this.getTaskChannelName(ctf, task, "started")), + this.findChannel(teamId, this.getTaskChannelName(ctf, task, "solved")), + ]); + + const existingChannel = existingChannels.find((c) => c !== null); + + if (!existingChannel) { + console.error(`Channel not found for task: ${task.title}`); + return; + } + + // Update channel name to reflect new status + const newChannelName = this.getTaskChannelName(ctf, task, targetType); + + await client.patchChannel(existingChannel.id, { + name: newChannelName, + display_name: `[${targetType.toUpperCase()}] ${task.title}`, + }); + + console.log(`Moved task ${task.title} to ${targetType} status`); + } catch (error) { + console.error(`Failed to move task channel ${task.title}:`, error); + } + } + + private async createChannel( + teamId: string, + name: string, + displayName: string, + type: "O" | "P" = "O", + purpose?: string + ): Promise { + try { + const client = mattermostClient.getClient(); + + // Check if channel already exists + const existingChannel = await this.findChannel(teamId, name); + + if (existingChannel) { + console.log(`Channel ${name} already exists`); + return existingChannel; + } + + const channel = await client.createChannel({ + team_id: teamId, + name: name, + display_name: displayName, + type: type, + purpose: purpose || displayName, + }); + + console.log( + `Created channel: ${name} (ID: ${channel.id}, Display: ${channel.display_name})` + ); + return channel; + } catch (error) { + console.error(`Failed to create channel ${name}:`, error); + + // Log more details about the error + if (error instanceof Error) { + console.error(`Error message: ${error.message}`); + } + + // Check for Mattermost API error structure + const mattermostError = error as { + server_error_id?: string; + status_code?: number; + message?: string; + }; + + if (mattermostError.server_error_id) { + console.error( + `Mattermost error ID: ${mattermostError.server_error_id}` + ); + } + if (mattermostError.status_code) { + console.error(`HTTP status code: ${mattermostError.status_code}`); + + // Common issues + if (mattermostError.status_code === 401) { + console.error("Authentication failed - check Mattermost credentials"); + } else if (mattermostError.status_code === 403) { + console.error( + "Permission denied - user may not have permission to create channels" + ); + } else if (mattermostError.status_code === 404) { + console.error( + "Team not found - check MATTERMOST_TEAM_NAME configuration" + ); + } + } + + return null; + } + } + + private async findChannel( + teamId: string, + name: string + ): Promise { + try { + const client = mattermostClient.getClient(); + const channel = await client.getChannelByName(teamId, name); + return channel; + } catch (error) { + // Channel doesn't exist, which is fine + return null; + } + } + + 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}`; + } + + private async createTeamForCtf(ctf: CTF): Promise { + try { + const client = mattermostClient.getClient(); + const teamName = this.normalizeChannelName(ctf.title); + + // Check if team already exists + const existingTeam = await this.getTeamForCtf(ctf); + if (existingTeam) { + console.log(`Team ${teamName} already exists for CTF: ${ctf.title}`); + return existingTeam; + } + + // Create new team + const team = await client.createTeam({ + name: teamName, + display_name: ctf.title, + type: 'O' as const, // Open team + description: ctf.description || `Team for CTF: ${ctf.title}`, + allow_open_invite: config.mattermost.teamAllowOpenInvite, + } as Team); + + console.log(`Created team: ${team.name} (ID: ${team.id})`); + return team; + } catch (error) { + console.error(`Failed to create team for CTF ${ctf.title}:`, error); + return null; + } + } + + private async getTeamForCtf(ctf: CTF): Promise { + try { + const client = mattermostClient.getClient(); + const teamName = this.normalizeChannelName(ctf.title); + + // Try to get the team by name + const team = await client.getTeamByName(teamName); + return team; + } catch (error) { + // Team doesn't exist + return null; + } + } +} + +export const mattermostChannelManager = new MattermostChannelManager(); diff --git a/api/src/mattermost/client.ts b/api/src/mattermost/client.ts new file mode 100644 index 000000000..226682cf7 --- /dev/null +++ b/api/src/mattermost/client.ts @@ -0,0 +1,84 @@ +import { Client4 } from "@mattermost/client"; +import config from "../config"; + +export class MattermostClient { + private client: Client4; + private userId: string | null = null; + private teamId: string | null = null; + private connected: boolean = false; + + constructor() { + this.client = new Client4(); + if (config.mattermost.url) { + this.client.setUrl(config.mattermost.url); + } + } + + async connect(): Promise { + if (!config.mattermost.enabled) { + console.log("Mattermost integration is disabled"); + return; + } + + if ( + !config.mattermost.url || + !config.mattermost.username || + !config.mattermost.password + ) { + console.error("Mattermost configuration is incomplete"); + return; + } + + try { + console.log("Connecting to Mattermost..."); + + const user = await this.client.login( + config.mattermost.username, + config.mattermost.password + ); + + this.userId = user.id; + + const teamsResponse = await this.client.getTeams(); + const teams = Array.isArray(teamsResponse) + ? teamsResponse + : teamsResponse.teams; + + // Just use the first available team the user belongs to + if (teams.length === 0) { + throw new Error( + `No teams found for user ${config.mattermost.username}. Please ensure the user belongs to at least one team.` + ); + } + + const team = teams[0]; + this.teamId = team.id; + this.connected = true; + + console.log( + `Connected to Mattermost as ${user.username} using team ${team.display_name}` + ); + } catch (error) { + console.error("Failed to connect to Mattermost:", error); + this.connected = false; + } + } + + isConnected(): boolean { + return this.connected; + } + + getClient(): Client4 { + return this.client; + } + + getUserId(): string | null { + return this.userId; + } + + getTeamId(): string | null { + return this.teamId; + } +} + +export const mattermostClient = new MattermostClient(); diff --git a/api/src/mattermost/hooks.ts b/api/src/mattermost/hooks.ts new file mode 100644 index 000000000..08112071e --- /dev/null +++ b/api/src/mattermost/hooks.ts @@ -0,0 +1,171 @@ +import { mattermostClient } from "./client"; +import { mattermostChannelManager } from "./channels"; +import config from "../config"; + +export interface CTFEventData { + id: bigint; + title: string; + description?: string; + startTime?: Date; + endTime?: Date; +} + +export interface TaskEventData { + id: bigint; + ctf_id: bigint; + title: string; + description: string; + flag: string; +} + +export class MattermostHooks { + async initialize(): Promise { + if (!config.mattermost.enabled) { + console.log("Mattermost integration is disabled"); + return; + } + + await mattermostClient.connect(); + + if (!mattermostClient.isConnected()) { + console.error( + "Failed to initialize Mattermost hooks - client not connected" + ); + return; + } + + console.log("Mattermost hooks initialized successfully"); + } + + async handleCtfCreated(ctf: CTFEventData): Promise { + if (!mattermostClient.isConnected()) { + return; + } + + console.log(`Handling CTF created event: ${ctf.title}`); + + try { + await mattermostChannelManager.createChannelsForCtf({ + id: ctf.id, + title: ctf.title, + description: ctf.description, + startTime: ctf.startTime, + endTime: ctf.endTime, + }); + } catch (error) { + console.error( + `Failed to handle CTF created event for ${ctf.title}:`, + error + ); + } + } + + async handleTaskCreated( + task: TaskEventData, + ctf: CTFEventData + ): Promise { + if (!mattermostClient.isConnected()) { + return; + } + + console.log(`Handling task created event: ${task.title}`); + + try { + await mattermostChannelManager.createChannelForTask( + { + 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.startTime, + endTime: ctf.endTime, + } + ); + } catch (error) { + console.error( + `Failed to handle task created event for ${task.title}:`, + error + ); + } + } + + async handleTaskStarted( + task: TaskEventData, + ctf: CTFEventData + ): Promise { + if (!mattermostClient.isConnected()) { + return; + } + + console.log(`Handling task started event: ${task.title}`); + + try { + await mattermostChannelManager.moveTaskChannel( + { + 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.startTime, + endTime: ctf.endTime, + }, + "started" + ); + } catch (error) { + console.error( + `Failed to handle task started event for ${task.title}:`, + error + ); + } + } + + async handleTaskSolved( + task: TaskEventData, + ctf: CTFEventData + ): Promise { + if (!mattermostClient.isConnected()) { + return; + } + + console.log(`Handling task solved event: ${task.title}`); + + try { + await mattermostChannelManager.moveTaskChannel( + { + 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.startTime, + endTime: ctf.endTime, + }, + "solved" + ); + } catch (error) { + console.error( + `Failed to handle task solved event for ${task.title}:`, + error + ); + } + } +} + +export const mattermostHooks = new MattermostHooks(); diff --git a/api/src/mattermost/postgraphileHooks.ts b/api/src/mattermost/postgraphileHooks.ts new file mode 100644 index 000000000..d7ff507ba --- /dev/null +++ b/api/src/mattermost/postgraphileHooks.ts @@ -0,0 +1,277 @@ +import { Build, Context, SchemaBuilder } from "postgraphile"; +import { GraphQLResolveInfoWithMessages } from "@graphile/operation-hooks"; +import { mattermostHooks } from "./hooks"; +import config from "../config"; +import { PoolClient } from "pg"; + +interface CTFData { + id: bigint; + title: string; + description?: string; + startTime?: Date; + endTime?: Date; +} + +interface TaskData { + id: bigint; + ctf_id: bigint; + title: string; + description: string; + flag: string; +} + +async function getCtfFromDatabase( + ctfId: bigint, + pgClient: PoolClient +): Promise { + try { + const result = await pgClient.query( + 'SELECT id, title, description, start_time as "startTime", end_time as "endTime" FROM ctfnote.ctf WHERE id = $1', + [ctfId] + ); + + if (result.rows.length === 0) return null; + + return result.rows[0]; + } catch (error) { + console.error("Failed to get CTF from database:", error); + return null; + } +} + +async function getTaskFromDatabase( + taskId: bigint, + pgClient: PoolClient +): Promise { + try { + const result = await pgClient.query( + "SELECT id, ctf_id, title, description, flag FROM ctfnote.task WHERE id = $1", + [taskId] + ); + + if (result.rows.length === 0) return null; + + return result.rows[0]; + } catch (error) { + console.error("Failed to get task from database:", error); + return null; + } +} + +async function getTaskByCtfIdAndTitle( + 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", + [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 mattermostMutationHook = + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + (_build: Build) => (fieldContext: Context) => { + const { + scope: { isRootMutation }, + } = fieldContext; + + if (!isRootMutation) return null; + + if (!config.mattermost.enabled) return null; + + const relevantMutations = [ + "createCtf", + "createTask", + "updateTask", + "startWorkingOn", + "stopWorkingOn", + ]; + + if (!relevantMutations.includes(fieldContext.scope.fieldName)) { + return null; + } + + const handleMattermostMutationAfter = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _resolveInfo: GraphQLResolveInfoWithMessages + ) => { + console.log( + `Mattermost hook triggered for mutation: ${fieldContext.scope.fieldName}` + ); + try { + switch (fieldContext.scope.fieldName) { + case "createCtf": { + console.log("Processing createCtf mutation for Mattermost"); + + // The CTF ID is in the returned data structure at input.data["@ctf"].id + const ctfId = input?.data?.["@ctf"]?.id; + if (!ctfId) { + console.log("No CTF ID found in mutation result"); + break; + } + + console.log(`Found CTF ID: ${ctfId}`); + + const ctf = await getCtfFromDatabase(ctfId, context.pgClient); + if (!ctf) { + console.log("CTF not found in database"); + break; + } + + console.log(`Creating Mattermost channels for CTF: ${ctf.title}`); + await mattermostHooks.handleCtfCreated({ + id: ctf.id, + title: ctf.title, + description: ctf.description, + startTime: ctf.startTime, + endTime: ctf.endTime, + }); + break; + } + + case "createTask": { + const ctfId = args.input.ctfId; + const title = args.input.title; + + if (!ctfId || !title) break; + + const task = await getTaskByCtfIdAndTitle( + ctfId, + title, + context.pgClient + ); + if (!task) break; + + const ctf = await getCtfFromDatabase(ctfId, context.pgClient); + if (!ctf) break; + + await mattermostHooks.handleTaskCreated( + { + 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.startTime, + endTime: ctf.endTime, + } + ); + break; + } + + case "updateTask": { + const taskId = args.input.id; + const newFlag = args.input.patch?.flag; + + if (!taskId) break; + + const task = await getTaskFromDatabase(taskId, context.pgClient); + if (!task) break; + + const ctf = await getCtfFromDatabase(task.ctf_id, context.pgClient); + if (!ctf) break; + + if (newFlag && newFlag !== "") { + await mattermostHooks.handleTaskSolved( + { + id: task.id, + ctf_id: task.ctf_id, + title: task.title, + description: task.description, + flag: newFlag, + }, + { + id: ctf.id, + title: ctf.title, + description: ctf.description, + startTime: ctf.startTime, + endTime: ctf.endTime, + } + ); + } + break; + } + + case "startWorkingOn": { + const taskId = args.input.taskId; + + if (!taskId) break; + + const task = await getTaskFromDatabase(taskId, context.pgClient); + if (!task) break; + + const ctf = await getCtfFromDatabase(task.ctf_id, context.pgClient); + if (!ctf) break; + + await mattermostHooks.handleTaskStarted( + { + 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.startTime, + endTime: ctf.endTime, + } + ); + break; + } + } + } catch (error) { + console.error("Mattermost hook error:", error); + } + + return input; + }; + + return { + after: [ + { + priority: 500, + callback: handleMattermostMutationAfter, + }, + ], + }; + }; + +export default async function (builder: SchemaBuilder): Promise { + if (!config.mattermost.enabled) { + console.log("Mattermost integration is disabled in config"); + return; + } + + console.log("Initializing Mattermost hooks..."); + await mattermostHooks.initialize(); + + builder.hook("init", (_, build) => { + console.log("Adding Mattermost operation hooks to GraphQL"); + build.addOperationHook(mattermostMutationHook(build)); + return _; + }); +} diff --git a/api/test-ctfnote-channels.ts b/api/test-ctfnote-channels.ts new file mode 100644 index 000000000..72eacc04e --- /dev/null +++ b/api/test-ctfnote-channels.ts @@ -0,0 +1,108 @@ +import { mattermostClient } from "./src/mattermost/client"; +import { mattermostChannelManager } from "./src/mattermost/channels"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testCTFNoteChannelCreation() { + console.log("=== Testing CTFNote Channel Creation ===\n"); + + try { + // Connect to Mattermost + console.log("1. Connecting to Mattermost..."); + await mattermostClient.connect(); + + if (!mattermostClient.isConnected()) { + throw new Error("Failed to connect to Mattermost"); + } + + console.log("✓ Connected successfully"); + console.log(` Team ID: ${mattermostClient.getTeamId()}`); + console.log(` User ID: ${mattermostClient.getUserId()}`); + + // Create a test CTF + const testCtf = { + id: BigInt(999), + title: `Test CTF ${Date.now()}`, + description: "This is a test CTF for channel creation" + }; + + console.log(`\n2. Creating channels for CTF: ${testCtf.title}`); + await mattermostChannelManager.createChannelsForCtf(testCtf); + + console.log("\n3. Verifying channels were created..."); + + // Add a small delay to ensure channels are fully created + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Get the client and team ID to check channels + const client = mattermostClient.getClient(); + const teamId = mattermostClient.getTeamId(); + + if (!teamId) { + throw new Error("No team ID found"); + } + + // List all channels and find our test channels + const channels = await client.getChannels(teamId); + const normalizedTitle = testCtf.title + .toLowerCase() + .replace(/[^a-z0-9-_]/g, "") + .replace(/-+/g, "-") + .substring(0, 64); + + console.log(`Looking for channels with normalized title: ${normalizedTitle}`); + + // The timestamp is part of the title, so let's extract it + const timestamp = testCtf.title.match(/\d+$/)?.[0] || ""; + + const testChannels = channels.filter(ch => + ch.name.includes("test-ctf-") && ch.name.includes(timestamp) + ); + + console.log(`\nFound ${testChannels.length} channels for our test CTF:`); + testChannels.forEach(ch => { + console.log(` ✓ ${ch.name} (${ch.display_name})`); + console.log(` ID: ${ch.id}`); + console.log(` Purpose: ${ch.purpose || "No purpose"}`); + }); + + if (testChannels.length === 0) { + console.log(" ❌ No channels found! Channels were not created."); + } + + // Clean up - delete test channels + console.log("\n4. Cleaning up test channels..."); + for (const channel of testChannels) { + try { + await client.deleteChannel(channel.id); + console.log(` ✓ Deleted ${channel.name}`); + } catch (error) { + console.log(` ⚠️ Could not delete ${channel.name}: ${error}`); + } + } + + } catch (error) { + console.error("\n❌ Test failed:"); + console.error(error); + + const err = error as { server_error_id?: string; status_code?: number }; + if (err.server_error_id) { + console.error(`Mattermost Error ID: ${err.server_error_id}`); + } + if (err.status_code) { + console.error(`HTTP Status: ${err.status_code}`); + } + } +} + +// Run the test +testCTFNoteChannelCreation() + .then(() => { + console.log("\n=== Test completed ==="); + process.exit(0); + }) + .catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/api/test-mattermost-channels.ts b/api/test-mattermost-channels.ts new file mode 100644 index 000000000..5f8025c98 --- /dev/null +++ b/api/test-mattermost-channels.ts @@ -0,0 +1,138 @@ +import { Client4 } from "@mattermost/client"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testMattermostChannels() { + const client = new Client4(); + + // Update this to use localhost:8065 for your Docker container + const MATTERMOST_URL = process.env.MATTERMOST_URL || "http://localhost:8065"; + const MATTERMOST_USERNAME = process.env.MATTERMOST_USERNAME!; + const MATTERMOST_PASSWORD = process.env.MATTERMOST_PASSWORD!; + const MATTERMOST_TEAM_NAME = process.env.MATTERMOST_TEAM_NAME!; + + console.log("=== Mattermost Channel Test ==="); + console.log(`URL: ${MATTERMOST_URL}`); + console.log(`Username: ${MATTERMOST_USERNAME}`); + console.log(`Team: ${MATTERMOST_TEAM_NAME}`); + console.log(""); + + try { + // Set the URL + client.setUrl(MATTERMOST_URL); + + // Login + console.log("1. Logging in..."); + const user = await client.login(MATTERMOST_USERNAME, MATTERMOST_PASSWORD); + console.log(`✓ Logged in as: ${user.username} (ID: ${user.id})`); + + // Get teams + console.log("\n2. Getting teams..."); + const teamsResponse = await client.getTeams(); + const teams = Array.isArray(teamsResponse) ? teamsResponse : teamsResponse.teams; + console.log(`✓ Found ${teams.length} teams:`); + teams.forEach((team: { name: string; display_name: string; id: string }) => { + console.log(` - ${team.name} (${team.display_name}) - ID: ${team.id}`); + }); + + // Find our team + const team = teams.find((t: { name: string }) => t.name === MATTERMOST_TEAM_NAME); + if (!team) { + throw new Error(`Team '${MATTERMOST_TEAM_NAME}' not found!`); + } + console.log(`✓ Using team: ${team.display_name}`); + + // Get all channels for the team + console.log("\n3. Getting all channels in team..."); + const channels = await client.getChannels(team.id); + console.log(`✓ Found ${channels.length} channels:`); + + // Filter and display CTF-related channels + const ctfChannels = channels.filter(ch => + ch.name.includes("talk") || + ch.name.includes("voice") || + ch.name.includes("asssss") || + ch.name.includes("wieso") + ); + + console.log("\nCTF-related channels:"); + if (ctfChannels.length === 0) { + console.log(" ❌ No CTF channels found!"); + } else { + ctfChannels.forEach(ch => { + console.log(` - ${ch.name} (${ch.display_name}) - ID: ${ch.id}`); + console.log(` Purpose: ${ch.purpose || "No purpose set"}`); + console.log(` Created: ${new Date(ch.create_at).toLocaleString()}`); + }); + } + + // Test creating a channel + console.log("\n4. Testing channel creation..."); + const testChannelName = `test-ctf-${Date.now()}`; + + try { + const newChannel = await client.createChannel({ + team_id: team.id, + name: testChannelName, + display_name: "Test CTF Channel", + type: "O", + purpose: "Test channel created by API" + }); + + console.log(`✓ Successfully created test channel:`); + console.log(` - Name: ${newChannel.name}`); + console.log(` - Display: ${newChannel.display_name}`); + console.log(` - ID: ${newChannel.id}`); + + // Delete the test channel + console.log("\n5. Cleaning up test channel..."); + await client.deleteChannel(newChannel.id); + console.log("✓ Test channel deleted"); + + } catch (error) { + console.error("❌ Failed to create test channel:"); + const err = error as { message?: string; server_error_id?: string; status_code?: number }; + if (err.message) { + console.error(` Error: ${err.message}`); + } + if (err.server_error_id) { + console.error(` Server Error ID: ${err.server_error_id}`); + } + if (err.status_code) { + console.error(` Status Code: ${err.status_code}`); + } + } + + // List all channels again to show current state + console.log("\n6. Final channel list (first 10):"); + const allChannels = await client.getChannels(team.id); + allChannels.slice(0, 10).forEach(ch => { + console.log(` - ${ch.name} (${ch.display_name})`); + }); + + } catch (error) { + console.error("\n❌ Test failed:"); + const err = error as { message?: string; server_error_id?: string; status_code?: number }; + if (err.message) { + console.error(err.message); + } + if (err.server_error_id) { + console.error(`Server Error ID: ${err.server_error_id}`); + } + if (err.status_code) { + console.error(`Status Code: ${err.status_code}`); + } + } +} + +// Run the test +testMattermostChannels() + .then(() => { + console.log("\n=== Test completed ==="); + process.exit(0); + }) + .catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/api/test-mattermost-teams.ts b/api/test-mattermost-teams.ts new file mode 100644 index 000000000..29909f845 --- /dev/null +++ b/api/test-mattermost-teams.ts @@ -0,0 +1,143 @@ +import { Client4 } from "@mattermost/client"; +import type { Team } from "@mattermost/types/teams"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function testMattermostTeams() { + const client = new Client4(); + + const MATTERMOST_URL = process.env.MATTERMOST_URL || "http://localhost:8065"; + const MATTERMOST_USERNAME = process.env.MATTERMOST_USERNAME!; + const MATTERMOST_PASSWORD = process.env.MATTERMOST_PASSWORD!; + + console.log("=== Mattermost Team Creation Test ==="); + console.log(`URL: ${MATTERMOST_URL}`); + console.log(`Username: ${MATTERMOST_USERNAME}`); + console.log(""); + + try { + // Set the URL + client.setUrl(MATTERMOST_URL); + + // Login + console.log("1. Logging in..."); + const user = await client.login(MATTERMOST_USERNAME, MATTERMOST_PASSWORD); + console.log(`✓ Logged in as: ${user.username} (ID: ${user.id})`); + + // Test creating a team + console.log("\n2. Testing team creation..."); + const testTeamName = `test-ctf-${Date.now()}`; + const testCtfName = "Test CTF Competition"; + + try { + const newTeam = await client.createTeam({ + name: testTeamName, + display_name: testCtfName, + type: "O" as const, + description: `Team for CTF: ${testCtfName}`, + } as Team); + + console.log(`✓ Successfully created test team:`); + console.log(` - Name: ${newTeam.name}`); + console.log(` - Display: ${newTeam.display_name}`); + console.log(` - ID: ${newTeam.id}`); + console.log(` - Description: ${newTeam.description}`); + + // Create channels in the new team + console.log("\n3. Creating channels in the new team..."); + + // Create talk channel + const talkChannel = await client.createChannel({ + team_id: newTeam.id, + name: `${testTeamName}-talk`, + display_name: `${testCtfName} - Talk`, + type: "O", + purpose: `General discussion for ${testCtfName}` + }); + console.log(`✓ Created talk channel: ${talkChannel.name}`); + + // Create voice channels + const voiceChannels = []; + for (let i = 0; i < 2; i++) { + const voiceChannel = await client.createChannel({ + team_id: newTeam.id, + name: `${testTeamName}-voice-${i}`, + display_name: `${testCtfName} - Voice ${i}`, + type: "O", + purpose: `Voice channel ${i} for ${testCtfName}` + }); + voiceChannels.push(voiceChannel); + console.log(`✓ Created voice channel ${i}: ${voiceChannel.name}`); + } + + // List channels in the new team + console.log("\n4. Channels in the new team:"); + const channels = await client.getChannels(newTeam.id); + channels.forEach(ch => { + console.log(` - ${ch.name} (${ch.display_name})`); + }); + + // Clean up - delete channels and team + console.log("\n5. Cleaning up test data..."); + + // Delete channels + await client.deleteChannel(talkChannel.id); + console.log("✓ Deleted talk channel"); + + for (const vc of voiceChannels) { + await client.deleteChannel(vc.id); + } + console.log("✓ Deleted voice channels"); + + // Note: Deleting teams requires admin permissions + // For testing, we'll leave the team and let admin clean it up later + console.log("ℹ️ Note: Team deletion requires admin permissions. Team will remain for manual cleanup."); + + } catch (error) { + console.error("❌ Failed to create test team:"); + const err = error as { message?: string; server_error_id?: string; status_code?: number }; + if (err.message) { + console.error(` Error: ${err.message}`); + } + if (err.server_error_id) { + console.error(` Server Error ID: ${err.server_error_id}`); + } + if (err.status_code) { + console.error(` Status Code: ${err.status_code}`); + } + } + + // List all teams to show current state + console.log("\n6. Current teams:"); + const teamsResponse = await client.getTeams(); + const teams = Array.isArray(teamsResponse) ? teamsResponse : teamsResponse.teams; + teams.forEach((team: { name: string; display_name: string }) => { + console.log(` - ${team.name} (${team.display_name})`); + }); + + } catch (error) { + console.error("\n❌ Test failed:"); + const err = error as { message?: string; server_error_id?: string; status_code?: number }; + if (err.message) { + console.error(err.message); + } + if (err.server_error_id) { + console.error(`Server Error ID: ${err.server_error_id}`); + } + if (err.status_code) { + console.error(`Status Code: ${err.status_code}`); + } + } +} + +// Run the test +testMattermostTeams() + .then(() => { + console.log("\n=== Test completed ==="); + process.exit(0); + }) + .catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); + }); \ No newline at end of file diff --git a/api/yarn.lock b/api/yarn.lock index 01811db16..20d174d91 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -261,6 +261,31 @@ __metadata: languageName: node linkType: hard +"@mattermost/client@npm:^10.9.0": + version: 10.9.0 + resolution: "@mattermost/client@npm:10.9.0" + peerDependencies: + "@mattermost/types": 10.9.0 + typescript: ^4.3.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/22acc3445a71fd2208111712ef43589b246fa1284a03bb94173058bf2a396820519d0942706d82afe9031d1989143faa329e201a7098a3ccbdb23ce1163c9837 + languageName: node + linkType: hard + +"@mattermost/types@npm:^10.9.0": + version: 10.9.0 + resolution: "@mattermost/types@npm:10.9.0" + peerDependencies: + typescript: ^4.3.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/8bfcf1333235a38228806c0073d75dcb35c33088c43ccb0f02c715ee6f1f7fa9af33aedb043a41651d8c8656ea7a5f3c3aa3404913edff4c2deeaeb4f638d267 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1296,6 +1321,8 @@ __metadata: "@graphile-contrib/pg-simplify-inflector": "npm:^6.1.0" "@graphile/operation-hooks": "npm:^1.0.0" "@graphile/pg-pubsub": "npm:4.13.0" + "@mattermost/client": "npm:^10.9.0" + "@mattermost/types": "npm:^10.9.0" "@types/express": "npm:^4.17.21" "@typescript-eslint/eslint-plugin": "npm:^7.3.1" "@typescript-eslint/parser": "npm:^7.16.0" 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