fix: block development writes to Discord channels
This commit is contained in:
@@ -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(),
|
||||
|
||||
36
src/sharing/development-writes.test.ts
Normal file
36
src/sharing/development-writes.test.ts
Normal 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(); }
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user