Skip to content
Merged
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
27 changes: 27 additions & 0 deletions apps/bot/src/channel-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";

import { isChannelInGuild } from "./channel-scope.js";

const GUILD = "111111111111111111";
const OTHER = "222222222222222222";

describe("isChannelInGuild", () => {
it("accepts a channel of the delivery's own guild", () => {
expect(isChannelInGuild({ id: "1", guildId: GUILD }, GUILD)).toBe(true);
});

it("rejects a channel on another server", () => {
expect(isChannelInGuild({ id: "1", guildId: OTHER }, GUILD)).toBe(false);
});

it("rejects channels without a guild, such as DMs", () => {
expect(isChannelInGuild({ id: "1" }, GUILD)).toBe(false);
expect(isChannelInGuild({ id: "1", guildId: null }, GUILD)).toBe(false);
});

it("rejects everything when the guild's Discord id is unknown", () => {
expect(isChannelInGuild({ id: "1", guildId: GUILD }, null)).toBe(false);
expect(isChannelInGuild({ id: "1", guildId: GUILD }, undefined)).toBe(false);
expect(isChannelInGuild({ id: "1", guildId: "" }, "")).toBe(false);
});
});
17 changes: 17 additions & 0 deletions apps/bot/src/channel-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Whether a fetched Discord channel lives in the guild a delivery belongs to.
*
* Channel ids in a form's settings or a guild's bot config are only checked to
* be snowflakes when they are saved, and `client.channels.fetch` resolves any
* channel the bot can see, on any server it was invited to. Without this check
* a form manager could point the review channel at a server of their own and
* receive the applicants' answers there. DM channels have no guild and never
* qualify.
*/
export function isChannelInGuild(
channel: object,
discordGuildId: string | null | undefined,
): boolean {
if (!discordGuildId) return false;
return "guildId" in channel && channel.guildId === discordGuildId;
}
16 changes: 14 additions & 2 deletions apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type Client,
} from "discord.js";

import { isChannelInGuild } from "./channel-scope.js";
import { config } from "./config.js";
import { isExhausted, isTerminalDmCode, nextAttemptAt } from "./delivery-policy.js";
import { describeError } from "./log.js";
Expand Down Expand Up @@ -107,7 +108,10 @@ async function deliverReview(client: Client, row: PendingRow): Promise<Outcome>
// bot config. Look both up via the submission this notification is about.
const sub = await prisma.submission.findUnique({
where: { id: payload.submissionId },
select: { form: { select: { settings: true } }, guild: { select: { botConfig: true } } },
select: {
form: { select: { settings: true } },
guild: { select: { botConfig: true, discordGuildId: true } },
},
});
const botCfg = parseBotConfig(sub?.guild.botConfig);
const channelId = parseFormSettings(sub?.form.settings).reviewChannelId ?? botCfg.reviewChannelId;
Expand All @@ -120,6 +124,10 @@ async function deliverReview(client: Client, row: PendingRow): Promise<Outcome>
console.warn(`[bot] review channel ${channelId} not usable — dropping ${row.id}.`);
return true;
}
if (!isChannelInGuild(channel, sub?.guild.discordGuildId)) {
console.warn(`[bot] review channel ${channelId} is not in the form's guild — dropping ${row.id}.`);
return true;
}

const url = dashboardSubmissionUrl(config.apiBaseUrl, row.guildId, payload.submissionId);
const lines = [`**${s.applicant}:** ${payload.applicantName ?? s.anonymous}`];
Expand Down Expand Up @@ -168,7 +176,7 @@ async function deliverLog(client: Client, row: PendingRow): Promise<Outcome> {

const guild = await prisma.guild.findUnique({
where: { id: row.guildId },
select: { botConfig: true },
select: { botConfig: true, discordGuildId: true },
});
const botCfg = parseBotConfig(guild?.botConfig);
const channelId = botCfg.logChannelId;
Expand All @@ -190,6 +198,10 @@ async function deliverLog(client: Client, row: PendingRow): Promise<Outcome> {
console.warn(`[bot] log channel ${channelId} not usable — dropping ${row.id}.`);
return true;
}
if (!isChannelInGuild(channel, guild?.discordGuildId)) {
console.warn(`[bot] log channel ${channelId} is not in the guild — dropping ${row.id}.`);
return true;
}

const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (dashboardUrl) {
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What MSK Forms can do. Step-by-step instructions with screenshots are in the
- **Powerful builder:** 20+ field types, multi-step pages, conditional logic, quiz scoring, calculated fields, and per-form scheduling with an optional public countdown.
- **Real feedback loop:** every submission gets a live status page; applicants follow along without an account and can withdraw, export, or delete their data (GDPR). One active submission per person by default.
- **Discord-native review:** new submissions post to a review channel with Accept / Reject buttons, status DMs, automatic role grants, and a full activity log, all in your server's language.
Review and log channels must be on your own server; the bot ignores channels on other servers.
- **Make it yours:** accent color, logo, custom CSS with a live preview, form categories, a public form hub, and custom domains with automatic TLS.
- **Team & workflow:** roles with per-form reviewer access, a Kanban board, bulk actions, custom statuses, automations, A/B tests, and CSV / XLSX / JSON / PDF exports.
- **Integrations:** outgoing webhooks, Zapier / Make, and a REST API.
Expand Down
Loading