fix: block development writes to Discord channels

This commit is contained in:
syntaxbullet
2026-09-07 11:57:23 +02:00
parent 6d1d12953c
commit e6002ad619
4 changed files with 45 additions and 3 deletions

View File

@@ -1,10 +1,12 @@
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[]; writesEnabled?: boolean };
/** Read only from the server entrypoint; never include credentials in public config. */
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
return {
// Real channel writes are production-only. Test fixtures may inject a stub config.
writesEnabled: env.NODE_ENV === "production",
allowedUserIds,
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),

View File

@@ -0,0 +1,36 @@
import { expect, test } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { createApi } from '../api';
import { liveDiscordCards } from '../db/schema';
import { readDiscordSharingConfig } from './config';
test('real Discord channel writes are enabled only in production', () => {
for (const NODE_ENV of ['development', 'test', undefined]) {
expect(readDiscordSharingConfig({ NODE_ENV, DISCORD_BOT_TOKEN: 'token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }).writesEnabled).toBe(false);
}
expect(readDiscordSharingConfig({ NODE_ENV: 'production' }).writesEnabled).toBe(true);
});
test('development preserves copied live state, allows PNG previews, and blocks all channel sends', async () => {
const f = fixture();
f.db.insert(liveDiscordCards).values({ userId: 'alice', channelId: '223456789012345678', messageId: '423456789012345678', nonce: 'copied-nonce', theme: 'light', status: 'active', dirty: true, updatedAt: 1 }).run();
const before = f.db.select().from(liveDiscordCards).all();
const requests: string[] = [];
const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-signing-secret-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async url => {
requests.push(url); throw new Error('Development contacted Discord');
}, readDiscordSharingConfig({ NODE_ENV: 'development', DISCORD_BOT_TOKEN: 'real-looking-token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }));
const headers = { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' };
try {
for (const path of ['/sharing/live', '/sharing/discord/send']) {
const response = await app.request(`${f.origin}/api${path}`, { method: 'POST', headers, body: JSON.stringify({ theme: 'light' }) });
expect(response.status).toBe(403);
}
await app.flushLiveSharing('alice');
const preview = await app.request(`${f.origin}/api/sharing/live/preview`, { headers });
expect(preview.status).toBe(200);
expect(preview.headers.get('Content-Type')).toBe('image/png');
await Bun.sleep(2100);
expect(requests).toEqual([]);
expect(f.db.select().from(liveDiscordCards).all()).toEqual(before);
} finally { app.stopLiveSharing(); f.close(); }
});

View File

@@ -33,12 +33,13 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
}
function schedule(id: string, delay: number) {
if (stopped) return;
if (stopped || config.writesEnabled === false) return;
clearTimeout(timers.get(id));
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
timer.unref(); timers.set(id, timer);
}
function changed(id: string) {
if (config.writesEnabled === false) return;
const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return;
update(id, { dirty: true });
@@ -52,7 +53,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
}
async function deliver(id: string) {
if (stopped) return;
if (stopped || config.writesEnabled === false) return;
const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return;
const owner = db.select().from(users).where(eq(users.id, id)).get();
@@ -155,6 +156,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
return c.body(new Uint8Array(image));
});
app.post('/', async c => {
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
const user = c.get('user');
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
@@ -183,6 +185,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
});
// Recover only known message edits. Never risk duplicating an interrupted first post.
for (const current of db.select().from(liveDiscordCards).all()) {
if (config.writesEnabled === false) break;
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
}

View File

@@ -99,6 +99,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
});
app.post("/discord/send", async c => {
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403);
const userId = c.get("user").id;
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");