Skip to content
Draft
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
5 changes: 5 additions & 0 deletions api/migrations/57-hedgedoc-password-entry-in-settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE ctfnote.settings
ADD COLUMN "hedgedoc_meta_user_password" VARCHAR(128);

GRANT SELECT ("hedgedoc_meta_user_password") ON ctfnote.settings TO user_postgraphile;
GRANT UPDATE ("hedgedoc_meta_user_password") ON ctfnote.settings TO user_postgraphile;
4 changes: 4 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type CTFNoteConfig = DeepReadOnly<{
documentMaxLength: number;
domain: string;
useSSL: string;
nonpublicPads: boolean;
metaUserName: string;
};

web: {
Expand Down Expand Up @@ -90,6 +92,8 @@ const config: CTFNoteConfig = {
documentMaxLength: Number(getEnv("CMD_DOCUMENT_MAX_LENGTH", "100000")),
domain: getEnv("CMD_DOMAIN", ""),
useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"),
metaUserName: "[email protected]", //has to be an email address
nonpublicPads: Boolean(getEnv("CMD_NON_PUBLIC_PADS", "false")), //TODO check if parsing works here correctly
},
web: {
port: getEnvInt("WEB_PORT"),
Expand Down
6 changes: 6 additions & 0 deletions api/src/discord/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Task, getTaskFromId } from "../database/tasks";
import { CTF, getCtfFromDatabase } from "../database/ctfs";
import { challengesTalkChannelName, getTaskChannel } from "../agile/channels";
import { createPad } from "../../plugins/createTask";
import { registerAndLoginUser } from "../..//utils/hedgedoc";

export const discordArchiveTaskName = "Discord archive";

Expand Down Expand Up @@ -245,13 +246,16 @@ export async function createPadWithoutLimit(
let currentPadLength = 0;
let padIndex = 1;

const cookie = await registerAndLoginUser();

for (const message of messages) {
const messageLength = message.length;

// If adding the current message exceeds the maximum pad length
if (currentPadLength + messageLength > MAX_PAD_LENGTH) {
// Create a new pad
const padUrl = await createPad(
cookie,
`${ctfTitle} ${discordArchiveTaskName} (${padIndex})`,
currentPadMessages.join("\n")
);
Expand All @@ -272,6 +276,7 @@ export async function createPadWithoutLimit(
if (pads.length > 0) {
// Create the final pad for the remaining messages
const padUrl = await createPad(
cookie,
`${ctfTitle} ${discordArchiveTaskName} (${padIndex})`,
currentPadMessages.join("\n")
);
Expand All @@ -286,6 +291,7 @@ export async function createPadWithoutLimit(
}

return await createPad(
cookie,
`${ctfTitle} ${discordArchiveTaskName}`,
firstPadContent
);
Expand Down
13 changes: 13 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import discordHooks from "./discord/hooks";
import { initDiscordBot } from "./discord";
import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many";
import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin";
import hedgedocAuth from "./plugins/hedgedocAuth";
import { initHedgedocPassword } from "./utils/hedgedoc";

function getDbUrl(role: "user" | "admin") {
const login = config.db[role].login;
Expand Down Expand Up @@ -63,10 +65,17 @@ function createOptions() {
discordHooks,
PgManyToManyPlugin,
ProfileSubscriptionPlugin,
hedgedocAuth,
],
ownerConnectionString: getDbUrl("admin"),
enableQueryBatching: true,
legacyRelations: "omit" as const,
async additionalGraphQLContextFromRequest(req, res) {
return {
setHeader: (name: string, value: string | number) =>
res.setHeader(name, value),
};
},
};

if (config.env == "development") {
Expand Down Expand Up @@ -154,6 +163,10 @@ async function main() {

await initDiscordBot();

if (config.pad.nonpublicPads) {
await initHedgedocPassword();
}

app.listen(config.web.port, () => {
//sendMessageToDiscord("CTFNote API started");
console.log(`Listening on :${config.web.port}`);
Expand Down
95 changes: 57 additions & 38 deletions api/src/plugins/createTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { makeExtendSchemaPlugin, gql } from "graphile-utils";
import axios from "axios";
import savepointWrapper from "./savepointWrapper";
import config from "../config";
import { registerAndLoginUser } from "../utils/hedgedoc";

function buildNoteContent(
title: string,
Expand Down Expand Up @@ -32,13 +33,15 @@ function buildNoteContent(
}

export async function createPad(
setCookieHeader: string[],
title: string,
description?: string,
tags?: string[]
): Promise<string> {
const options = {
headers: {
"Content-Type": "text/markdown",
Cookie: setCookieHeader.join("; "),
},

maxRedirects: 0,
Expand Down Expand Up @@ -92,49 +95,65 @@ export default makeExtendSchemaPlugin((build) => {
{ pgClient },
resolveInfo
) => {
const {
rows: [isAllowed],
} = await pgClient.query(`SELECT ctfnote_private.can_play_ctf($1)`, [
ctfId,
]);

if (isAllowed.can_play_ctf !== true) {
return {};
}

const padPathOrUrl = await createPad(title, description, tags);

let padPath: string;
if (padPathOrUrl.startsWith("/")) {
padPath = padPathOrUrl.slice(1);
} else {
padPath = new URL(padPathOrUrl).pathname.slice(1);
}

const padUrl = `${config.pad.showUrl}${padPath}`;
try {
let cookie: string[] | undefined = undefined;
if (config.pad.nonpublicPads) {
cookie = await registerAndLoginUser();
}

return await savepointWrapper(pgClient, async () => {
const {
rows: [newTask],
rows: [isAllowed],
} = await pgClient.query(
`SELECT * FROM ctfnote_private.create_task($1, $2, $3, $4, $5)`,
[title, description ?? "", flag ?? "", padUrl, ctfId]
`SELECT ctfnote_private.can_play_ctf($1)`,
[ctfId]
);
const [row] =
await resolveInfo.graphile.selectGraphQLResultFromTable(
sql.fragment`ctfnote.task`,
(tableAlias, queryBuilder) => {
queryBuilder.where(
sql.fragment`${tableAlias}.id = ${sql.value(newTask.id)}`
);
}
);

return {
data: row,
query: build.$$isQuery,
};
});
if (isAllowed.can_play_ctf !== true) {
return {};
}

const padPathOrUrl = await createPad(
cookie ?? [],
title,
description,
tags
);

let padPath: string;
if (padPathOrUrl.startsWith("/")) {
padPath = padPathOrUrl.slice(1);
} else {
padPath = new URL(padPathOrUrl).pathname.slice(1);
}

const padUrl = `${config.pad.showUrl}${padPath}`;

return await savepointWrapper(pgClient, async () => {
const {
rows: [newTask],
} = await pgClient.query(
`SELECT * FROM ctfnote_private.create_task($1, $2, $3, $4, $5)`,
[title, description ?? "", flag ?? "", padUrl, ctfId]
);
const [row] =
await resolveInfo.graphile.selectGraphQLResultFromTable(
sql.fragment`ctfnote.task`,
(tableAlias, queryBuilder) => {
queryBuilder.where(
sql.fragment`${tableAlias}.id = ${sql.value(newTask.id)}`
);
}
);

return {
data: row,
query: build.$$isQuery,
};
});
} catch (error) {
console.error("error:", error);
throw new Error(`Creating task failed: ${error}`);
}
},
},
},
Expand Down
116 changes: 116 additions & 0 deletions api/src/plugins/hedgedocAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { makeWrapResolversPlugin } from "graphile-utils";
import axios from "axios";
import querystring from "querystring";

class HedgedocAuth {
private static async baseUrl(): Promise<string | null> {
let cleanUrl: string =
process.env.CREATE_PAD_URL || "http://hedgedoc:3000/new";
cleanUrl = cleanUrl.slice(0, -4); //remove '/new' for clean url
return cleanUrl;
}

private static async authPad(
username: string,
password: string,
url: URL
): Promise<string> {
let domain: string;
//if domain does not end in '.[tld]', it will be rejected
//so we add '.local' manually
if (url.hostname.split(".").length == 1) {
domain = `${url.hostname}.local`;
} else {
domain = url.hostname;
}

const email = `${username}@${domain}`;

try {
const res = await axios.post(
url.toString(),
querystring.stringify({
email: email,
password: password,
}),
{
validateStatus: (status) => status === 302,
maxRedirects: 0,
timeout: 5000,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
return (res.headers["set-cookie"] ?? [""])[0].replace("HttpOnly;", "");
} catch (e) {
console.error(e);
return "";
}
}

static async register(username: string, password: string): Promise<string> {
const authUrl = new URL(`${await this.baseUrl()}/register`);
return this.authPad(username, password, authUrl);
}

static async verifyLogin(cookie: string) {
const url = new URL(`${await this.baseUrl()}/me`);

try {
const res = await axios.get(url.toString(), {
validateStatus: (status) => status === 200,
maxRedirects: 0,
timeout: 5000,
headers: {
Cookie: cookie,
},
});

return res.data.status == "ok";
} catch (e) {
return false;
}
}

static async login(username: string, password: string): Promise<string> {
const authUrl = new URL(`${await this.baseUrl()}/login`);
const result = await this.authPad(username, password, authUrl);
const success = await this.verifyLogin(result);
if (!success) {
//create account for existing users that are not registered to Hedgedoc
await this.register(username, password);
return this.authPad(username, password, authUrl);
} else {
return result;
}
}
}

export default makeWrapResolversPlugin({
Mutation: {
login: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async resolve(resolve: any, _source, args, context: any) {
const result = await resolve();
context.setHeader(
"set-cookie",
await HedgedocAuth.login(args.input.login, args.input.password)
);
return result;
},
},
register: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async resolve(resolve: any, _source, args, context: any) {
const result = await resolve();
await HedgedocAuth.register(args.input.login, args.input.password);
context.setHeader(
"set-cookie",
await HedgedocAuth.login(args.input.login, args.input.password)
);
return result;
},
},
},
});
Loading