feat: limit Discord sharing and support community allowlists
This commit is contained in:
@@ -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