diff --git a/.env.example b/.env.example index 4278219..375e515 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 96f8a86..2b49f37 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/drizzle/0006_sharing_limits.sql b/drizzle/0006_sharing_limits.sql new file mode 100644 index 0000000..10f14b3 --- /dev/null +++ b/drizzle/0006_sharing_limits.sql @@ -0,0 +1,5 @@ +CREATE TABLE `sharing_limits` ( + `key` text PRIMARY KEY NOT NULL, + `started_at` integer NOT NULL, + `count` integer NOT NULL +); diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..5f0e747 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 323aa37..bde93af 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1788536554543, "tag": "0005_bored_firelord", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1788539042148, + "tag": "0006_sharing_limits", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index efceb74..5bc1e58 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -60,3 +60,9 @@ export const combinedCharts = sqliteTable('combined_charts', { habitIds: text('habit_ids', { mode: 'json' }).$type().notNull(), settings: text('settings', { mode: 'json' }).$type().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(), +}); diff --git a/src/sharing/config.ts b/src/sharing/config.ts index 6d0d97a..4eae9d9 100644 --- a/src/sharing/config.ts +++ b/src/sharing/config.ts @@ -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(), }; diff --git a/src/sharing/routes.test.ts b/src/sharing/routes.test.ts index 75091a9..4b03378 100644 --- a/src/sharing/routes.test.ts +++ b/src/sharing/routes.test.ts @@ -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); +}); diff --git a/src/sharing/routes.ts b/src/sharing/routes.ts index 733140a..f781b1a 100644 --- a/src/sharing/routes.ts +++ b/src/sharing/routes.ts @@ -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 { 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 { + 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");