import { createHash } from "node:crypto"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { eq, lt } from "drizzle-orm"; import { z } from "zod"; import type { AppDatabase, AuthEnv, createAuth } from "../auth"; import { discordDeliveries, sharingLimits } from "../db/schema"; import { HabitService, ApiError } from "../habits/service"; import { shade } from "../habits/calendar"; import { shareInput, type ShareData } from "./contracts"; import type { DiscordSharingConfig } from "./config"; export type DiscordFetch = (url: string, init?: RequestInit) => Promise; const MAX_IMAGE_BYTES = 4 * 1024 * 1024; const snowflake = /^\d{17,20}$/; export function createSharingRoutes(db: AppDatabase, auth: ReturnType, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) { const app = new Hono(); const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId)); const channelEndpoint = `https://discord.com/api/v10/channels/${config.channelId}`; const headers = { Authorization: `Bot ${config.token}` }; let cachedBotAvatar: { url: string | null; expiresAt: number } | null = null; let loadingBotAvatar: Promise | null = null; async function botAvatarUrl(): Promise { if (!configured) return null; if (cachedBotAvatar && cachedBotAvatar.expiresAt > now()) return cachedBotAvatar.url; if (loadingBotAvatar) return loadingBotAvatar; loadingBotAvatar = (async () => { let url: string | null = null; try { const response = await request("https://discord.com/api/v10/users/@me", { headers, redirect: "error", signal: AbortSignal.timeout(5000) }); const bot = response.ok ? await response.json() as { id?: string; bot?: boolean; avatar?: string | null; discriminator?: string } : null; if (bot?.bot && snowflake.test(bot.id ?? "")) { if (bot.avatar && /^(?:a_)?[a-fA-F0-9]{32}$/.test(bot.avatar)) { url = `https://cdn.discordapp.com/avatars/${bot.id}/${bot.avatar}.png?size=128`; } else if (!bot.avatar) { const index = bot.discriminator && /^\d{4}$/.test(bot.discriminator) && bot.discriminator !== "0000" ? Number(bot.discriminator) % 5 : Number((BigInt(bot.id!) >> 22n) % 6n); url = `https://cdn.discordapp.com/embed/avatars/${index}.png`; } } } catch { /* Keep card exports available when Discord cannot supply the avatar. */ } cachedBotAvatar = { url, expiresAt: now() + (url ? 300000 : 30000) }; return url; })(); try { return await loadingBotAvatar; } finally { loadingBotAvatar = null; } } let cachedChannel: { name: string; guildId: string; channelUrl: string; expiresAt: number } | null = null; async function channel() { if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server."); if (cachedChannel && cachedChannel.expiresAt > now()) return cachedChannel; let response: Response; try { response = await request(channelEndpoint, { headers, redirect: "error", signal: AbortSignal.timeout(10000) }); } catch { throw new ApiError(502, "Could not reach Discord. Please try again."); } if (!response.ok) { const message = response.status === 401 ? "The Discord bot token is invalid. Update the server configuration." : response.status === 403 || response.status === 404 ? "The bot cannot access the sharing channel. Check its channel permissions." : response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Could not load the Discord sharing channel."; throw new ApiError(response.status === 429 ? 429 : 502, message); } const data = await response.json().catch(() => null) as { id?: string; type?: number; name?: string; guild_id?: string } | null; if (data?.id !== config.channelId || !snowflake.test(data.guild_id ?? "") || ![0, 5, 10, 11, 12].includes(data.type ?? -1)) throw new ApiError(422, "Set the sharing channel to a Discord server text channel or thread."); cachedChannel = { name: data.name ? `#${data.name}` : "Discord sharing channel", guildId: data.guild_id!, channelUrl: `https://discord.com/channels/${data.guild_id}/${config.channelId}`, expiresAt: now() + 60000 }; return cachedChannel; } app.use("*", auth.requireAuth); app.use("*", async (c, next) => c.req.method === "GET" ? next() : auth.requireSameOrigin(c, next)); app.use("*", bodyLimit({ maxSize: MAX_IMAGE_BYTES + 65536, onError: c => c.json({ error: "Choose a progress image smaller than 4 MB." }, 413) })); async function json(req: Request, schema: T): Promise> { if (req.headers.get("Content-Type")?.split(";")[0] !== "application/json") throw new ApiError(400, "Expected application/json"); const value = await req.json().catch(() => { throw new ApiError(400, "Malformed JSON"); }); const parsed = schema.safeParse(value); if (!parsed.success) throw new ApiError(422, "Check your selection and try again."); return parsed.data; } app.post("/preview", async c => { const input = await json(c.req.raw, shareInput); const service = new HabitService(db, c.get("user"), now()); service.sync(); if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today."); const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => { const habit = service.current(id); const chart = service.calendar([id], input.from, input.to, service.settings(id), false); return { name: habit.name, color: chart.settings.mainColor, legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor, partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] }, completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) }; }) }; result.botAvatarUrl = await botAvatarUrl(); return c.json(result); }); 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 => { if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403); 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")); const image = form.get("image"); if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB."); const bytes = Buffer.from(await image.arrayBuffer()); if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending."); const destination = await channel(); const imageHash = createHash("sha256").update(bytes).update(`bot:${config.channelId}`).digest("hex"); const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get(); if (previous) { 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 }); } 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: "mina-habits-progress.png", description: "Progress card shared from Mina Habits" }] })); attachment.set("files[0]", image, "mina-habits-progress.png"); let response: Response; try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); } catch { return c.json({ status: "uncertain" }); } if (!response.ok) { if (response.status >= 500) return c.json({ status: "uncertain" }); db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run(); cachedChannel = null; throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads)."); } const message = await response.json().catch(() => null) as { id?: string } | null; const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null; db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run(); return c.json({ status: "sent", messageUrl: messageUrl ?? undefined }); }); return app; }