feat: limit Discord sharing and support community allowlists

This commit is contained in:
syntaxbullet
2026-09-04 18:25:09 +02:00
parent d624ee6c3c
commit 32002151e0
9 changed files with 892 additions and 7 deletions

View File

@@ -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");