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. */
|
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||||
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
|
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");
|
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 {
|
return {
|
||||||
|
// Real channel writes are production-only. Test fixtures may inject a stub config.
|
||||||
|
writesEnabled: env.NODE_ENV === "production",
|
||||||
allowedUserIds,
|
allowedUserIds,
|
||||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
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 };
|
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) {
|
function schedule(id: string, delay: number) {
|
||||||
if (stopped) return;
|
if (stopped || config.writesEnabled === false) return;
|
||||||
clearTimeout(timers.get(id));
|
clearTimeout(timers.get(id));
|
||||||
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
|
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
|
||||||
timer.unref(); timers.set(id, timer);
|
timer.unref(); timers.set(id, timer);
|
||||||
}
|
}
|
||||||
function changed(id: string) {
|
function changed(id: string) {
|
||||||
|
if (config.writesEnabled === false) return;
|
||||||
const current = row(id);
|
const current = row(id);
|
||||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||||
update(id, { dirty: true });
|
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);
|
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
|
||||||
}
|
}
|
||||||
async function deliver(id: string) {
|
async function deliver(id: string) {
|
||||||
if (stopped) return;
|
if (stopped || config.writesEnabled === false) return;
|
||||||
const current = row(id);
|
const current = row(id);
|
||||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||||
const owner = db.select().from(users).where(eq(users.id, id)).get();
|
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));
|
return c.body(new Uint8Array(image));
|
||||||
});
|
});
|
||||||
app.post('/', async c => {
|
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.');
|
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
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.
|
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
||||||
for (const current of db.select().from(liveDiscordCards).all()) {
|
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.' });
|
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()));
|
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 });
|
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||||
});
|
});
|
||||||
app.post("/discord/send", async c => {
|
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;
|
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 (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.");
|
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||||
|
|||||||
Reference in New Issue
Block a user