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

@@ -14,3 +14,5 @@ AUTH_COOKIE_SECRET=
# View Channel, Send Messages, and Attach Files in this channel.
DISCORD_BOT_TOKEN=
DISCORD_SHARING_CHANNEL_ID=
# Optional comma-separated Discord user IDs permitted to post. Empty permits all accounts.
DISCORD_SHARING_ALLOWED_USER_IDS=

View File

@@ -335,3 +335,11 @@ It excludes session tokens and credentials. `DELETE /api/account` requires match
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
revokes every session. Discord posts and retained operator backups are not erased
by this action.
Discord sharing reserves persistent quotas before posting: one new delivery per
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
existing delivery does not consume quota. Rejected attempts consume quota too;
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
(comma-separated Discord IDs) restricts posting to community members you approve.
An empty list permits all signed-in accounts. Restricted users can still preview
and download PNGs. Quota rows expire and are pruned after 24 hours.

View File

@@ -0,0 +1,5 @@
CREATE TABLE `sharing_limits` (
`key` text PRIMARY KEY NOT NULL,
`started_at` integer NOT NULL,
`count` integer NOT NULL
);

View File

@@ -0,0 +1,788 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b5822e36-a1a9-42dd-9439-9490297bc097",
"prevId": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"discord_deliveries": {
"name": "discord_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_hash": {
"name": "image_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message_url": {
"name": "message_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"discord_deliveries_user_id_users_id_fk": {
"name": "discord_deliveries_user_id_users_id_fk",
"tableFrom": "discord_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sharing_limits": {
"name": "sharing_limits",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"closed_at": {
"name": "closed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -43,6 +43,13 @@
"when": 1788536554543,
"tag": "0005_bored_firelord",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1788539042148,
"tag": "0006_sharing_limits",
"breakpoints": true
}
]
}

View File

@@ -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(),
});

View File

@@ -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(),
};

View File

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

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