feat: limit Discord sharing and support community allowlists
This commit is contained in:
@@ -60,3 +60,9 @@ export const combinedCharts = sqliteTable('combined_charts', {
|
||||
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
|
||||
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
|
||||
}, t => [index('combined_charts_user_idx').on(t.userId)]);
|
||||
|
||||
export const sharingLimits = sqliteTable('sharing_limits', {
|
||||
key: text('key').primaryKey(),
|
||||
startedAt: integer('started_at').notNull(),
|
||||
count: integer('count').notNull(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type DiscordSharingConfig = { token: string; channelId: string };
|
||||
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
|
||||
|
||||
/** 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 {
|
||||
allowedUserIds,
|
||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||
};
|
||||
|
||||
@@ -11,9 +11,10 @@ const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789
|
||||
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
|
||||
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
|
||||
const f = fixture(); fixtures.push(f);
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
|
||||
let timestamp = Date.parse("2026-09-04T12:00:00Z");
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => timestamp, request, config);
|
||||
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { f, call, connect: () => call("/discord") };
|
||||
return { f, call, advance: (seconds: number) => { timestamp += seconds * 1000; }, connect: () => call("/discord") };
|
||||
}
|
||||
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
||||
function upload(id = crypto.randomUUID()) {
|
||||
@@ -87,12 +88,14 @@ test("delivery sends exactly the PNG with mentions disabled and deduplicates ret
|
||||
});
|
||||
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
|
||||
let posts = 0;
|
||||
const { call, connect } = setup(async (_, init) => {
|
||||
const { call, connect, advance } = setup(async (_, init) => {
|
||||
if (init?.method !== "POST") return Response.json(channel);
|
||||
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
|
||||
});
|
||||
await connect(); const id = crypto.randomUUID();
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||
advance(60);
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
|
||||
});
|
||||
@@ -103,3 +106,32 @@ test("invalid files and absent bot configuration cannot send", async () => {
|
||||
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
|
||||
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
test("posting quotas block new deliveries, allow duplicate checks, and expire", async () => {
|
||||
let posts = 0;
|
||||
const { f, call, advance } = setup(async (_, init) => {
|
||||
if (init?.method === "POST") { posts++; return Response.json({ id: "423456789012345678" }); }
|
||||
return Response.json(channel);
|
||||
});
|
||||
const id = crypto.randomUUID();
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
|
||||
const limited = await call("/discord/send", "POST", upload());
|
||||
expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).toBe("60");
|
||||
expect((await call("/discord/send", "POST", upload(), "b")).status).toBe(200);
|
||||
expect(posts).toBe(2);
|
||||
f.sqlite.query("UPDATE sharing_limits SET count = 10 WHERE key = ?").run(`channel:${channel.id}`);
|
||||
advance(30);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(429);
|
||||
advance(30);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(200);
|
||||
expect(posts).toBe(3);
|
||||
});
|
||||
|
||||
test("an allowlist blocks unauthorized posting without preventing exports", async () => {
|
||||
const { call } = setup(undefined, { ...botConfig, allowedUserIds: ["bob"] });
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(403);
|
||||
expect((await (await call("/discord")).json()).connected).toBe(false);
|
||||
expect((await (await call("/discord", "GET", undefined, "b")).json()).connected).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { discordDeliveries } from "../db/schema";
|
||||
import { discordDeliveries, sharingLimits } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { shareInput, type ShareData } from "./contracts";
|
||||
@@ -93,11 +93,14 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
app.get("/discord", async c => {
|
||||
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
|
||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId))
|
||||
return c.json({ connected: false, message: "Discord sharing is limited to approved community members. PNG download remains available." });
|
||||
const destination = await channel();
|
||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||
});
|
||||
app.post("/discord/send", async c => {
|
||||
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.");
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||
@@ -112,7 +115,38 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
}
|
||||
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
|
||||
const timestamp = now();
|
||||
// Reserve both quotas and the delivery before any asynchronous outbound work.
|
||||
// SQLite makes limits survive restarts and coordinate concurrent workers.
|
||||
const retryAfter = db.transaction(tx => {
|
||||
const raced = tx.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||
if (raced) {
|
||||
if (raced.userId !== userId || raced.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return -1;
|
||||
}
|
||||
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
|
||||
const quotas = [
|
||||
{ key: `channel:${config.channelId}`, maximum: 10 },
|
||||
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
|
||||
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
|
||||
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
|
||||
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
|
||||
for (const { key, row } of quotas) {
|
||||
const current = row && timestamp < row.startedAt + 60000;
|
||||
tx.insert(sharingLimits).values({ key, startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 })
|
||||
.onConflictDoUpdate({ target: sharingLimits.key, set: { startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 } }).run();
|
||||
}
|
||||
tx.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: timestamp }).run();
|
||||
return 0;
|
||||
}, { behavior: "immediate" });
|
||||
if (retryAfter === -1) {
|
||||
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
|
||||
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
|
||||
}
|
||||
if (retryAfter > 0) {
|
||||
c.header("Retry-After", String(retryAfter));
|
||||
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
|
||||
}
|
||||
const attachment = new FormData();
|
||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
|
||||
attachment.set("files[0]", image, "minabot-progress.png");
|
||||
|
||||
Reference in New Issue
Block a user