Files
minabot/src/sharing/routes.ts
2026-09-07 11:57:23 +02:00

170 lines
12 KiB
TypeScript

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<Response>;
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const snowflake = /^\d{17,20}$/;
export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
const app = new Hono<AuthEnv>();
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<string | null> | null = null;
async function botAvatarUrl(): Promise<string | null> {
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<T extends z.ZodType>(req: Request, schema: T): Promise<z.output<T>> {
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;
}